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.
+
@@ -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"