diff --git a/CHANGELOG.md b/CHANGELOG.md index 03ad87d..7a64291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.1.7] - 2026-06-16 + +### Added + +- Bundle the `keito-time-track` agent skill with the CLI so `keito skill install` no longer depends on an external skill repository by default. +- Add a gstack-style `./setup` entrypoint for source-checkout installs with Claude Code and Codex target selection. +- Add `keito skill team-init optional|required` to write repo-level agent guidance and `.keito/config.example.yml`. +- Add packaged lifecycle hook tests that exercise the bundled skill with a fake Keito CLI. +- Add AI-native services positioning notes for agent billing, project attribution, and client profitability. + +### Changed + +- Keep the audit-first external skill install path available through `--source` and `--skip-skills-add`. +- Document the bundled skill install, repo setup workflow, and release positioning in README and the agent guide. +- Accept `calendar` and `desktop` as time entry source values alongside `web`, `cli`, `api`, and `agent`. + ## [0.1.6] - 2026-05-12 ### Changed diff --git a/Cargo.lock b/Cargo.lock index e741ec0..522e9cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,7 +872,7 @@ dependencies = [ [[package]] name = "keito-cli" -version = "0.1.6" +version = "0.1.7" dependencies = [ "assert_cmd", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d1618f5..cec1804 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "keito-cli" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "CLI for AI agents and humans to track billable time against the Keito platform" license = "MIT" diff --git a/README.md b/README.md index 5bcb8b8..4999608 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Track billable time against the [Keito](https://keito.ai) platform — from the terminal or from an AI agent. +Keito is billing and profitability infrastructure for AI-native services teams: +agent work is recorded against the same clients, projects, tasks, and invoices +as human work, with metadata for margin and audit analysis. +

keito demo

@@ -91,14 +95,137 @@ Exit codes tell you exactly what happened — no need to parse error messages. S ## Agent Skill -The Keito Agent Skill is installed from the GitHub skill repo, not from an npm package: +The Keito Agent Skill ships with this CLI. It brings Keito to the places where +agents already work by installing lifecycle hooks for Claude Code and OpenAI +Codex CLI, then recording one `source=agent` time entry when a tracked coding +session ends. + +### Requirements + +- Claude Code and/or Codex CLI +- Git, Bash, and `jq` +- Keito credentials via `keito auth login` or `KEITO_API_KEY` + + `KEITO_ACCOUNT_ID` +- macOS/Linux: `./setup` can install the CLI through Homebrew or release + tarballs if `keito` is not already on `PATH` +- Windows: install the binary from + [Releases](https://github.com/osodevops/keito-cli/releases) first, then run + the skill commands from Git Bash or WSL where `bash` and `jq` are available + +### Step 1: Install on your machine + +Open Claude Code or Codex and paste this. The agent can run the same +source-checkout flow that gstack documents: + +```sh +git clone --single-branch --depth 1 https://github.com/osodevops/keito-cli.git ~/.keito/keito-cli +cd ~/.keito/keito-cli +./setup +``` + +`./setup` installs the CLI if needed, installs the bundled `keito-time-track` +skill, and configures hooks for Claude Code, Codex, or both. In an interactive +terminal it asks which host to configure. In non-interactive runs it defaults +to both. + +Target one host explicitly when needed: + +```sh +~/.keito/keito-cli/setup --host claude +~/.keito/keito-cli/setup --host codex +~/.keito/keito-cli/setup --host both +``` + +If the CLI is already installed, this equivalent command uses the bundled skill +without the source checkout: ```sh -keito auth login keito skill install ``` -`keito skill install` uses `npx` only to run the open skills installer. The installer package is pinned to `skills@1.5.6` by default and can be overridden intentionally with `KEITO_SKILLS_PACKAGE`. +Choose one target when needed: + +```sh +keito skill install --agent claude-code +keito skill install --agent codex +``` + +Check readiness: + +```sh +keito skill doctor +keito skill status --json +``` + +### Step 2: Configure each client repo + +From each client repository, invoke the skill once to map that worktree to a +Keito client, project, and task: + +```text +/track-time-keito +``` + +This writes `.keito/config.yml`, which is intentionally repo-local and should +not be committed. + +### Team Mode + +For shared repos, use the same model as gstack team mode: the skill remains +globally installed, and the repository commits only agent guidance plus an +example config. + +From inside the shared repo: + +```sh +~/.keito/keito-cli/setup --team optional +git add AGENTS.md CLAUDE.md .gitignore .keito/config.example.yml +git commit -m "add Keito tracking guidance for agent work" +``` + +Use `required` instead of `optional` when agents must stop before billable +coding work until `/track-time-keito` has configured the repo: + +```sh +~/.keito/keito-cli/setup --team required +``` + +If the CLI is already installed and the global skill is already configured, you +can run only the repo bootstrap: + +```sh +keito skill team-init optional +keito skill team-init required +``` + +`team-init` writes `AGENTS.md`, `CLAUDE.md`, `.gitignore`, and +`.keito/config.example.yml`. It does not vendor the skill into the repo. Do not +commit `.keito/config.yml`; that file contains the local project/task mapping +created by `/track-time-keito`. + +### How It Works + +- `./setup` is the gstack-style source-checkout installer. It keeps a stable + checkout at `~/.keito/keito-cli`, installs the CLI if needed, then calls + `keito skill install --source bundled`. +- `keito skill install` materializes the bundled `keito-time-track` skill and + copies it into agent home directories: + `~/.claude/skills/keito-time-track` for Claude Code and + `~/.codex/skills/keito-time-track` for Codex. +- The hook installers merge lifecycle hooks into `~/.claude/settings.json` and + `~/.codex/hooks.json` using `jq`. +- `/track-time-keito` verifies auth, asks for the Keito client/project/task for + the current repo, and writes `.keito/config.yml`. +- On agent session start, the hook records local session state. On session end, + the hook logs one Keito time entry with `source=agent` and metadata such as + repo path, branch, commit, and agent type. + +Audit-first external install remains available: + +```sh +npx --yes skills@1.5.6 add osodevops/keito-skill -g -a codex -a claude-code -s keito-time-track -y --copy +keito skill install --skip-skills-add +``` ## Features diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 5238386..12e87c9 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -28,6 +28,47 @@ keito auth status --json Exit code `0` = ready. Exit code `1` = fix credentials. +## Skill Installation + +Install the packaged Keito skill into Claude Code and Codex: + +```sh +keito skill install +``` + +Install one host only when needed: + +```sh +keito skill install --agent claude-code +keito skill install --agent codex +``` + +For source-checkout installs, use the gstack-style setup entrypoint: + +```sh +git clone --single-branch --depth 1 https://github.com/osodevops/keito-cli.git ~/.keito/keito-cli +cd ~/.keito/keito-cli +./setup +``` + +From each client repository, invoke the skill once: + +```text +/track-time-keito +``` + +This writes `.keito/config.yml` with the selected Keito client, project, and +task IDs. Do not commit that file. For shared repositories, run +`keito skill team-init optional` or `keito skill team-init required` to add +agent guidance plus `.keito/config.example.yml`. + +Check readiness: + +```sh +keito skill doctor +keito skill status --json +``` + ## Discovery ### List Projects diff --git a/docs/ai-native-services-positioning.md b/docs/ai-native-services-positioning.md new file mode 100644 index 0000000..705cae2 --- /dev/null +++ b/docs/ai-native-services-positioning.md @@ -0,0 +1,97 @@ +# Keito Positioning: AI-Native Services + +Keito should be positioned as billing and profitability infrastructure for +AI-native services companies. + +## Why Now + +YC's Summer 2026 Request for Startups explicitly calls for **AI-Native Service +Companies**: companies that do not sell software, but sell the completed +service. YC also calls for **Software for Agents**, arguing that agents need +machine-readable APIs, MCPs, CLIs, and documentation so they can use tools +programmatically without a human in the loop. + +Sources: +- https://www.ycombinator.com/rfs +- https://github.com/garrytan/gstack + +The category implication is direct: if agents are doing client work, the +software around services businesses has to become agent-native too. Billing, +time tracking, project mapping, approvals, and profitability reporting cannot +remain browser-only workflows for humans. + +## Category Frame + +Keito is not LLM cost observability. + +LLM observability answers: +- Which model ran? +- How many tokens did it use? +- What did the inference cost? +- Was latency or quality acceptable? + +Keito answers: +- Which client and project did the agent work for? +- What task was delivered? +- What time or effort should flow into billing? +- What did that work cost internally? +- What margin did the client, project, workflow, or agent generate? + +Lightspeed's investment note on Paid frames the same gap as missing economic +infrastructure for AI agents: traditional SaaS pricing breaks when agents are +autonomous digital workers, and the missing capabilities are value proof, +custom pricing, outcome and hybrid models, cost tracking, and customer +profitability dashboards. + +Source: +- https://lsvp.com/stories/the-ai-agent-economy-has-a-19-trillion-problem-our-investment-in-paid/ + +## Buyer + +Primary buyer: +- AI-native agencies and consultancies +- Professional-services teams using coding, research, support, finance, or + operations agents +- YC-style startups replacing outsourced services with agent-run delivery + +The buyer wants software-margin economics without losing service-business +controls: client attribution, auditability, utilization, approval, invoices, +and profitability. + +## Messaging + +Use this compact positioning: + +> Keito is the billing and profitability layer for AI-native services teams. +> Agents log work against clients, projects, and tasks the same way humans do, +> so firms can invoice accurately, prove value, and see margin by customer and +> workflow. + +Supporting claims: +- "Bring Keito to where agents work": ship a CLI plus Codex and Claude Code + skills, not a dashboard-only workflow. +- "Track billable work, not just token spend": connect agent activity to + projects, invoices, and margin. +- "One worktree, one client/project/task mapping": repo-local setup keeps + agent time from leaking across clients. +- "Best-effort hooks, deterministic CLI": agent sessions should never fail + because billing telemetry failed, but failures must be logged and recoverable. + +## Product Implications For The CLI + +The CLI should prioritize: +- Bundled agent skills for Codex and Claude Code. +- A gstack-style install flow: install globally, then bootstrap each repo. +- Deterministic `--json` commands and stable exit codes. +- `source=agent` entries with metadata for session ID, agent type, skill, git + branch, git revision, duration, and draft status. +- Repo-local `.keito/config.yml` selected by a wizard and excluded from git. +- `keito skill status` and `keito skill doctor` for agent-readable readiness. + +MindStudio's monetization guide makes the margin reason explicit: successful +AI-agent businesses need disciplined cost tracking and 60-75% gross margins; +cost creep at the interaction level can erase margin. Keito should connect +that cost discipline to the professional-services billing system. + +Source: +- https://www.mindstudio.ai/blog/build-monetize-ai-agents-business diff --git a/setup b/setup new file mode 100755 index 0000000..97021c0 --- /dev/null +++ b/setup @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Keito setup - install the bundled agent skill into Claude Code and/or Codex. +set -euo pipefail + +HOST="auto" +TEAM_MODE="" + +usage() { + cat >&2 <<'EOF' +Usage: ./setup [--host auto|both|claude|codex] [--team optional|required] + +Installs the Keito Time Track skill and lifecycle hooks into local agent tools. +Run from a checkout of keito-cli, or use `keito skill install` directly if the +CLI is already installed. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --host) + [ $# -ge 2 ] || { echo "Missing value for --host" >&2; exit 1; } + HOST="$2" + shift 2 + ;; + --host=*) + HOST="${1#--host=}" + shift + ;; + --team) + [ $# -ge 2 ] || { echo "Missing value for --team" >&2; exit 1; } + TEAM_MODE="$2" + shift 2 + ;; + --team=*) + TEAM_MODE="${1#--team=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +case "$HOST" in + auto|both|claude|codex) ;; + *) echo "Unknown --host value: $HOST" >&2; exit 1 ;; +esac + +case "$TEAM_MODE" in + ""|optional|required) ;; + *) echo "Unknown --team value: $TEAM_MODE" >&2; exit 1 ;; +esac + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) + +if ! command -v keito >/dev/null 2>&1; then + "$script_dir/skills/keito-time-track/scripts/install-cli.sh" +fi + +if ! command -v keito >/dev/null 2>&1; then + echo "Keito CLI is still not on PATH after install. Add it to PATH and rerun ./setup." >&2 + exit 1 +fi + +if [ "$HOST" = "auto" ] && [ -t 0 ]; then + echo "" + echo "Install Keito skill for:" + echo "" + echo " 1) Claude Code + Codex (recommended)" + echo " 2) Claude Code only" + echo " 3) Codex only" + echo "" + printf "Choice [1/2/3] (default: 1): " + read -r choice + case "${choice:-1}" in + 2) HOST="claude" ;; + 3) HOST="codex" ;; + *) HOST="both" ;; + esac +elif [ "$HOST" = "auto" ]; then + HOST="both" +fi + +args=(skill install --source bundled) +case "$HOST" in + both) + ;; + claude) + args+=(--agent claude-code) + ;; + codex) + args+=(--agent codex) + ;; +esac + +keito "${args[@]}" + +if ! keito --json auth status >/dev/null 2>&1; then + echo "" + echo "Keito CLI is installed, but authentication is not ready." + echo "Run: keito auth login" +fi + +if [ -n "$TEAM_MODE" ]; then + keito skill team-init "$TEAM_MODE" +fi + +echo "" +echo "Next: cd into each client repo and run /track-time-keito." diff --git a/skills/keito-time-track/SKILL.md b/skills/keito-time-track/SKILL.md new file mode 100644 index 0000000..65fe684 --- /dev/null +++ b/skills/keito-time-track/SKILL.md @@ -0,0 +1,68 @@ +--- +name: keito-time-track +description: > + Track billable AI coding-session time to Keito through the Keito CLI. Use + when the user asks to set up repository time tracking, install or configure + the Keito skill, log agent work, check tracking status, pause or resume + tracking, disable tracking, or review today's source=agent time entries. +--- + +# Keito Time Track + +You are helping the user manage Keito time tracking for coding sessions in +this repository. Time entries are created automatically by installed lifecycle +hooks when a session ends. Do not create time entries directly from the skill +body. + +## Commands + +When the user asks to set up Keito tracking for the current repo, or invokes +this skill directly with `/track-time-keito` or `/keito-time-track:keito-time-track`, run: + +```bash +if ! command -v keito >/dev/null 2>&1; then scripts/install-cli.sh; fi +keito skill install --skip-skills-add +scripts/setup-wizard.sh +``` + +If `keito auth status` reports unauthenticated, stop before setup and tell the +user to run `keito auth login`. Do not ask for or print API keys. + +When the user asks for tracking status, run: + +```bash +scripts/status.sh +``` + +When the user asks to pause or resume tracking for the current session, run: + +```bash +scripts/pause-resume.sh pause +scripts/pause-resume.sh resume +``` + +When the user asks to disable tracking for this repository, run: + +```bash +scripts/disable.sh +``` + +When the user asks what was logged today, run: + +```bash +keito --json time list --today --source agent +``` + +Summarise the returned entries by duration, project, task, and notes. + +## Rules + +- Never read or expose Keito API keys. Authentication belongs to the Keito CLI. +- Never write `.keito/config.yml` by hand. Use `scripts/setup-wizard.sh`. +- Treat `.keito/config.yml` as repository-specific. Do not reuse a config from + another client or project. +- Never create a time entry from the skill body. The session-end hook owns + duration measurement and writes exactly one entry per session. +- If the Keito CLI is missing, run `scripts/install-cli.sh`. +- If the Keito CLI is unauthenticated, tell the user to run `keito auth login` + or set `KEITO_API_KEY` and `KEITO_ACCOUNT_ID`. diff --git a/skills/keito-time-track/agents/openai.yaml b/skills/keito-time-track/agents/openai.yaml new file mode 100644 index 0000000..5942789 --- /dev/null +++ b/skills/keito-time-track/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Keito Time Track" + short_description: "Log coding sessions to Keito" + default_prompt: "Use $keito-time-track to set up Keito time tracking for this repository." + +policy: + allow_implicit_invocation: true diff --git a/skills/keito-time-track/assets/claude.md-block.md b/skills/keito-time-track/assets/claude.md-block.md new file mode 100644 index 0000000..216d635 --- /dev/null +++ b/skills/keito-time-track/assets/claude.md-block.md @@ -0,0 +1,5 @@ +## Keito Time Tracking + +This repository is configured for Keito agent time tracking. Use +`/track-time-keito status` to check whether tracking is enabled, and +`/track-time-keito pause` before doing one-off unbillable work in this repo. diff --git a/skills/keito-time-track/assets/config.example.yml b/skills/keito-time-track/assets/config.example.yml new file mode 100644 index 0000000..9efb1c8 --- /dev/null +++ b/skills/keito-time-track/assets/config.example.yml @@ -0,0 +1,18 @@ +version: 1 +workspace_id: co_example +client_id: cli_example +client_name: "Example Client" +project_id: prj_example +project_name: "Example Project" +task_id: tsk_development +task_name: "Development" +agent_tracking: + enabled: true + source: agent + draft: true + min_duration_seconds: 60 + max_duration_seconds: 28800 + redact_notes: false +metadata: + integration: keito_skill + cost_centre: engineering diff --git a/skills/keito-time-track/hooks/lib/config.sh b/skills/keito-time-track/hooks/lib/config.sh new file mode 100644 index 0000000..46f2c18 --- /dev/null +++ b/skills/keito-time-track/hooks/lib/config.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash + +keito_require_jq() { + command -v jq >/dev/null 2>&1 +} + +keito_json_get() { + local payload="$1" + local filter="$2" + printf '%s' "$payload" | jq -r "$filter" 2>/dev/null +} + +keito_json_get_string() { + local value + value=$(keito_json_get "$1" "$2 // empty") + [ "$value" != "null" ] && printf '%s\n' "$value" +} + +keito_safe_filename() { + printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' +} + +keito_session_id_from_payload() { + local payload="$1" + local cwd="$2" + local raw + raw=$(keito_json_get_string "$payload" '.session_id // .sessionId // .conversation_id // .conversationId // .transcript_path') + if [ -n "$raw" ]; then + printf '%s\n' "$raw" + return 0 + fi + + if command -v shasum >/dev/null 2>&1; then + printf '%s\n' "$cwd" | shasum | awk '{print "session_" $1}' + else + printf 'session_%s\n' "$(date -u +%s)" + fi +} + +keito_find_config() { + local dir="$1" + [ -n "$dir" ] || dir="$PWD" + dir=$(cd "$dir" 2>/dev/null && pwd -P) || return 1 + + local git_root="" + git_root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null || true) + + while [ -n "$dir" ] && [ "$dir" != "/" ]; do + if [ -f "$dir/.keito/config.yml" ]; then + printf '%s\n' "$dir/.keito/config.yml" + return 0 + fi + if [ -n "$git_root" ] && [ "$dir" = "$git_root" ]; then + break + fi + dir=$(dirname "$dir") + done + + return 1 +} + +keito_yaml_unquote() { + sed -e 's/^[[:space:]]*//' \ + -e 's/[[:space:]]*$//' \ + -e 's/^"//' \ + -e 's/"$//' \ + -e "s/^'//" \ + -e "s/'$//" +} + +keito_yaml_get_fallback() { + local file="$1" + local path="$2" + local default="$3" + + local section key value + if [[ "$path" == *.* ]]; then + section=${path%%.*} + key=${path#*.} + value=$(awk -v section="$section" -v key="$key" ' + $0 ~ "^[[:space:]]*" section ":" { in_section=1; next } + in_section && $0 ~ "^[^[:space:]]" { in_section=0 } + in_section && $0 ~ "^[[:space:]]+" key ":" { + sub("^[[:space:]]+" key ":[[:space:]]*", "") + sub("[[:space:]]+#.*$", "") + print + exit + } + ' "$file") + else + value=$(awk -v key="$path" ' + $0 ~ "^" key ":" { + sub("^" key ":[[:space:]]*", "") + sub("[[:space:]]+#.*$", "") + print + exit + } + ' "$file") + fi + + if [ -n "$value" ]; then + printf '%s\n' "$value" | keito_yaml_unquote + else + printf '%s\n' "$default" + fi +} + +keito_yaml_get() { + local file="$1" + local path="$2" + local default="${3:-}" + + if command -v yq >/dev/null 2>&1; then + local value + value=$(yq -r ".$path // \"\"" "$file" 2>/dev/null || true) + if [ -n "$value" ] && [ "$value" != "null" ]; then + printf '%s\n' "$value" + return 0 + fi + fi + + keito_yaml_get_fallback "$file" "$path" "$default" +} + +keito_yaml_metadata_json() { + local file="$1" + + if command -v yq >/dev/null 2>&1; then + local json + json=$(yq -o=json '.metadata // {}' "$file" 2>/dev/null || true) + if printf '%s' "$json" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s\n' "$json" + return 0 + fi + fi + + local json key value + json='{}' + while IFS='=' read -r key value; do + [ -n "$key" ] || continue + json=$(printf '%s' "$json" | jq --arg key "$key" --arg value "$value" '. + {($key): $value}') + done < <(awk ' + /^metadata:/ { in_section=1; next } + in_section && /^[^[:space:]]/ { in_section=0 } + in_section && /^[[:space:]]+[A-Za-z0-9_-]+:/ { + line=$0 + sub(/^[[:space:]]+/, "", line) + split(line, parts, ":") + key=parts[1] + sub(/^[^:]+:[[:space:]]*/, "", line) + sub(/[[:space:]]+#.*$/, "", line) + gsub(/^"|"$/, "", line) + gsub(/^'\''|'\''$/, "", line) + print key "=" line + } + ' "$file") + + printf '%s\n' "$json" +} + +keito_latest_state_for_cwd() { + local cwd="$1" + local state_dir + state_dir=$(keito_state_dir) + [ -d "$state_dir" ] || return 1 + + local candidate + candidate=$(find "$state_dir" -type f -name '*.json' -print 2>/dev/null | while read -r file; do + if [ "$(jq -r '.cwd // empty' "$file" 2>/dev/null)" = "$cwd" ]; then + printf '%s\n' "$file" + fi + done | xargs ls -t 2>/dev/null | head -n 1) + + [ -n "$candidate" ] && printf '%s\n' "$candidate" +} diff --git a/skills/keito-time-track/hooks/lib/duration.sh b/skills/keito-time-track/hooks/lib/duration.sh new file mode 100644 index 0000000..1e121e7 --- /dev/null +++ b/skills/keito-time-track/hooks/lib/duration.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +keito_now_epoch() { + date -u +%s +} + +keito_now_iso() { + date -u +'%Y-%m-%dT%H:%M:%SZ' +} + +keito_git_rev() { + git -C "$1" rev-parse --short HEAD 2>/dev/null || true +} + +keito_git_branch() { + git -C "$1" branch --show-current 2>/dev/null || true +} + +keito_trim_notes() { + awk 'BEGIN { max = 1500 } { text = text $0 "\n" } END { gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", text); print substr(text, 1, max) }' +} + +keito_notes_from_transcript() { + local transcript="$1" + [ -f "$transcript" ] || return 1 + + jq -r ' + select((.role // .message.role // empty) == "assistant") + | (.content // .message.content // .message.text // empty) + | if type == "array" then map(.text // empty) | join(" ") else . end + ' "$transcript" 2>/dev/null | tail -n 1 +} + +keito_extract_notes() { + local payload="$1" + local transcript="$2" + local cwd="$3" + local redact="$4" + + if [ "$redact" = "true" ]; then + printf '[redacted]\n' + return 0 + fi + + local notes + notes=$(printf '%s' "$payload" | jq -r '.last_assistant_message // .summary // .message.content // empty' 2>/dev/null | keito_trim_notes) + if [ -z "$notes" ] && [ -n "$transcript" ]; then + notes=$(keito_notes_from_transcript "$transcript" | keito_trim_notes) + fi + if [ -z "$notes" ]; then + notes="Agent coding session in $(basename "$cwd")" + fi + + printf '%s\n' "$notes" +} diff --git a/skills/keito-time-track/hooks/lib/log.sh b/skills/keito-time-track/hooks/lib/log.sh new file mode 100644 index 0000000..54c4863 --- /dev/null +++ b/skills/keito-time-track/hooks/lib/log.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +keito_skill_home() { + printf '%s\n' "${KEITO_SKILL_HOME:-$HOME/.keito/skill}" +} + +keito_state_dir() { + printf '%s\n' "$(keito_skill_home)/sessions" +} + +keito_log_file() { + printf '%s\n' "$(keito_skill_home)/skill.log" +} + +keito_rotate_log_if_needed() { + local file="$1" + [ -f "$file" ] || return 0 + + local size + size=$(wc -c < "$file" 2>/dev/null || printf '0') + if [ "${size:-0}" -gt 10485760 ]; then + mv "$file" "$file.1" 2>/dev/null || true + fi +} + +keito_log() { + local level="$1" + shift + local home log_file + home=$(keito_skill_home) + log_file=$(keito_log_file) + + mkdir -p "$home" "$(keito_state_dir)" 2>/dev/null || true + keito_rotate_log_if_needed "$log_file" + printf '%s [%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >> "$log_file" 2>/dev/null || true +} diff --git a/skills/keito-time-track/hooks/session-end.sh b/skills/keito-time-track/hooks/session-end.sh new file mode 100755 index 0000000..52eba03 --- /dev/null +++ b/skills/keito-time-track/hooks/session-end.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -u + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +# shellcheck source=hooks/lib/log.sh +. "$script_dir/lib/log.sh" +# shellcheck source=hooks/lib/config.sh +. "$script_dir/lib/config.sh" +# shellcheck source=hooks/lib/duration.sh +. "$script_dir/lib/duration.sh" + +payload=$(cat || true) + +if ! keito_require_jq; then + keito_log WARN "jq is not installed; skipping session end" + exit 0 +fi + +cwd=$(keito_json_get_string "$payload" '.cwd // .workspace.current_dir // .workspace_root // .repository_root') +[ -n "$cwd" ] || cwd="$PWD" +cwd=$(cd "$cwd" 2>/dev/null && pwd -P) || exit 0 + +session_id=$(keito_session_id_from_payload "$payload" "$cwd") +safe_session_id=$(keito_safe_filename "$session_id") +state_file="$(keito_state_dir)/$safe_session_id.json" +if [ ! -f "$state_file" ]; then + state_file=$(keito_latest_state_for_cwd "$cwd" 2>/dev/null || true) +fi +[ -f "$state_file" ] || exit 0 + +stored_session_id=$(jq -r '.session_id // empty' "$state_file") +[ -n "$stored_session_id" ] && session_id="$stored_session_id" + +paused=$(jq -r '.paused // false' "$state_file") +if [ "$paused" = "true" ]; then + keito_log INFO "skipped paused session session_id=$session_id" + rm -f "$state_file" + exit 0 +fi + +started_at=$(jq -r '.started_at // empty' "$state_file") +started_epoch=$(jq -r '.started_epoch // empty' "$state_file") +config_path=$(jq -r '.config_path // empty' "$state_file") +[ -f "$config_path" ] || config_path=$(keito_find_config "$cwd" 2>/dev/null || true) + +if [ ! -f "$config_path" ]; then + keito_log WARN "missing config for session session_id=$session_id cwd=$cwd" + exit 0 +fi + +enabled=$(keito_yaml_get "$config_path" "agent_tracking.enabled" "true") +if [ "$enabled" = "false" ]; then + keito_log INFO "skipped disabled config session_id=$session_id" + rm -f "$state_file" + exit 0 +fi + +now_epoch=$(keito_now_epoch) +if [ -z "$started_epoch" ] || ! [[ "$started_epoch" =~ ^[0-9]+$ ]]; then + started_epoch=$now_epoch +fi +duration_seconds=$(( now_epoch - started_epoch )) +[ "$duration_seconds" -lt 0 ] && duration_seconds=0 + +min_duration=$(keito_yaml_get "$config_path" "agent_tracking.min_duration_seconds" "60") +[[ "$min_duration" =~ ^[0-9]+$ ]] || min_duration=60 +if [ "$duration_seconds" -lt "$min_duration" ]; then + keito_log INFO "skipped short session session_id=$session_id duration=${duration_seconds}s min=${min_duration}s" + rm -f "$state_file" + exit 0 +fi + +max_duration=$(keito_yaml_get "$config_path" "agent_tracking.max_duration_seconds" "28800") +[[ "$max_duration" =~ ^[0-9]+$ ]] || max_duration=28800 +duration_capped=false +original_duration_seconds=$duration_seconds +if [ "$duration_seconds" -gt "$max_duration" ]; then + duration_seconds=$max_duration + duration_capped=true +fi + +workspace_id=$(keito_yaml_get "$config_path" "workspace_id" "") +project_id=$(keito_yaml_get "$config_path" "project_id" "") +task_id=$(keito_yaml_get "$config_path" "task_id" "") +source=$(keito_yaml_get "$config_path" "agent_tracking.source" "agent") +case "$source" in + web|cli|api|agent) ;; + *) source="agent" ;; +esac + +if [ -z "$project_id" ] || [ -z "$task_id" ]; then + keito_log WARN "missing project_id or task_id in $config_path; preserving state session_id=$session_id" + exit 0 +fi + +redact_notes=$(keito_yaml_get "$config_path" "agent_tracking.redact_notes" "false") +transcript=$(keito_json_get_string "$payload" '.transcript_path // .transcriptPath // empty') +notes=$(keito_extract_notes "$payload" "$transcript" "$cwd" "$redact_notes") +ended_at=$(keito_now_iso) +agent_id=${KEITO_AGENT_ID:-} +[ -n "$agent_id" ] || agent_id=$(keito_json_get_string "$payload" '.agent_id // .agent.id // empty') +agent_type=${KEITO_AGENT_TYPE:-} +[ -n "$agent_type" ] || agent_type=$(jq -r '.agent_type // "unknown"' "$state_file") +git_rev=$(jq -r '.git_rev // empty' "$state_file") +git_branch=$(jq -r '.git_branch // empty' "$state_file") +draft=$(keito_yaml_get "$config_path" "agent_tracking.draft" "true") +base_metadata=$(keito_yaml_metadata_json "$config_path") + +metadata=$(jq -c -n \ + --argjson base "$base_metadata" \ + --arg skill "keito-time-track" \ + --arg session_id "$session_id" \ + --arg agent_id "$agent_id" \ + --arg agent_type "$agent_type" \ + --arg git_rev "$git_rev" \ + --arg git_branch "$git_branch" \ + --arg cwd "$cwd" \ + --arg config_path "$config_path" \ + --argjson duration_seconds "$duration_seconds" \ + --argjson original_duration_seconds "$original_duration_seconds" \ + --argjson duration_capped "$duration_capped" \ + --argjson draft "$draft" \ + '$base + { + skill: $skill, + session_id: $session_id, + agent_id: $agent_id, + agent_type: $agent_type, + git_rev: $git_rev, + git_branch: $git_branch, + cwd: $cwd, + config_path: $config_path, + duration_seconds: $duration_seconds, + original_duration_seconds: $original_duration_seconds, + duration_capped: $duration_capped, + draft: $draft + }') + +keito_cmd=${KEITO_CLI_BIN:-keito} +cmd=("$keito_cmd" --json) +if [ -n "$workspace_id" ]; then + cmd+=(--workspace "$workspace_id") +fi +cmd+=(time session-record) +cmd+=(--project "$project_id") +cmd+=(--task "$task_id") +cmd+=(--session-id "$session_id") +cmd+=(--duration-seconds "$duration_seconds") +if [ -n "$started_at" ]; then + cmd+=(--started-at "$started_at") +fi +cmd+=(--ended-at "$ended_at") +cmd+=(--source "$source") +cmd+=(--metadata "$metadata") +cmd+=(--notes "$notes") + +if "${cmd[@]}" >> "$(keito_log_file)" 2>&1; then + keito_log INFO "logged session session_id=$session_id duration=${duration_seconds}s source=$source" + rm -f "$state_file" +else + keito_log ERROR "failed to log session session_id=$session_id; state preserved at $state_file" +fi + +exit 0 diff --git a/skills/keito-time-track/hooks/session-start.sh b/skills/keito-time-track/hooks/session-start.sh new file mode 100755 index 0000000..90232ef --- /dev/null +++ b/skills/keito-time-track/hooks/session-start.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -u + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +# shellcheck source=hooks/lib/log.sh +. "$script_dir/lib/log.sh" +# shellcheck source=hooks/lib/config.sh +. "$script_dir/lib/config.sh" +# shellcheck source=hooks/lib/duration.sh +. "$script_dir/lib/duration.sh" + +payload=$(cat || true) + +if ! keito_require_jq; then + keito_log WARN "jq is not installed; skipping session start" + exit 0 +fi + +cwd=$(keito_json_get_string "$payload" '.cwd // .workspace.current_dir // .workspace_root // .repository_root') +[ -n "$cwd" ] || cwd="$PWD" +cwd=$(cd "$cwd" 2>/dev/null && pwd -P) || exit 0 + +config_path=$(keito_find_config "$cwd" 2>/dev/null || true) +[ -n "$config_path" ] || exit 0 + +enabled=$(keito_yaml_get "$config_path" "agent_tracking.enabled" "true") +if [ "$enabled" = "false" ]; then + exit 0 +fi + +session_id=$(keito_session_id_from_payload "$payload" "$cwd") +safe_session_id=$(keito_safe_filename "$session_id") +state_dir=$(keito_state_dir) +state_file="$state_dir/$safe_session_id.json" +mkdir -p "$state_dir" || exit 0 + +started_at=$(keito_now_iso) +started_epoch=$(keito_now_epoch) +agent_type=${KEITO_AGENT_TYPE:-} +if [ -z "$agent_type" ]; then + agent_type=$(keito_json_get_string "$payload" '.agent_type // .agent.name // .agent // empty') +fi +[ -n "$agent_type" ] || agent_type="unknown" + +jq -n \ + --arg session_id "$session_id" \ + --arg started_at "$started_at" \ + --argjson started_epoch "$started_epoch" \ + --arg cwd "$cwd" \ + --arg config_path "$config_path" \ + --arg git_rev "$(keito_git_rev "$cwd")" \ + --arg git_branch "$(keito_git_branch "$cwd")" \ + --arg agent_type "$agent_type" \ + '{ + session_id: $session_id, + started_at: $started_at, + started_epoch: $started_epoch, + cwd: $cwd, + config_path: $config_path, + git_rev: $git_rev, + git_branch: $git_branch, + agent_type: $agent_type, + paused: false + }' > "$state_file.tmp" 2>/dev/null && mv "$state_file.tmp" "$state_file" + +keito_log INFO "started session session_id=$session_id cwd=$cwd config=$config_path" +exit 0 diff --git a/skills/keito-time-track/installers/install-claude-code.sh b/skills/keito-time-track/installers/install-claude-code.sh new file mode 100755 index 0000000..e9e214b --- /dev/null +++ b/skills/keito-time-track/installers/install-claude-code.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to merge Claude Code hook settings." >&2 + exit 1 +fi + +source_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +dest="$HOME/.claude/skills/keito-time-track" +settings="$HOME/.claude/settings.json" +keito_bin=${KEITO_CLI_BIN:-} +[ -n "$keito_bin" ] || keito_bin=$(command -v keito 2>/dev/null || true) + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +mkdir -p "$(dirname "$dest")" "$(dirname "$settings")" +dest_dir=$(cd "$(dirname "$dest")" && pwd -P) +dest_physical="$dest_dir/$(basename "$dest")" +if [ "$source_dir" != "$dest_physical" ]; then + rm -rf "$dest" + cp -R "$source_dir" "$dest" +fi + +[ -f "$settings" ] || printf '{}\n' > "$settings" +keito_env="" +if [ -n "$keito_bin" ]; then + keito_env="KEITO_CLI_BIN=$(shell_quote "$keito_bin") " +fi +start_cmd="${keito_env}KEITO_AGENT_TYPE=claude-code $(shell_quote "$dest/hooks/session-start.sh")" +end_cmd="${keito_env}KEITO_AGENT_TYPE=claude-code $(shell_quote "$dest/hooks/session-end.sh")" + +jq \ + --arg start "$start_cmd" \ + --arg end "$end_cmd" \ + --arg start_script "$dest/hooks/session-start.sh" \ + --arg end_script "$dest/hooks/session-end.sh" \ + ' + .hooks = (.hooks // {}) | + .hooks.SessionStart = ( + (.hooks.SessionStart // []) + | map(select( + (.hooks // [] | map(.command // "") | any(. == $start or contains($start_script))) + | not + )) + + [{ + matcher: "*", + hooks: [{ type: "command", command: $start }] + }] + ) | + .hooks.Stop = ( + (.hooks.Stop // []) + | map(select( + (.hooks // [] | map(.command // "") | any(. == $end or contains($end_script))) + | not + )) + + [{ + hooks: [{ type: "command", command: $end, timeout: 30 }] + }] + ) + ' "$settings" > "$settings.tmp" +mv "$settings.tmp" "$settings" + +chmod +x "$dest/hooks/session-start.sh" "$dest/hooks/session-end.sh" "$dest/scripts/"*.sh + +echo "Installed Keito Time Track skill for Claude Code:" +echo " $dest" +echo "Updated hooks:" +echo " $settings" diff --git a/skills/keito-time-track/installers/install-codex.sh b/skills/keito-time-track/installers/install-codex.sh new file mode 100755 index 0000000..4ee08d0 --- /dev/null +++ b/skills/keito-time-track/installers/install-codex.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to merge Codex hook settings." >&2 + exit 1 +fi + +source_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +dest="$HOME/.codex/skills/keito-time-track" +hooks="$HOME/.codex/hooks.json" +keito_bin=${KEITO_CLI_BIN:-} +[ -n "$keito_bin" ] || keito_bin=$(command -v keito 2>/dev/null || true) + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +mkdir -p "$(dirname "$dest")" "$(dirname "$hooks")" +dest_dir=$(cd "$(dirname "$dest")" && pwd -P) +dest_physical="$dest_dir/$(basename "$dest")" +if [ "$source_dir" != "$dest_physical" ]; then + rm -rf "$dest" + cp -R "$source_dir" "$dest" +fi + +[ -f "$hooks" ] || printf '{}\n' > "$hooks" +keito_env="" +if [ -n "$keito_bin" ]; then + keito_env="KEITO_CLI_BIN=$(shell_quote "$keito_bin") " +fi +start_cmd="${keito_env}KEITO_AGENT_TYPE=codex $(shell_quote "$dest/hooks/session-start.sh")" +end_cmd="${keito_env}KEITO_AGENT_TYPE=codex $(shell_quote "$dest/hooks/session-end.sh")" + +jq \ + --arg start "$start_cmd" \ + --arg end "$end_cmd" \ + --arg start_script "$dest/hooks/session-start.sh" \ + --arg end_script "$dest/hooks/session-end.sh" \ + ' + .hooks = (.hooks // {}) | + .hooks.SessionStart = ( + (.hooks.SessionStart // []) + | map(select( + (.hooks // [] | map(.command // "") | any(. == $start or contains($start_script))) + | not + )) + + [{ + matcher: "startup|resume", + hooks: [{ type: "command", command: $start }] + }] + ) | + .hooks.Stop = ( + (.hooks.Stop // []) + | map(select( + (.hooks // [] | map(.command // "") | any(. == $end or contains($end_script))) + | not + )) + + [{ + hooks: [{ type: "command", command: $end, timeout: 30 }] + }] + ) + ' "$hooks" > "$hooks.tmp" +mv "$hooks.tmp" "$hooks" + +chmod +x "$dest/hooks/session-start.sh" "$dest/hooks/session-end.sh" "$dest/scripts/"*.sh + +echo "Installed Keito Time Track skill for Codex:" +echo " $dest" +echo "Updated hooks:" +echo " $hooks" diff --git a/skills/keito-time-track/scripts/disable.sh b/skills/keito-time-track/scripts/disable.sh new file mode 100755 index 0000000..12f3135 --- /dev/null +++ b/skills/keito-time-track/scripts/disable.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +# shellcheck disable=SC1091 +# shellcheck source=../hooks/lib/log.sh +. "$script_dir/../hooks/lib/log.sh" +# shellcheck disable=SC1091 +# shellcheck source=../hooks/lib/config.sh +. "$script_dir/../hooks/lib/config.sh" + +cwd=$(pwd -P) +config_path=$(keito_find_config "$cwd" 2>/dev/null || true) + +if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then + echo "Keito tracking is already untracked for $cwd." + exit 0 +fi + +echo "Keito tracking config found:" +echo " $config_path" +printf 'Disable tracking for this repo by moving this config aside? [y/N] ' +read -r answer + +case "$answer" in + y|Y|yes|YES) ;; + *) + echo "Disable cancelled; no changes written." + exit 0 + ;; +esac + +disabled_path="$config_path.disabled" +if [ -e "$disabled_path" ]; then + disabled_path="$config_path.disabled.$(date -u +%Y%m%dT%H%M%SZ)" +fi + +mv "$config_path" "$disabled_path" +keito_log INFO "disabled repo tracking config=$config_path disabled_path=$disabled_path" +echo "Disabled Keito tracking for this repo." +echo "Moved config to: $disabled_path" diff --git a/skills/keito-time-track/scripts/install-cli.sh b/skills/keito-time-track/scripts/install-cli.sh new file mode 100755 index 0000000..78390ce --- /dev/null +++ b/skills/keito-time-track/scripts/install-cli.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v keito >/dev/null 2>&1; then + echo "Keito CLI is already installed: $(command -v keito)" + exit 0 +fi + +if command -v brew >/dev/null 2>&1; then + brew install osodevops/tap/keito + exit 0 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to install the Keito CLI without Homebrew." >&2 + exit 1 +fi +if ! command -v tar >/dev/null 2>&1; then + echo "tar is required to install the Keito CLI without Homebrew." >&2 + exit 1 +fi + +os=$(uname -s) +arch=$(uname -m) +case "$os:$arch" in + Darwin:arm64) asset="keito-aarch64-apple-darwin.tar.gz" ;; + Darwin:x86_64) asset="keito-x86_64-apple-darwin.tar.gz" ;; + Linux:x86_64) asset="keito-x86_64-unknown-linux-gnu.tar.gz" ;; + *) + echo "Unsupported platform: $os $arch" >&2 + echo "Install manually from https://github.com/osodevops/keito-cli/releases/latest" >&2 + exit 1 + ;; +esac + +tmp_dir=$(mktemp -d) +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +url="https://github.com/osodevops/keito-cli/releases/latest/download/$asset" +archive="$tmp_dir/$asset" +curl -fsSL "$url" -o "$archive" +tar -xzf "$archive" -C "$tmp_dir" + +keito_bin=$(find "$tmp_dir" -type f -name keito | head -n 1) +if [ -z "$keito_bin" ]; then + echo "Downloaded Keito CLI archive did not contain a keito binary." >&2 + exit 1 +fi + +install_dir="${KEITO_INSTALL_DIR:-$HOME/.local/bin}" +mkdir -p "$install_dir" +cp "$keito_bin" "$install_dir/keito" +chmod +x "$install_dir/keito" + +echo "Installed Keito CLI to $install_dir/keito" +case ":$PATH:" in + *":$install_dir:"*) ;; + *) + echo "Add $install_dir to PATH if keito is not found in new shells." + ;; +esac diff --git a/skills/keito-time-track/scripts/pause-resume.sh b/skills/keito-time-track/scripts/pause-resume.sh new file mode 100755 index 0000000..61fc9aa --- /dev/null +++ b/skills/keito-time-track/scripts/pause-resume.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: pause-resume.sh pause|resume" >&2 +} + +action="${1:-}" +case "$action" in + pause|resume) ;; + *) usage; exit 1 ;; +esac + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +# shellcheck disable=SC1091 +# shellcheck source=../hooks/lib/log.sh +. "$script_dir/../hooks/lib/log.sh" +# shellcheck disable=SC1091 +# shellcheck source=../hooks/lib/config.sh +. "$script_dir/../hooks/lib/config.sh" + +cwd=$(pwd -P) +state_file=$(keito_latest_state_for_cwd "$cwd" 2>/dev/null || true) +if [ -z "$state_file" ] || [ ! -f "$state_file" ]; then + echo "No active Keito-tracked session state found for $cwd." + exit 1 +fi + +paused=false +[ "$action" = "pause" ] && paused=true + +jq --argjson paused "$paused" '.paused = $paused' "$state_file" > "$state_file.tmp" +mv "$state_file.tmp" "$state_file" + +keito_log INFO "$action session state_file=$state_file" +echo "Keito tracking ${action}d for this session." diff --git a/skills/keito-time-track/scripts/setup-wizard.sh b/skills/keito-time-track/scripts/setup-wizard.sh new file mode 100755 index 0000000..9816529 --- /dev/null +++ b/skills/keito-time-track/scripts/setup-wizard.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(pwd -P) +config_path="$repo_root/.keito/config.yml" +keito_cmd=${KEITO_CLI_BIN:-keito} + +json_field() { + local json=$1 + local filter=$2 + local label=$3 + local value + + if ! value=$(printf '%s' "$json" | jq -er "$filter // empty"); then + echo "Keito CLI did not return $label." >&2 + exit 1 + fi + + printf '%s\n' "$value" +} + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required. Install jq and rerun /track-time-keito." >&2 + exit 1 +fi + +if [ -n "${KEITO_CLI_BIN:-}" ]; then + if [ ! -x "$keito_cmd" ]; then + echo "Keito CLI path is not executable: $keito_cmd" >&2 + exit 1 + fi +elif ! command -v "$keito_cmd" >/dev/null 2>&1; then + echo "Keito CLI is required. Install it, then run: keito auth login" >&2 + exit 1 +fi + +auth_json=$("$keito_cmd" --json auth status 2>/dev/null || printf '{"authenticated":false}') +authenticated=$(printf '%s' "$auth_json" | jq -r '.authenticated // false') +api_key_valid=$(printf '%s' "$auth_json" | jq -r '.api_key_valid // true') +if [ "$authenticated" != "true" ] || [ "$api_key_valid" != "true" ]; then + echo "Keito CLI is not authenticated. Run: keito auth login" >&2 + exit 1 +fi + +workspace_id=$(printf '%s' "$auth_json" | jq -r '.account_id // .workspace_id // empty') + +echo "Keito time tracking setup for $(basename "$repo_root")" +echo + +clients_json=$("$keito_cmd" --json clients list) +client_count=$(printf '%s' "$clients_json" | jq 'length') + +echo "Select a client:" +if [ "$client_count" -gt 0 ]; then + printf '%s' "$clients_json" | jq -r 'to_entries[] | "\(.key + 1)) \(.value.name) - \(.value.id)"' +fi +printf '%s) + Create new client\n' "$((client_count + 1))" +printf 'Client number: ' +read -r client_choice +if ! [[ "$client_choice" =~ ^[0-9]+$ ]] || [ "$client_choice" -lt 1 ] || [ "$client_choice" -gt $((client_count + 1)) ]; then + echo "Invalid client selection." >&2 + exit 1 +fi + +if [ "$client_choice" -eq $((client_count + 1)) ]; then + printf 'Client name: ' + read -r client_name + if [ -z "$client_name" ]; then + echo "Client name is required." >&2 + exit 1 + fi + client_json=$("$keito_cmd" --json clients create "$client_name") + client_id=$(json_field "$client_json" '.id' 'created client id') + client_name=$(json_field "$client_json" '.name' 'created client name') +else + client_index=$((client_choice - 1)) + client_id=$(json_field "$clients_json" ".[$client_index].id" 'selected client id') + client_name=$(json_field "$clients_json" ".[$client_index].name" 'selected client name') +fi + +echo +projects_json=$("$keito_cmd" --json projects list --client "$client_id") +project_count=$(printf '%s' "$projects_json" | jq 'length') + +echo "Select a project for $client_name:" +if [ "$project_count" -gt 0 ]; then + printf '%s' "$projects_json" | jq -r ' + to_entries[] + | "\(.key + 1)) \(.value.name) \((.value.code // "") | if . == "" then "" else "[" + . + "]" end) - \(.value.id)" + ' +fi +printf '%s) + Create new project\n' "$((project_count + 1))" +printf 'Project number: ' +read -r project_choice +if ! [[ "$project_choice" =~ ^[0-9]+$ ]] || [ "$project_choice" -lt 1 ] || [ "$project_choice" -gt $((project_count + 1)) ]; then + echo "Invalid project selection." >&2 + exit 1 +fi + +if [ "$project_choice" -eq $((project_count + 1)) ]; then + printf 'Project name [%s]: ' "$(basename "$repo_root")" + read -r project_name + [ -n "$project_name" ] || project_name=$(basename "$repo_root") + project_json=$("$keito_cmd" --json projects create "$project_name" --client "$client_id") + project_id=$(json_field "$project_json" '.id' 'created project id') + project_name=$(json_field "$project_json" '.name' 'created project name') +else + project_index=$((project_choice - 1)) + project_id=$(json_field "$projects_json" ".[$project_index].id" 'selected project id') + project_name=$(json_field "$projects_json" ".[$project_index].name" 'selected project name') +fi + +tasks_json=$("$keito_cmd" --json projects tasks) +task_count=$(printf '%s' "$tasks_json" | jq 'length') +if [ "$task_count" -eq 0 ]; then + echo "No Keito tasks were returned. Add a default task in Keito, then rerun this wizard." >&2 + exit 1 +fi + +echo +echo "Select a task:" +printf '%s' "$tasks_json" | jq -r 'to_entries[] | "\(.key + 1)) \(.value.name) - \(.value.id)"' +default_task=$(printf '%s' "$tasks_json" | jq -r ' + to_entries[] + | select((.value.name | ascii_downcase) == "development") + | .key + 1 +' | head -n 1) +[ -n "$default_task" ] || default_task=1 +printf 'Task number [%s]: ' "$default_task" +read -r task_choice +[ -n "$task_choice" ] || task_choice=$default_task +if ! [[ "$task_choice" =~ ^[0-9]+$ ]] || [ "$task_choice" -lt 1 ] || [ "$task_choice" -gt "$task_count" ]; then + echo "Invalid task selection." >&2 + exit 1 +fi +task_index=$((task_choice - 1)) +task_id=$(json_field "$tasks_json" ".[$task_index].id" 'selected task id') +task_name=$(json_field "$tasks_json" ".[$task_index].name" 'selected task name') +client_name_yaml=$(jq -Rn --arg value "$client_name" '$value') +project_name_yaml=$(jq -Rn --arg value "$project_name" '$value') +task_name_yaml=$(jq -Rn --arg value "$task_name" '$value') + +echo +printf 'Enable agent time tracking for this repo? [Y/n] ' +read -r enabled_answer +enabled_answer=${enabled_answer:-Y} +case "$enabled_answer" in + y|Y|yes|YES) enabled=true ;; + *) enabled=false ;; +esac + +echo +if [ -f "$config_path" ]; then + echo "Existing Keito repo config will be replaced:" + echo " $config_path" + echo +fi +echo "Review this repo-specific Keito config:" +echo " Path: $config_path" +echo " Workspace: ${workspace_id:-default}" +echo " Client: $client_name ($client_id)" +echo " Project: $project_name ($project_id)" +echo " Task: $task_name ($task_id)" +echo " Source: agent" +echo " Draft metadata: true" +echo " Min duration: 60s" +echo " Max duration: 28800s" +printf 'Write this config for this repo only? [y/N] ' +read -r confirm_answer +case "$confirm_answer" in + y|Y|yes|YES) ;; + *) + echo "Setup cancelled; no changes written." + exit 0 + ;; +esac + +mkdir -p "$repo_root/.keito" +config_tmp=$(mktemp "$repo_root/.keito/config.yml.tmp.XXXXXX") +cleanup_config_tmp() { + rm -f "$config_tmp" +} +trap cleanup_config_tmp EXIT + +cat > "$config_tmp" </dev/null || true) +keito_cmd=${KEITO_CLI_BIN:-keito} + +if [ -z "$config_path" ]; then + echo "Keito tracking: untracked" + echo "No .keito/config.yml found at or above $cwd" + exit 0 +fi + +echo "Keito tracking: configured" +echo "Current directory: $cwd" +echo "Config: $config_path" +workspace_id=$(keito_yaml_get "$config_path" "workspace_id" "") +client_id=$(keito_yaml_get "$config_path" "client_id" "") +client_name=$(keito_yaml_get "$config_path" "client_name" "$client_id") +project_id=$(keito_yaml_get "$config_path" "project_id" "") +project_name=$(keito_yaml_get "$config_path" "project_name" "$project_id") +task_id=$(keito_yaml_get "$config_path" "task_id" "") +task_name=$(keito_yaml_get "$config_path" "task_name" "$task_id") +echo "Workspace: ${workspace_id:-default}" +echo "Client: $client_name ($client_id)" +echo "Project: $project_name ($project_id)" +echo "Task: $task_name ($task_id)" +echo "Enabled: $(keito_yaml_get "$config_path" "agent_tracking.enabled" "true")" +echo "Source: $(keito_yaml_get "$config_path" "agent_tracking.source" "agent")" +echo "Draft metadata: $(keito_yaml_get "$config_path" "agent_tracking.draft" "true")" +echo "Min duration: $(keito_yaml_get "$config_path" "agent_tracking.min_duration_seconds" "60")s" +echo "Max duration: $(keito_yaml_get "$config_path" "agent_tracking.max_duration_seconds" "28800")s" + +state_file=$(keito_latest_state_for_cwd "$cwd" 2>/dev/null || true) +if [ -n "$state_file" ] && [ -f "$state_file" ]; then + started_epoch=$(jq -r '.started_epoch // empty' "$state_file") + session_id=$(jq -r '.session_id // empty' "$state_file") + paused=$(jq -r '.paused // false' "$state_file") + now=$(date -u +%s) + elapsed=0 + if [[ "$started_epoch" =~ ^[0-9]+$ ]]; then + elapsed=$((now - started_epoch)) + fi + echo "Current session: $session_id (${elapsed}s elapsed, paused=$paused)" +else + echo "Current session: none recorded" +fi + +last_error=$(grep ' \[ERROR\] ' "$(keito_log_file)" 2>/dev/null | tail -n 1 || true) +if [ -n "$last_error" ]; then + echo "Last hook error: $last_error" +else + echo "Last hook error: none" +fi + +if [ -n "${KEITO_CLI_BIN:-}" ] || command -v "$keito_cmd" >/dev/null 2>&1; then + echo + echo "Today's agent entries:" + "$keito_cmd" --json time list --today --source agent --limit 20 2>/dev/null || echo "Unable to query Keito CLI." +fi diff --git a/src/cli/skill.rs b/src/cli/skill.rs index cc36116..c29cb5b 100644 --- a/src/cli/skill.rs +++ b/src/cli/skill.rs @@ -9,6 +9,7 @@ Codex lifecycle hooks so local coding sessions can be logged automatically. EXAMPLES: keito skill install keito skill install --agent codex + keito skill team-init optional keito skill status --json keito skill doctor")] pub struct SkillCommand { @@ -27,12 +28,8 @@ By default this configures both Codex and Claude Code. The skill still needs per-repository setup after installation: cd into a client repo and run /track-time-keito to select its Keito client, project, and task.")] Install { - /// Skill source for the skills installer - #[arg( - long, - default_value = "osodevops/keito-skill", - env = "KEITO_SKILL_SOURCE" - )] + /// Skill source for the skills installer, or "bundled" for the skill shipped with this CLI + #[arg(long, default_value = "bundled", env = "KEITO_SKILL_SOURCE")] source: String, /// Agent hook target to configure @@ -49,6 +46,23 @@ per-repository setup after installation: cd into a client repo and run /// Run readiness checks and print next actions Doctor, + + /// Add repo-level Keito tracking guidance for agent teammates + #[command(long_about = "\ +Add Keito tracking guidance to the current Git repository. + +This mirrors gstack-style team mode: the Keito skill remains globally installed, +while the repository records how agents should set up local project/task mapping. +It updates AGENTS.md for Codex/OpenAI agents, CLAUDE.md for Claude Code, and +.gitignore for the repo-local .keito/config.yml file. + +Use \"optional\" to suggest tracking, or \"required\" to tell agents to stop +before billable coding work until /track-time-keito has configured the repo.")] + TeamInit { + /// Team policy to write into repo guidance + #[arg(value_enum)] + mode: SkillTeamMode, + }, } #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] @@ -57,6 +71,12 @@ pub enum SkillAgent { ClaudeCode, } +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum SkillTeamMode { + Optional, + Required, +} + impl SkillAgent { pub fn skills_cli_name(self) -> &'static str { match self { diff --git a/src/cli/time.rs b/src/cli/time.rs index 55fe9db..2c2ab8b 100644 --- a/src/cli/time.rs +++ b/src/cli/time.rs @@ -194,7 +194,7 @@ Duration of the time entry. Accepts two formats: #[arg(long)] billable: Option, - /// Source to store on the time entry: web, cli, api, or agent + /// Source to store on the time entry: web, cli, api, agent, calendar, or desktop #[arg(long, default_value = "cli")] source: String, @@ -270,7 +270,7 @@ EXAMPLE: #[arg(long)] billable: Option, - /// Source to store on the time entry: web, cli, api, or agent + /// Source to store on the time entry: web, cli, api, agent, calendar, or desktop #[arg(long, default_value = "agent")] source: String, @@ -328,7 +328,7 @@ EXAMPLES: #[arg(long)] task: Option, - /// Filter by source: web, cli, api, or agent + /// Filter by source: web, cli, api, agent, calendar, or desktop #[arg(long)] source: Option, diff --git a/src/commands/skill.rs b/src/commands/skill.rs index 741d2bc..47000bf 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -1,9 +1,11 @@ use colored::Colorize; use serde::Serialize; +use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command as ProcessCommand, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; -use crate::cli::skill::{SkillAgent, SkillCommand, SkillSubcommand}; +use crate::cli::skill::{SkillAgent, SkillCommand, SkillSubcommand, SkillTeamMode}; use crate::cli::GlobalFlags; use crate::config::ResolvedAuth; use crate::error::AppError; @@ -11,6 +13,7 @@ use crate::output::OutputMode; const SKILL_NAME: &str = "keito-time-track"; const DEFAULT_SKILLS_PACKAGE: &str = "skills@1.5.6"; +const BUNDLED_SKILL_SOURCE: &str = "bundled"; #[derive(Debug, Serialize)] struct SkillStatus { @@ -32,6 +35,114 @@ struct AgentStatus { hook_config_path: Option, } +#[derive(Debug)] +struct BundledSkillFile { + path: &'static str, + contents: &'static str, + executable: bool, +} + +const BUNDLED_SKILL_FILES: &[BundledSkillFile] = &[ + BundledSkillFile { + path: "SKILL.md", + contents: include_str!("../../skills/keito-time-track/SKILL.md"), + executable: false, + }, + BundledSkillFile { + path: "agents/openai.yaml", + contents: include_str!("../../skills/keito-time-track/agents/openai.yaml"), + executable: false, + }, + BundledSkillFile { + path: "assets/claude.md-block.md", + contents: include_str!("../../skills/keito-time-track/assets/claude.md-block.md"), + executable: false, + }, + BundledSkillFile { + path: "assets/config.example.yml", + contents: include_str!("../../skills/keito-time-track/assets/config.example.yml"), + executable: false, + }, + BundledSkillFile { + path: "hooks/lib/config.sh", + contents: include_str!("../../skills/keito-time-track/hooks/lib/config.sh"), + executable: false, + }, + BundledSkillFile { + path: "hooks/lib/duration.sh", + contents: include_str!("../../skills/keito-time-track/hooks/lib/duration.sh"), + executable: false, + }, + BundledSkillFile { + path: "hooks/lib/log.sh", + contents: include_str!("../../skills/keito-time-track/hooks/lib/log.sh"), + executable: false, + }, + BundledSkillFile { + path: "hooks/session-end.sh", + contents: include_str!("../../skills/keito-time-track/hooks/session-end.sh"), + executable: true, + }, + BundledSkillFile { + path: "hooks/session-start.sh", + contents: include_str!("../../skills/keito-time-track/hooks/session-start.sh"), + executable: true, + }, + BundledSkillFile { + path: "installers/install-claude-code.sh", + contents: include_str!("../../skills/keito-time-track/installers/install-claude-code.sh"), + executable: true, + }, + BundledSkillFile { + path: "installers/install-codex.sh", + contents: include_str!("../../skills/keito-time-track/installers/install-codex.sh"), + executable: true, + }, + BundledSkillFile { + path: "scripts/disable.sh", + contents: include_str!("../../skills/keito-time-track/scripts/disable.sh"), + executable: true, + }, + BundledSkillFile { + path: "scripts/install-cli.sh", + contents: include_str!("../../skills/keito-time-track/scripts/install-cli.sh"), + executable: true, + }, + BundledSkillFile { + path: "scripts/pause-resume.sh", + contents: include_str!("../../skills/keito-time-track/scripts/pause-resume.sh"), + executable: true, + }, + BundledSkillFile { + path: "scripts/setup-wizard.sh", + contents: include_str!("../../skills/keito-time-track/scripts/setup-wizard.sh"), + executable: true, + }, + BundledSkillFile { + path: "scripts/status.sh", + contents: include_str!("../../skills/keito-time-track/scripts/status.sh"), + executable: true, + }, +]; + +struct MaterializedBundledSkill { + temp_root: PathBuf, + skill_root: PathBuf, +} + +impl Drop for MaterializedBundledSkill { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.temp_root); + } +} + +#[derive(Debug, Serialize)] +struct TeamInitStatus { + mode: String, + repo_root: String, + changed_files: Vec, +} + pub async fn run( cmd: SkillCommand, global: &GlobalFlags, @@ -54,12 +165,13 @@ pub async fn run( } SkillSubcommand::Status => status(global, mode, false).await, SkillSubcommand::Doctor => status(global, mode, true).await, + SkillSubcommand::TeamInit { mode: team_mode } => team_init(team_mode, global, mode).await, } } pub async fn install_defaults(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { let source = - std::env::var("KEITO_SKILL_SOURCE").unwrap_or_else(|_| "osodevops/keito-skill".into()); + std::env::var("KEITO_SKILL_SOURCE").unwrap_or_else(|_| BUNDLED_SKILL_SOURCE.into()); install(global, mode, &source, default_agents(), false).await } @@ -70,9 +182,12 @@ async fn install( agents: Vec, skip_skills_add: bool, ) -> Result<(), AppError> { - if !skip_skills_add && find_in_path("npx").is_none() { + let source = source.trim(); + let use_bundled = source.is_empty() || source == BUNDLED_SKILL_SOURCE; + + if !skip_skills_add && !use_bundled && find_in_path("npx").is_none() { return Err(AppError::Config( - "npx is required to install the Keito Skill. Install Node.js or run the manual skill installer.".into(), + "npx is required for an external skill source. Install Node.js, use --source bundled, or run the manual skill installer.".into(), )); } if find_in_path("jq").is_none() { @@ -81,12 +196,22 @@ async fn install( )); } + let bundled_skill = if !skip_skills_add && use_bundled { + Some(materialize_bundled_skill()?) + } else { + None + }; + for agent in &agents { let show_child_output = !global.quiet && mode == OutputMode::Table; - if !skip_skills_add { + if skip_skills_add { + run_hook_installer(*agent, show_child_output)?; + } else if let Some(skill) = bundled_skill.as_ref() { + run_bundled_hook_installer(*agent, show_child_output, &skill.skill_root)?; + } else { run_skills_add(source, *agent, show_child_output)?; + run_hook_installer(*agent, show_child_output)?; } - run_hook_installer(*agent, show_child_output)?; } if global.quiet { @@ -103,6 +228,7 @@ async fn install( } else { println!("{}", "Keito Skill installed.".green().bold()); println!("Next: cd into each client repo and run /track-time-keito."); + println!("For shared repos: keito skill team-init optional"); println!("Check readiness any time with: keito skill doctor"); } @@ -174,6 +300,19 @@ fn run_skills_add(source: &str, agent: SkillAgent, show_output: bool) -> Result< Ok(()) } +fn run_bundled_hook_installer( + agent: SkillAgent, + show_output: bool, + skill_root: &Path, +) -> Result<(), AppError> { + let installer_name = match agent { + SkillAgent::Codex => "install-codex.sh", + SkillAgent::ClaudeCode => "install-claude-code.sh", + }; + let script = skill_root.join("installers").join(installer_name); + run_installer_script(agent, show_output, &script) +} + fn run_hook_installer(agent: SkillAgent, show_output: bool) -> Result<(), AppError> { let script = hook_installer_path(agent).ok_or_else(|| { AppError::Config(format!( @@ -182,11 +321,19 @@ fn run_hook_installer(agent: SkillAgent, show_output: bool) -> Result<(), AppErr )) })?; + run_installer_script(agent, show_output, &script) +} + +fn run_installer_script( + agent: SkillAgent, + show_output: bool, + script: &Path, +) -> Result<(), AppError> { let current_exe = std::env::current_exe() .map_err(|e| AppError::Config(format!("Could not resolve current keito binary: {e}")))?; let status = ProcessCommand::new("bash") - .arg(&script) + .arg(script) .env("KEITO_CLI_BIN", current_exe) .stdin(Stdio::null()) .stdout(child_stdio(show_output)) @@ -204,6 +351,60 @@ fn run_hook_installer(agent: SkillAgent, show_output: bool) -> Result<(), AppErr Ok(()) } +fn materialize_bundled_skill() -> Result { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let temp_root = + std::env::temp_dir().join(format!("keito-skill-{}-{unique}", std::process::id())); + let skill_root = temp_root.join(SKILL_NAME); + + for file in BUNDLED_SKILL_FILES { + let path = skill_root.join(file.path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + AppError::Config(format!("Failed to create bundled skill directory: {e}")) + })?; + } + fs::write(&path, file.contents).map_err(|e| { + AppError::Config(format!( + "Failed to write bundled skill file {}: {e}", + file.path + )) + })?; + set_executable_if_needed(&path, file.executable)?; + } + + Ok(MaterializedBundledSkill { + temp_root, + skill_root, + }) +} + +#[cfg(unix)] +fn set_executable_if_needed(path: &Path, executable: bool) -> Result<(), AppError> { + if !executable { + return Ok(()); + } + + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path) + .map_err(|e| AppError::Config(format!("Failed to inspect {}: {e}", path.display())))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).map_err(|e| { + AppError::Config(format!("Failed to mark {} executable: {e}", path.display())) + })?; + + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable_if_needed(_path: &Path, _executable: bool) -> Result<(), AppError> { + Ok(()) +} + fn child_stdio(show_output: bool) -> Stdio { if show_output { Stdio::inherit() @@ -374,3 +575,243 @@ fn yes_no(value: bool) -> String { "no".red().to_string() } } + +async fn team_init( + team_mode: SkillTeamMode, + global: &GlobalFlags, + mode: OutputMode, +) -> Result<(), AppError> { + let repo_root = git_repo_root()?; + let mut changed_files = Vec::new(); + + let ag_sections = team_guidance_sections(team_mode); + append_section_once( + &repo_root.join("AGENTS.md"), + "## Keito Time Tracking", + ag_sections.agents, + &mut changed_files, + )?; + append_section_once( + &repo_root.join("CLAUDE.md"), + "## Keito Time Tracking", + ag_sections.claude, + &mut changed_files, + )?; + ensure_gitignore(&repo_root, &mut changed_files)?; + write_config_example(&repo_root, &mut changed_files)?; + + if global.quiet { + return Ok(()); + } + + let status = TeamInitStatus { + mode: match team_mode { + SkillTeamMode::Optional => "optional".to_string(), + SkillTeamMode::Required => "required".to_string(), + }, + repo_root: repo_root.display().to_string(), + changed_files, + }; + + if mode == OutputMode::Json { + println!( + "{}", + serde_json::to_string_pretty(&status) + .map_err(|e| AppError::ServerError(format!("JSON serialization failed: {e}")))? + ); + } else { + println!( + "{} Keito repo guidance initialized ({})", + "Done.".green().bold(), + status.mode + ); + if status.changed_files.is_empty() { + println!("No files changed; Keito guidance was already present."); + } else { + println!("Changed files:"); + for file in &status.changed_files { + println!("- {file}"); + } + } + println!("Next: commit the guidance, then run /track-time-keito in this repo."); + } + + Ok(()) +} + +struct TeamGuidance { + agents: &'static str, + claude: &'static str, +} + +fn team_guidance_sections(team_mode: SkillTeamMode) -> TeamGuidance { + match team_mode { + SkillTeamMode::Optional => TeamGuidance { + agents: "\ +## Keito Time Tracking + +This repository can use Keito agent time tracking for billable coding work. +If the user wants tracking enabled, verify `keito skill status --json`, then +run `/track-time-keito` from Codex or Claude Code to create the repo-local +`.keito/config.yml` project/task mapping. + +Do not commit `.keito/config.yml`; it is specific to one Keito account, +client, project, and task. The lifecycle hooks create one `source=agent` time +entry when a tracked session ends, so do not create duplicate manual entries. +", + claude: "\ +## Keito Time Tracking + +This repository can use Keito agent time tracking for billable coding work. +If the user wants tracking enabled, verify `keito skill status --json`, then +run `/track-time-keito` to create the repo-local `.keito/config.yml` +project/task mapping. + +Do not commit `.keito/config.yml`; it is specific to one Keito account, +client, project, and task. The lifecycle hooks create one `source=agent` time +entry when a tracked session ends, so do not create duplicate manual entries. +", + }, + SkillTeamMode::Required => TeamGuidance { + agents: "\ +## Keito Time Tracking + +Keito agent time tracking is required before billable coding work in this +repository. Before starting, verify `keito skill status --json`. If the skill +or hooks are missing, stop and tell the user to run `keito skill install`. + +If `.keito/config.yml` is missing for this worktree, stop and run +`/track-time-keito` from Codex or Claude Code so the user can choose the Keito +client, project, and task. Do not guess project or task IDs. Do not commit +`.keito/config.yml`. + +The lifecycle hooks create one `source=agent` time entry when a tracked session +ends. Do not create duplicate manual entries for the same coding session. +", + claude: "\ +## Keito Time Tracking + +Keito agent time tracking is required before billable coding work in this +repository. Before starting, verify `keito skill status --json`. If the skill +or hooks are missing, stop and tell the user to run `keito skill install`. + +If `.keito/config.yml` is missing for this worktree, stop and run +`/track-time-keito` so the user can choose the Keito client, project, and task. +Do not guess project or task IDs. Do not commit `.keito/config.yml`. + +The lifecycle hooks create one `source=agent` time entry when a tracked session +ends. Do not create duplicate manual entries for the same coding session. +", + }, + } +} + +fn git_repo_root() -> Result { + let output = ProcessCommand::new("git") + .args(["rev-parse", "--show-toplevel"]) + .stdin(Stdio::null()) + .output() + .map_err(|e| AppError::Config(format!("Failed to run git: {e}")))?; + + if !output.status.success() { + return Err(AppError::Config( + "Run this from inside the Git repository you want to bootstrap.".into(), + )); + } + + let root = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if root.is_empty() { + return Err(AppError::Config( + "Git did not return a repository root.".into(), + )); + } + + Ok(PathBuf::from(root)) +} + +fn append_section_once( + path: &Path, + marker: &str, + section: &str, + changed_files: &mut Vec, +) -> Result<(), AppError> { + let current = fs::read_to_string(path).unwrap_or_default(); + if current.contains(marker) { + return Ok(()); + } + + let mut next = current; + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + if !next.is_empty() { + next.push('\n'); + } + next.push_str(section.trim_end()); + next.push('\n'); + + fs::write(path, next) + .map_err(|e| AppError::Config(format!("Failed to update {}: {e}", path.display())))?; + changed_files.push(relative_display(path)); + Ok(()) +} + +fn ensure_gitignore(repo_root: &Path, changed_files: &mut Vec) -> Result<(), AppError> { + let path = repo_root.join(".gitignore"); + let mut current = fs::read_to_string(&path).unwrap_or_default(); + let mut changed = false; + for line in [ + ".keito/config.yml", + ".keito/*.disabled*", + "!.keito/config.example.yml", + ] { + if !current.lines().any(|existing| existing.trim() == line) { + if !current.is_empty() && !current.ends_with('\n') { + current.push('\n'); + } + current.push_str(line); + current.push('\n'); + changed = true; + } + } + + if changed { + fs::write(&path, current) + .map_err(|e| AppError::Config(format!("Failed to update {}: {e}", path.display())))?; + changed_files.push(relative_display(&path)); + } + + Ok(()) +} + +fn write_config_example(repo_root: &Path, changed_files: &mut Vec) -> Result<(), AppError> { + let path = repo_root.join(".keito").join("config.example.yml"); + if path.exists() { + return Ok(()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| AppError::Config(format!("Failed to create {}: {e}", parent.display())))?; + } + fs::write( + &path, + include_str!("../../skills/keito-time-track/assets/config.example.yml"), + ) + .map_err(|e| AppError::Config(format!("Failed to write {}: {e}", path.display())))?; + changed_files.push(relative_display(&path)); + Ok(()) +} + +fn relative_display(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| { + if name == "config.example.yml" { + ".keito/config.example.yml".to_string() + } else { + name.to_string() + } + }) + .unwrap_or_else(|| path.display().to_string()) +} diff --git a/src/commands/time.rs b/src/commands/time.rs index c9c0551..dec1175 100644 --- a/src/commands/time.rs +++ b/src/commands/time.rs @@ -626,9 +626,9 @@ struct MetadataInput { fn normalize_source(source: &str) -> Result { let normalized = source.trim().to_ascii_lowercase(); match normalized.as_str() { - "web" | "cli" | "api" | "agent" => Ok(normalized), + "web" | "cli" | "api" | "agent" | "calendar" | "desktop" => Ok(normalized), _ => Err(AppError::InvalidInput(format!( - "source must be one of: web, cli, api, agent (got '{source}')" + "source must be one of: web, cli, api, agent, calendar, desktop (got '{source}')" ))), } } @@ -751,7 +751,9 @@ mod tests { #[test] fn source_is_normalized_and_validated() { assert_eq!(normalize_source("Agent").unwrap(), "agent"); - assert!(normalize_source("desktop").is_err()); + assert_eq!(normalize_source("desktop").unwrap(), "desktop"); + assert_eq!(normalize_source("Calendar").unwrap(), "calendar"); + assert!(normalize_source("mobile").is_err()); } #[test] diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index c0a6fc4..801c7c9 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -3,6 +3,8 @@ use assert_cmd::Command; use predicates::prelude::*; use std::fs; use std::path::Path; +#[cfg(unix)] +use std::process::Command as StdCommand; use wiremock::matchers::{body_json, header, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -294,6 +296,54 @@ fn skill_install_configures_agent_hooks_with_fake_skills_cli() { assert!(temp_dir.path().join(".claude/settings.json").exists()); } +#[test] +#[cfg(unix)] +fn skill_install_uses_bundled_skill_by_default() { + if StdCommand::new("jq").arg("--version").output().is_err() { + return; + } + + let temp_dir = tempfile::tempdir().unwrap(); + + let output = Command::cargo_bin("keito") + .unwrap() + .env("HOME", temp_dir.path()) + .env("XDG_CONFIG_HOME", temp_dir.path().join("config")) + .env("APPDATA", temp_dir.path().join("AppData").join("Roaming")) + .env("KEITO_API_KEY", "kto_test_key") + .env("KEITO_ACCOUNT_ID", "co_test") + .args([ + "--json", + "skill", + "install", + "--agent", + "codex", + "--agent", + "claude-code", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let status_json: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(status_json["authenticated"], true); + assert_eq!(status_json["codex"]["skill_installed"], true); + assert_eq!(status_json["codex"]["hooks_configured"], true); + assert_eq!(status_json["claude_code"]["skill_installed"], true); + assert_eq!(status_json["claude_code"]["hooks_configured"], true); + + assert!(temp_dir + .path() + .join(".codex/skills/keito-time-track/SKILL.md") + .exists()); + assert!(temp_dir + .path() + .join(".claude/skills/keito-time-track/SKILL.md") + .exists()); +} + #[tokio::test] async fn clients_list_sends_account_header_against_mock_api() { let server = MockServer::start().await; diff --git a/tests/man_pages.rs b/tests/man_pages.rs index 6ba3061..f5a665a 100644 --- a/tests/man_pages.rs +++ b/tests/man_pages.rs @@ -13,11 +13,22 @@ const EXPECTED_PAGES: &[&str] = &[ "keito-auth-logout.1", "keito-auth-status.1", "keito-auth-whoami.1", + "keito-clients.1", + "keito-clients-help.1", + "keito-clients-create.1", + "keito-clients-list.1", "keito-projects.1", "keito-projects-help.1", + "keito-projects-create.1", "keito-projects-list.1", "keito-projects-show.1", "keito-projects-tasks.1", + "keito-skill.1", + "keito-skill-help.1", + "keito-skill-doctor.1", + "keito-skill-install.1", + "keito-skill-status.1", + "keito-skill-team-init.1", "keito-time.1", "keito-time-help.1", "keito-time-start.1", @@ -25,6 +36,7 @@ const EXPECTED_PAGES: &[&str] = &[ "keito-time-log.1", "keito-time-list.1", "keito-time-running.1", + "keito-time-session-record.1", ]; #[test] diff --git a/tests/skill_hooks.rs b/tests/skill_hooks.rs new file mode 100644 index 0000000..18a193c --- /dev/null +++ b/tests/skill_hooks.rs @@ -0,0 +1,13 @@ +#![allow(deprecated)] + +#[cfg(unix)] +use assert_cmd::Command; + +#[test] +#[cfg(unix)] +fn bundled_skill_hooks_record_sessions_with_fake_keito() { + Command::new("bash") + .arg("tests/skill_hooks.sh") + .assert() + .success(); +} diff --git a/tests/skill_hooks.sh b/tests/skill_hooks.sh new file mode 100755 index 0000000..a078bf5 --- /dev/null +++ b/tests/skill_hooks.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +skill_root="$repo_root/skills/keito-time-track" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +export HOME="$tmp/home" +export KEITO_SKILL_HOME="$tmp/skill-home" +mkdir -p "$HOME" "$KEITO_SKILL_HOME" "$tmp/bin" "$tmp/repo/.keito" + +cat > "$tmp/repo/.keito/config.yml" <<'YAML' +version: 1 +workspace_id: co_test +project_id: p1 +project_name: "Project A" +task_id: t1 +task_name: "Development" +agent_tracking: + enabled: true + source: agent + draft: true + min_duration_seconds: 60 + max_duration_seconds: 28800 + redact_notes: false +metadata: + env: test +YAML + +cat > "$tmp/bin/keito" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$KEITO_FAKE_CALLS" +case "$*" in + *"time session-record"*) + printf '{"status":"created","entry_id":"te_test","source":"agent","session_id":"sess_test"}\n' + ;; + *"auth status"*) + printf '{"authenticated":true,"api_key_valid":true,"account_id":"co_test"}\n' + ;; + *"clients list"*) + printf '[{"id":"c1","name":"Client A"}]\n' + ;; + *"clients create"*) + printf '{"id":"c_new","name":"New Client"}\n' + ;; + *"projects list"*"--client c_new"*) + printf '[]\n' + ;; + *"projects list"*) + printf '[{"id":"p1","name":"Project A","code":"PA"}]\n' + ;; + *"projects create"*) + if [ "${KEITO_FAKE_BAD_PROJECT_CREATE:-}" = "1" ]; then + printf '{"name":"New Project"}\n' + exit 0 + fi + printf '{"id":"p_new","name":"New Project","client":{"id":"c_new","name":"New Client"}}\n' + ;; + *"projects tasks"*) + printf '[{"id":"t1","name":"Development"}]\n' + ;; + *"time list"*) + printf '[]\n' + ;; + *) + printf '{}\n' + ;; +esac +SH +chmod +x "$tmp/bin/keito" + +export PATH="$tmp/bin:$PATH" +export KEITO_FAKE_CALLS="$tmp/keito-calls.log" +export KEITO_CLI_BIN="$tmp/bin/keito" + +payload=$(jq -n --arg cwd "$tmp/repo" --arg session_id "sess_test" \ + '{cwd: $cwd, session_id: $session_id, last_assistant_message: "Implemented a local test feature."}') + +"$skill_root/hooks/session-start.sh" <<< "$payload" +state_file=$(find "$KEITO_SKILL_HOME/sessions" -type f -name '*.json' | head -n 1) +[ -f "$state_file" ] + +jq '.started_epoch = (.started_epoch - 65)' "$state_file" > "$state_file.tmp" +mv "$state_file.tmp" "$state_file" + +"$skill_root/hooks/session-end.sh" <<< "$payload" + +if find "$KEITO_SKILL_HOME/sessions" -type f -name '*.json' | grep -q .; then + echo "Expected session state to be removed after successful logging" >&2 + exit 1 +fi + +grep -q -- "time session-record" "$KEITO_FAKE_CALLS" +grep -q -- "--workspace co_test" "$KEITO_FAKE_CALLS" +grep -q -- "--project p1" "$KEITO_FAKE_CALLS" +grep -q -- "--task t1" "$KEITO_FAKE_CALLS" +grep -q -- "--session-id sess_test" "$KEITO_FAKE_CALLS" +grep -q -- "--source agent" "$KEITO_FAKE_CALLS" + +short_payload=$(jq -n --arg cwd "$tmp/repo" --arg session_id "short_session" \ + '{cwd: $cwd, session_id: $session_id}') +"$skill_root/hooks/session-start.sh" <<< "$short_payload" +"$skill_root/hooks/session-end.sh" <<< "$short_payload" +if grep -q -- "short_session" "$KEITO_FAKE_CALLS"; then + echo "Expected short session to be skipped" >&2 + exit 1 +fi + +mkdir -p "$tmp/repo-alpha/.keito" "$tmp/repo-beta/.keito" "$tmp/untracked-repo" +cat > "$tmp/repo-alpha/.keito/config.yml" <<'YAML' +version: 1 +workspace_id: co_test +client_id: c_alpha +project_id: p_alpha +project_name: "Project Alpha" +task_id: t_alpha +task_name: "Development" +agent_tracking: + enabled: true + source: agent + draft: true + min_duration_seconds: 60 + max_duration_seconds: 28800 + redact_notes: false +metadata: + env: alpha +YAML +cat > "$tmp/repo-beta/.keito/config.yml" <<'YAML' +version: 1 +workspace_id: co_test +client_id: c_beta +project_id: p_beta +project_name: "Project Beta" +task_id: t_beta +task_name: "Development" +agent_tracking: + enabled: true + source: agent + draft: true + min_duration_seconds: 60 + max_duration_seconds: 28800 + redact_notes: false +metadata: + env: beta +YAML + +alpha_payload=$(jq -n --arg cwd "$tmp/repo-alpha" --arg session_id "sess_alpha" \ + '{cwd: $cwd, session_id: $session_id, last_assistant_message: "Alpha repo work."}') +beta_payload=$(jq -n --arg cwd "$tmp/repo-beta" --arg session_id "sess_beta" \ + '{cwd: $cwd, session_id: $session_id, last_assistant_message: "Beta repo work."}') +untracked_payload=$(jq -n --arg cwd "$tmp/untracked-repo" --arg session_id "sess_untracked" \ + '{cwd: $cwd, session_id: $session_id}') + +"$skill_root/hooks/session-start.sh" <<< "$alpha_payload" +"$skill_root/hooks/session-start.sh" <<< "$beta_payload" +"$skill_root/hooks/session-start.sh" <<< "$untracked_payload" + +for tracked_state in "$KEITO_SKILL_HOME/sessions/sess_alpha.json" "$KEITO_SKILL_HOME/sessions/sess_beta.json"; do + [ -f "$tracked_state" ] + jq '.started_epoch = (.started_epoch - 65)' "$tracked_state" > "$tracked_state.tmp" + mv "$tracked_state.tmp" "$tracked_state" +done + +"$skill_root/hooks/session-end.sh" <<< "$alpha_payload" +"$skill_root/hooks/session-end.sh" <<< "$beta_payload" +"$skill_root/hooks/session-end.sh" <<< "$untracked_payload" + +alpha_call=$(grep -- "--session-id sess_alpha" "$KEITO_FAKE_CALLS") +beta_call=$(grep -- "--session-id sess_beta" "$KEITO_FAKE_CALLS") +if [[ "$alpha_call" != *"--project p_alpha"* ]] || [[ "$alpha_call" == *"--project p_beta"* ]]; then + echo "Expected alpha repo session to use only alpha project config" >&2 + exit 1 +fi +if [[ "$beta_call" != *"--project p_beta"* ]] || [[ "$beta_call" == *"--project p_alpha"* ]]; then + echo "Expected beta repo session to use only beta project config" >&2 + exit 1 +fi +if grep -q -- "sess_untracked" "$KEITO_FAKE_CALLS"; then + echo "Expected untracked repo session not to log time" >&2 + exit 1 +fi + +mkdir -p "$tmp/setup-repo" +( + cd "$tmp/setup-repo" + printf '1\n1\n1\nY\nY\n' | "$skill_root/scripts/setup-wizard.sh" >/dev/null +) +[ -f "$tmp/setup-repo/.keito/config.yml" ] +grep -q -- "client_id: c1" "$tmp/setup-repo/.keito/config.yml" +grep -q -- "project_id: p1" "$tmp/setup-repo/.keito/config.yml" +grep -q -- "task_id: t1" "$tmp/setup-repo/.keito/config.yml" +grep -q -- "integration: keito_skill" "$tmp/setup-repo/.keito/config.yml" + +status_output=$( + cd "$tmp/setup-repo" + "$skill_root/scripts/status.sh" +) +grep -q -- "Workspace: co_test" <<< "$status_output" +grep -q -- "Client: Client A (c1)" <<< "$status_output" +grep -q -- "Project: Project A (p1)" <<< "$status_output" + +( + cd "$tmp/setup-repo" + printf 'Y\n' | "$skill_root/scripts/disable.sh" >/dev/null +) +[ ! -f "$tmp/setup-repo/.keito/config.yml" ] +find "$tmp/setup-repo/.keito" -name 'config.yml.disabled*' | grep -q . + +mkdir -p "$tmp/setup-create-repo" +( + cd "$tmp/setup-create-repo" + printf '2\nNew Client\n1\nNew Project\n1\nY\nY\n' | "$skill_root/scripts/setup-wizard.sh" >/dev/null +) +[ -f "$tmp/setup-create-repo/.keito/config.yml" ] +grep -q -- "client_id: c_new" "$tmp/setup-create-repo/.keito/config.yml" +grep -q -- "project_id: p_new" "$tmp/setup-create-repo/.keito/config.yml" +grep -q -- "integration: keito_skill" "$tmp/setup-create-repo/.keito/config.yml" + +mkdir -p "$tmp/setup-cancel-repo" +( + cd "$tmp/setup-cancel-repo" + printf '1\n1\n1\nY\nn\n' | "$skill_root/scripts/setup-wizard.sh" >/dev/null +) +if [ -e "$tmp/setup-cancel-repo/.keito/config.yml" ]; then + echo "Expected setup wizard cancellation not to write config" >&2 + exit 1 +fi + +mkdir -p "$tmp/setup-fail-repo" +set +e +( + cd "$tmp/setup-fail-repo" + printf '2\nNew Client\n1\nNew Project\n' | KEITO_FAKE_BAD_PROJECT_CREATE=1 "$skill_root/scripts/setup-wizard.sh" >/dev/null 2>"$tmp/setup-fail.err" +) +setup_fail_status=$? +set -e +if [ "$setup_fail_status" -eq 0 ]; then + echo "Expected setup wizard to fail when project create response has no id" >&2 + exit 1 +fi +if [ -e "$tmp/setup-fail-repo/.keito/config.yml" ]; then + echo "Expected setup wizard not to write config after failed project creation" >&2 + exit 1 +fi +grep -q -- "created project id" "$tmp/setup-fail.err" + +echo "skill hook tests passed"