diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml index 34227fc..f590a44 100644 --- a/.github/workflows/validate-skills.yml +++ b/.github/workflows/validate-skills.yml @@ -5,6 +5,7 @@ on: pull_request: paths: - 'skills/**' + - 'skill-data/**' - 'package.json' - '.github/workflows/validate-skills.yml' push: @@ -12,6 +13,7 @@ on: - main paths: - 'skills/**' + - 'skill-data/**' - 'package.json' - '.github/workflows/validate-skills.yml' diff --git a/README.md b/README.md index 5d7a97f..2165297 100644 --- a/README.md +++ b/README.md @@ -153,20 +153,27 @@ Recommended sequence: 5. Export WebM recordings when reviewers need motion proof. 6. Destroy the session when done. -## AI agent skill +## AI agent skills -The public skill lives under `skills/agent-tty/` and ships in the npm package as well as the GitHub Release tarball. -Install `agent-tty` from npm first (or from a GitHub Release tarball when you need a registry-independent fallback), then either use the packaged skill directly or let TanStack Intent map it into your agent config. +`agent-tty` ships two related skill trees in the npm package as well as the GitHub Release tarball: -For coding agents that can ingest instructions on demand, `agent-tty skill` prints the packaged `SKILL.md` directly to stdout after installation. +- `skills/agent-tty/` is the thin public bootstrap used by TanStack Intent and other skill loaders that discover files directly. +- `skill-data/` contains the canonical runtime skills served by the CLI. +- `agent-tty skills list` discovers the bundled runtime skills, including `agent-tty` and `dogfood-tui`. + +Install `agent-tty` from npm first (or from a GitHub Release tarball when you need a registry-independent fallback), then either copy the bootstrap skill into your agent config or let the CLI print the canonical runtime skill on demand. + +For coding agents that can ingest instructions on demand, `agent-tty skills get ` prints the packaged runtime `SKILL.md` directly to stdout after installation. ```bash -agent-tty skill +agent-tty skills get agent-tty ``` +Use `agent-tty skills list` to discover every bundled runtime skill, and `agent-tty skills get dogfood-tui` when you want the built-in TUI dogfooding skill. + ### TanStack Intent integration -After installing `agent-tty` in the project, let Intent wire the mapping into `AGENTS.md`, `CLAUDE.md`, or another supported agent config file. +After installing `agent-tty` in the project, let Intent wire the bootstrap from `skills/agent-tty/` into `AGENTS.md`, `CLAUDE.md`, or another supported agent config file. ```bash PACKAGE_VERSION= @@ -175,27 +182,29 @@ npx @tanstack/intent@latest list npx @tanstack/intent@latest install ``` -That workflow keeps the skill version aligned with the installed `agent-tty` package and avoids writing one-off instructions for each individual coding agent. +That workflow keeps the skill version aligned with the installed `agent-tty` package, while the bootstrap stays small and points agents back to the CLI-served runtime skill. ### Mux skill installation -After installing the npm package globally: +After installing the npm package globally, copy the bootstrap skill from `skills/agent-tty/`: ```bash mkdir -p ~/.mux/skills/agent-tty cp -R "$(npm root -g)/agent-tty/skills/agent-tty/." ~/.mux/skills/agent-tty/ ``` +Mux can then discover the bootstrap normally, and the bootstrap instructs the agent to load the canonical runtime skill with `agent-tty skills get agent-tty`. + ### Direct skill copy for other skill loaders -After installing the npm package globally: +After installing the npm package globally, copy the same bootstrap for loaders that read skill files directly: ```bash mkdir -p ~/.claude/skills/agent-tty cp -R "$(npm root -g)/agent-tty/skills/agent-tty/." ~/.claude/skills/agent-tty/ ``` -If your assistant supports repository-backed skills, point it at `coder/agent-tty` and select the `agent-tty` skill directory. +If your assistant supports repository-backed skills, point it at `coder/agent-tty` and select the `skills/agent-tty/` bootstrap directory. ### Suggested `AGENTS.md` / `CLAUDE.md` snippet @@ -214,7 +223,7 @@ Preferred workflow: 6. Destroy the session when the task is done. ``` -Maintainers can validate the shipped skill locally with: +Maintainers can validate the shipped bootstrap skill locally with: ```bash npm run intent:validate diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 008e4b4..20c85cd 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -41,7 +41,7 @@ Or use the combined entry point: npm run verify ``` -If you touch the public skill, also run: +If you touch the public bootstrap under `skills/` or the bundled runtime skills under `skill-data/`, also run: ```bash npm run intent:validate @@ -51,5 +51,6 @@ npm run intent:validate - Keep the root docs split clear: `README.md` for overview, `RELEASE.md` for current scope, `ROADMAP.md` for future scope. - Update [`design/README.md`](../design/README.md) when the active vs archived design split changes. +- Keep the skill split clear in docs and packaging notes: `skills/` contains the thin public bootstrap, while `skill-data/` contains the canonical runtime skills served by `agent-tty skills get`. - Update [`dogfood/CATALOG.md`](../dogfood/CATALOG.md) when you add or promote a reviewer-facing proof bundle. - Prefer public `agent-tty ...` invocations in shipped skill/docs examples; do not commit repo-local `npx tsx src/cli/main.ts ...` substitutions into public-facing examples. diff --git a/docs/RELEASE-PROCESS.md b/docs/RELEASE-PROCESS.md index 7cae60b..0caf9dc 100644 --- a/docs/RELEASE-PROCESS.md +++ b/docs/RELEASE-PROCESS.md @@ -40,7 +40,7 @@ If `mise` is unavailable, run: npm run verify ``` -If the public skill changed, also run: +If the public bootstrap under `skills/` or the bundled runtime skills under `skill-data/` changed, also run: ```bash npm run intent:validate @@ -60,6 +60,8 @@ cat "$RELEASE_DIR/package-metadata.json" sha256sum -c "$RELEASE_DIR"/*.tgz.sha256 ``` +When skill packaging changes, also inspect `npm pack --dry-run` output to confirm the tarball still includes both `skills/` (bootstrap) and `skill-data/` (runtime skills). + That command produces the same tarball, checksum, and metadata shape that the GitHub release workflow uploads and later reuses for npm publishing. ## Release flow overview diff --git a/dogfood/20260413-skills-runtime-refactor/agent-tty-home.txt b/dogfood/20260413-skills-runtime-refactor/agent-tty-home.txt new file mode 100644 index 0000000..277f943 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/agent-tty-home.txt @@ -0,0 +1 @@ +/tmp/tmp.FCAJKW974f diff --git a/dogfood/20260413-skills-runtime-refactor/command-log.tsv b/dogfood/20260413-skills-runtime-refactor/command-log.tsv new file mode 100644 index 0000000..cba9c14 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/command-log.tsv @@ -0,0 +1,28 @@ +agent-tty-home.txt 0 mktemp -d > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/agent-tty-home.txt +skills-list.json 0 npx tsx src/cli/main.ts skills list --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/skills-list.json +skills-get-agent-tty.json 0 npx tsx src/cli/main.ts skills get agent-tty --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/skills-get-agent-tty.json +skills-get-dogfood-tui.json 0 npx tsx src/cli/main.ts skills get dogfood-tui --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/skills-get-dogfood-tui.json +skills-path-agent-tty.json 0 npx tsx src/cli/main.ts skills path agent-tty --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/skills-path-agent-tty.json +skills-path-dogfood-tui.json 0 npx tsx src/cli/main.ts skills path dogfood-tui --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/skills-path-dogfood-tui.json +npm-pack-skill-files.txt 0 npm pack --json --dry-run 2>/dev/null | jq -r '.[0].files[] | .path' | grep -E '^(skills|skill-data)/' > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/npm-pack-skill-files.txt +tui-doctor.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f doctor --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-doctor.json +tui-create.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f create --json -- npx tsx test/fixtures/apps/hello-prompt/main.ts > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-create.json +session-id.txt 0 jq -r '.result.sessionId' /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-create.json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/session-id.txt +tui-wait-ready.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f wait 01KP34JQENXVMGTMHQW8GSTHJH --json --text READY\>\ > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-wait-ready.json +tui-type-agent.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f type 01KP34JQENXVMGTMHQW8GSTHJH --json Agent > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-type-agent.json +tui-send-enter.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f send-keys 01KP34JQENXVMGTMHQW8GSTHJH --json Enter > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-send-enter.json +tui-wait-echo.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f wait 01KP34JQENXVMGTMHQW8GSTHJH --json --text ECHO:\ Agent > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-wait-echo.json +tui-wait-stable.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f wait 01KP34JQENXVMGTMHQW8GSTHJH --json --screen-stable-ms 500 > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-wait-stable.json +tui-snapshot-text.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f snapshot 01KP34JQENXVMGTMHQW8GSTHJH --format text --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-snapshot-text.json +tui-screenshot-echo.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f screenshot 01KP34JQENXVMGTMHQW8GSTHJH --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-screenshot-echo.json +copy-screenshot 0 cp /tmp/tmp.FCAJKW974f/sessions/01KP34JQENXVMGTMHQW8GSTHJH/artifacts/screenshot-5-reference-dark.png /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/screenshots/hello-prompt-echo.png +tui-type-exit.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f type 01KP34JQENXVMGTMHQW8GSTHJH --json exit > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-type-exit.json +tui-send-enter-exit.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f send-keys 01KP34JQENXVMGTMHQW8GSTHJH --json Enter > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-send-enter-exit.json +tui-wait-exit.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f wait 01KP34JQENXVMGTMHQW8GSTHJH --json --exit > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-wait-exit.json +tui-inspect-final.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f inspect 01KP34JQENXVMGTMHQW8GSTHJH --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-inspect-final.json +tui-record-export-cast.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f record export 01KP34JQENXVMGTMHQW8GSTHJH --format asciicast --out /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.cast --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-record-export-cast.json +tui-record-export-webm.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f record export 01KP34JQENXVMGTMHQW8GSTHJH --format webm --out /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.webm --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-record-export-webm.json +tui-destroy.json 0 npx tsx src/cli/main.ts --home /tmp/tmp.FCAJKW974f destroy 01KP34JQENXVMGTMHQW8GSTHJH --json > /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/tui-destroy.json +validate-bundle 0 node --input-type=module +manifest.json 0 write /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/manifest.json +notes.md 0 python3 diff --git a/dogfood/20260413-skills-runtime-refactor/commands.sh b/dogfood/20260413-skills-runtime-refactor/commands.sh new file mode 100755 index 0000000..59f8d7b --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/commands.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BUNDLE_DIR="$SCRIPT_DIR" +SCREENSHOTS_DIR="$BUNDLE_DIR/screenshots" +RECORDINGS_DIR="$BUNDLE_DIR/recordings" +COMMAND_LOG="$BUNDLE_DIR/command-log.tsv" +CLI=(npx tsx src/cli/main.ts) +FIXTURE=(npx tsx test/fixtures/apps/hello-prompt/main.ts) +SESSION_HOME="" +SESSION_ID="" +CLI_NODE_VERSION="" +NPM_VERSION="" +GIT_COMMIT="" +SKILLS_LIST_PATH="" +SKILLS_GET_AGENT_TTY_PATH="" +SKILLS_GET_DOGFOOD_TUI_PATH="" +SKILLS_PATH_AGENT_TTY_DIR="" +SKILLS_PATH_DOGFOOD_TUI_DIR="" +SCREENSHOT_SOURCE_PATH="" +SCREENSHOT_DEST_PATH="$SCREENSHOTS_DIR/hello-prompt-echo.png" +CAST_PATH="$RECORDINGS_DIR/hello-prompt.cast" +WEBM_PATH="$RECORDINGS_DIR/hello-prompt.webm" + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + printf 'missing required command: %s\n' "$1" >&2 + exit 1 + } +} + +assert_file() { + local path="$1" + [[ -f "$path" ]] || { + printf 'expected file to exist: %s\n' "$path" >&2 + exit 1 + } +} + +assert_dir() { + local path="$1" + [[ -d "$path" ]] || { + printf 'expected directory to exist: %s\n' "$path" >&2 + exit 1 + } +} + +command_string() { + local rendered + printf -v rendered '%q ' "$@" + printf '%s' "${rendered% }" +} + +log_step() { + local label="$1" + local exit_code="$2" + local command="$3" + printf '%s\t%s\t%s\n' "$label" "$exit_code" "$command" >> "$COMMAND_LOG" +} + +run_json() { + local label="$1" + shift + local output_path="$BUNDLE_DIR/$label" + local -a cmd=("$@") + local rendered_command + rendered_command="$(command_string "${cmd[@]}") > "$output_path"" + local exit_code=0 + if ! "${cmd[@]}" > "$output_path"; then + exit_code=$? + fi + log_step "$label" "$exit_code" "$rendered_command" + (( exit_code == 0 )) || return "$exit_code" +} + +run_shell_capture() { + local label="$1" + local shell_command="$2" + local output_path="$BUNDLE_DIR/$label" + local exit_code=0 + if ! bash -lc "$shell_command" > "$output_path"; then + exit_code=$? + fi + log_step "$label" "$exit_code" "$shell_command > $output_path" + (( exit_code == 0 )) || return "$exit_code" +} + +cleanup() { + local exit_code=$? + if [[ -n "$SESSION_ID" && -n "$SESSION_HOME" && -d "$SESSION_HOME" ]]; then + if [[ ! -f "$BUNDLE_DIR/tui-destroy.json" ]]; then + set +e + "${CLI[@]}" --home "$SESSION_HOME" destroy "$SESSION_ID" --json > "$BUNDLE_DIR/tui-destroy.json" + local destroy_exit_code=$? + set -e + log_step \ + 'tui-destroy.json' \ + "$destroy_exit_code" \ + "$(command_string "${CLI[@]}" --home "$SESSION_HOME" destroy "$SESSION_ID" --json) > $BUNDLE_DIR/tui-destroy.json" + fi + rm -rf "$SESSION_HOME" + fi + exit "$exit_code" +} +trap cleanup EXIT + +require_command npx +require_command npm +require_command jq +require_command python3 + +mkdir -p "$SCREENSHOTS_DIR" "$RECORDINGS_DIR" +find "$BUNDLE_DIR" -maxdepth 1 -type f ! -name 'commands.sh' -delete +find "$SCREENSHOTS_DIR" -maxdepth 1 -type f -delete +find "$RECORDINGS_DIR" -maxdepth 1 -type f -delete +: > "$COMMAND_LOG" + +cd "$REPO_ROOT" + +CLI_NODE_VERSION="$(npx tsx --eval 'console.log(process.version)')" +NPM_VERSION="$(npm -v)" +GIT_COMMIT="$(git rev-parse --short HEAD)" +SESSION_HOME="$(mktemp -d)" + +printf '%s\n' "$SESSION_HOME" > "$BUNDLE_DIR/agent-tty-home.txt" +log_step 'agent-tty-home.txt' 0 "mktemp -d > $BUNDLE_DIR/agent-tty-home.txt" + +run_json 'skills-list.json' "${CLI[@]}" skills list --json +run_json 'skills-get-agent-tty.json' "${CLI[@]}" skills get agent-tty --json +run_json 'skills-get-dogfood-tui.json' "${CLI[@]}" skills get dogfood-tui --json +run_json 'skills-path-agent-tty.json' "${CLI[@]}" skills path agent-tty --json +run_json 'skills-path-dogfood-tui.json' "${CLI[@]}" skills path dogfood-tui --json +run_shell_capture 'npm-pack-skill-files.txt' "npm pack --json --dry-run 2>/dev/null | jq -r '.[0].files[] | .path' | grep -E '^(skills|skill-data)/'" + +SKILLS_LIST_PATH="$(jq -r '.result.skills[] | select(.name == "agent-tty") | .path' "$BUNDLE_DIR/skills-list.json")" +SKILLS_GET_AGENT_TTY_PATH="$(jq -r '.result.path' "$BUNDLE_DIR/skills-get-agent-tty.json")" +SKILLS_GET_DOGFOOD_TUI_PATH="$(jq -r '.result.path' "$BUNDLE_DIR/skills-get-dogfood-tui.json")" +SKILLS_PATH_AGENT_TTY_DIR="$(jq -r '.result.path' "$BUNDLE_DIR/skills-path-agent-tty.json")" +SKILLS_PATH_DOGFOOD_TUI_DIR="$(jq -r '.result.path' "$BUNDLE_DIR/skills-path-dogfood-tui.json")" + +run_json 'tui-doctor.json' "${CLI[@]}" --home "$SESSION_HOME" doctor --json +run_json 'tui-create.json' "${CLI[@]}" --home "$SESSION_HOME" create --json -- "${FIXTURE[@]}" +SESSION_ID="$(jq -r '.result.sessionId' "$BUNDLE_DIR/tui-create.json")" +[[ -n "$SESSION_ID" && "$SESSION_ID" != 'null' ]] || { + printf 'failed to extract session id from tui-create.json\n' >&2 + exit 1 +} +printf '%s\n' "$SESSION_ID" > "$BUNDLE_DIR/session-id.txt" +log_step 'session-id.txt' 0 "jq -r '.result.sessionId' $BUNDLE_DIR/tui-create.json > $BUNDLE_DIR/session-id.txt" + +run_json 'tui-wait-ready.json' "${CLI[@]}" --home "$SESSION_HOME" wait "$SESSION_ID" --json --text 'READY> ' +run_json 'tui-type-agent.json' "${CLI[@]}" --home "$SESSION_HOME" type "$SESSION_ID" --json 'Agent' +run_json 'tui-send-enter.json' "${CLI[@]}" --home "$SESSION_HOME" send-keys "$SESSION_ID" --json Enter +run_json 'tui-wait-echo.json' "${CLI[@]}" --home "$SESSION_HOME" wait "$SESSION_ID" --json --text 'ECHO: Agent' +run_json 'tui-wait-stable.json' "${CLI[@]}" --home "$SESSION_HOME" wait "$SESSION_ID" --json --screen-stable-ms 500 +run_json 'tui-snapshot-text.json' "${CLI[@]}" --home "$SESSION_HOME" snapshot "$SESSION_ID" --format text --json +run_json 'tui-screenshot-echo.json' "${CLI[@]}" --home "$SESSION_HOME" screenshot "$SESSION_ID" --json +SCREENSHOT_SOURCE_PATH="$(jq -r '.result.artifactPath' "$BUNDLE_DIR/tui-screenshot-echo.json")" +assert_file "$SCREENSHOT_SOURCE_PATH" +cp "$SCREENSHOT_SOURCE_PATH" "$SCREENSHOT_DEST_PATH" +log_step 'copy-screenshot' 0 "cp $SCREENSHOT_SOURCE_PATH $SCREENSHOT_DEST_PATH" + +run_json 'tui-type-exit.json' "${CLI[@]}" --home "$SESSION_HOME" type "$SESSION_ID" --json 'exit' +run_json 'tui-send-enter-exit.json' "${CLI[@]}" --home "$SESSION_HOME" send-keys "$SESSION_ID" --json Enter +run_json 'tui-wait-exit.json' "${CLI[@]}" --home "$SESSION_HOME" wait "$SESSION_ID" --json --exit +run_json 'tui-inspect-final.json' "${CLI[@]}" --home "$SESSION_HOME" inspect "$SESSION_ID" --json +run_json 'tui-record-export-cast.json' "${CLI[@]}" --home "$SESSION_HOME" record export "$SESSION_ID" --format asciicast --out "$CAST_PATH" --json +run_json 'tui-record-export-webm.json' "${CLI[@]}" --home "$SESSION_HOME" record export "$SESSION_ID" --format webm --out "$WEBM_PATH" --json +run_json 'tui-destroy.json' "${CLI[@]}" --home "$SESSION_HOME" destroy "$SESSION_ID" --json +rm -rf "$SESSION_HOME" +SESSION_HOME="" + +assert_file "$SCREENSHOT_DEST_PATH" +assert_file "$CAST_PATH" +assert_file "$WEBM_PATH" + +node --input-type=module - "$BUNDLE_DIR" <<'NODE' +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const bundleDir = process.argv[2]; +const requiredJsonFiles = [ + 'skills-list.json', + 'skills-get-agent-tty.json', + 'skills-get-dogfood-tui.json', + 'skills-path-agent-tty.json', + 'skills-path-dogfood-tui.json', + 'tui-doctor.json', + 'tui-create.json', + 'tui-wait-ready.json', + 'tui-type-agent.json', + 'tui-send-enter.json', + 'tui-wait-echo.json', + 'tui-wait-stable.json', + 'tui-snapshot-text.json', + 'tui-screenshot-echo.json', + 'tui-type-exit.json', + 'tui-send-enter-exit.json', + 'tui-wait-exit.json', + 'tui-inspect-final.json', + 'tui-record-export-cast.json', + 'tui-record-export-webm.json', + 'tui-destroy.json', +]; +for (const file of requiredJsonFiles) { + const payload = JSON.parse(fs.readFileSync(path.join(bundleDir, file), 'utf8')); + assert.equal(payload.ok, true, `${file} should report ok=true`); +} +const skillsList = JSON.parse(fs.readFileSync(path.join(bundleDir, 'skills-list.json'), 'utf8')); +const skillNames = new Set(skillsList.result.skills.map((skill) => skill.name)); +assert(skillNames.has('agent-tty'), 'skills list should include agent-tty'); +assert(skillNames.has('dogfood-tui'), 'skills list should include dogfood-tui'); +const agentTty = JSON.parse(fs.readFileSync(path.join(bundleDir, 'skills-get-agent-tty.json'), 'utf8')); +const dogfoodTui = JSON.parse(fs.readFileSync(path.join(bundleDir, 'skills-get-dogfood-tui.json'), 'utf8')); +assert(agentTty.result.path.endsWith('/skill-data/agent-tty/SKILL.md'), 'agent-tty runtime skill should come from skill-data'); +assert(dogfoodTui.result.path.endsWith('/skill-data/dogfood-tui/SKILL.md'), 'dogfood-tui runtime skill should come from skill-data'); +assert.equal(agentTty.result.content, fs.readFileSync(agentTty.result.path, 'utf8'), 'agent-tty content should match runtime skill file'); +assert.equal(dogfoodTui.result.content, fs.readFileSync(dogfoodTui.result.path, 'utf8'), 'dogfood-tui content should match runtime skill file'); +const agentTtyPath = JSON.parse(fs.readFileSync(path.join(bundleDir, 'skills-path-agent-tty.json'), 'utf8')); +const dogfoodTuiPath = JSON.parse(fs.readFileSync(path.join(bundleDir, 'skills-path-dogfood-tui.json'), 'utf8')); +assert(agentTtyPath.result.path.endsWith('/skill-data/agent-tty'), 'skills path agent-tty should resolve to runtime directory'); +assert(dogfoodTuiPath.result.path.endsWith('/skill-data/dogfood-tui'), 'skills path dogfood-tui should resolve to runtime directory'); +const npmPackPaths = fs.readFileSync(path.join(bundleDir, 'npm-pack-skill-files.txt'), 'utf8').trim().split(/\n+/); +for (const expectedPath of [ + 'skills/agent-tty/SKILL.md', + 'skill-data/agent-tty/SKILL.md', + 'skill-data/dogfood-tui/SKILL.md', +]) { + assert(npmPackPaths.includes(expectedPath), `npm pack file list should include ${expectedPath}`); +} +const screenshotPath = path.join(bundleDir, 'screenshots', 'hello-prompt-echo.png'); +const screenshotBuffer = fs.readFileSync(screenshotPath); +assert(screenshotBuffer.byteLength > 1024, 'screenshot should be larger than 1KB'); +assert.equal(screenshotBuffer.subarray(0, 8).toString('hex'), '89504e470d0a1a0a', 'screenshot should have PNG signature'); +for (const recordingPath of [ + path.join(bundleDir, 'recordings', 'hello-prompt.cast'), + path.join(bundleDir, 'recordings', 'hello-prompt.webm'), +]) { + const stats = fs.statSync(recordingPath); + assert(stats.size > 0, `${path.basename(recordingPath)} should be non-empty`); +} +NODE +log_step 'validate-bundle' 0 "node --input-type=module " + +cat > "$BUNDLE_DIR/manifest.json" <` directories; `npm pack --json --dry-run` listed both the bootstrap `skills/agent-tty/SKILL.md` file and the runtime `skill-data/` copies; and the `hello-prompt` dogfood run produced a non-empty PNG screenshot plus both `.cast` and `.webm` recordings. +""" + +(bundle_dir / 'notes.md').write_text(notes, encoding='utf-8') +PY_NOTES +log_step 'notes.md' 0 "python3 " diff --git a/dogfood/20260413-skills-runtime-refactor/manifest.json b/dogfood/20260413-skills-runtime-refactor/manifest.json new file mode 100644 index 0000000..45b7861 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/manifest.json @@ -0,0 +1,30 @@ +{ + "bundle": "20260413-skills-runtime-refactor", + "description": "Skills runtime refactor validation", + "date": "2026-04-13", + "gitCommit": "2026f5c", + "notes": "notes.md", + "commands": "commands.sh", + "commandLog": "command-log.tsv", + "artifacts": { + "skills-list.json": "skills list --json output", + "skills-get-agent-tty.json": "skills get agent-tty --json output", + "skills-get-dogfood-tui.json": "skills get dogfood-tui --json output", + "skills-path-agent-tty.json": "skills path agent-tty --json output", + "skills-path-dogfood-tui.json": "skills path dogfood-tui --json output", + "npm-pack-skill-files.txt": "skill-related files in npm pack --json --dry-run output", + "tui-doctor.json": "doctor --json output for the isolated dogfood session", + "tui-create.json": "session creation output for the hello-prompt fixture", + "tui-wait-ready.json": "wait output for the READY prompt", + "tui-snapshot-text.json": "text snapshot after submitting Agent", + "tui-screenshot-echo.json": "screenshot command output for the echoed prompt", + "tui-record-export-cast.json": "asciicast export output", + "tui-record-export-webm.json": "webm export output", + "tui-inspect-final.json": "inspect output after the fixture exited", + "screenshots/hello-prompt-echo.png": "copied screenshot proof from the isolated session", + "recordings/hello-prompt.cast": "asciicast recording of the dogfood-tui flow", + "recordings/hello-prompt.webm": "webm recording of the dogfood-tui flow", + "session-id.txt": "captured session ID", + "agent-tty-home.txt": "temporary isolated home path used for the session" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/notes.md b/dogfood/20260413-skills-runtime-refactor/notes.md new file mode 100644 index 0000000..2eff4a7 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/notes.md @@ -0,0 +1,42 @@ +# Skills runtime refactor proof bundle + +- **Date:** 2026-04-13 +- **Bundle:** `dogfood/20260413-skills-runtime-refactor/` +- **CLI entrypoint:** `npx tsx src/cli/main.ts` +- **CLI Node:** `v24.14.1` +- **npm:** `10.9.3` +- **Git commit:** `2026f5c` +- **Isolated home:** `/tmp/tmp.FCAJKW974f` (cleaned up after `tui-destroy.json`) +- **Session ID:** `01KP34JQENXVMGTMHQW8GSTHJH` +- **Fixture:** `npx tsx test/fixtures/apps/hello-prompt/main.ts` + +## What was validated + +1. **Skills CLI surface** — `skills list`, `skills get`, and `skills path` all returned successful JSON envelopes with `"ok": true`. +2. **Bundled skills discovery** — `skills-list.json` lists both `agent-tty` and `dogfood-tui`. +3. **Runtime skill resolution** — `skills get` and `skills path` both resolve to `skill-data/`, proving the runtime CLI serves the canonical bundled skill files rather than the thin bootstrap under `skills/`. +4. **Tarball packaging** — `npm-pack-skill-files.txt` shows both `skills/agent-tty/SKILL.md` and the runtime `skill-data/` entries are included in `npm pack --json --dry-run`. +5. **dogfood-tui workflow** — the isolated `hello-prompt` session followed the dogfood skill pattern: `doctor`, `create`, `wait`, `type`, `send-keys`, `snapshot`, `screenshot`, `record export`, `inspect`, and `destroy`. + +## Key runtime paths + +- `skills-list.json` reported bundled entries for both `agent-tty` and `dogfood-tui` +- `skills-get-agent-tty.json` resolved to `/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/agent-tty/SKILL.md` +- `skills-get-dogfood-tui.json` resolved to `/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/dogfood-tui/SKILL.md` +- `skills-path-agent-tty.json` resolved to `/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/agent-tty` +- `skills-path-dogfood-tui.json` resolved to `/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/dogfood-tui` + +## TUI proof artifacts + +- `tui-snapshot-text.json` — searchable text proof of the echoed `Agent` response +- `tui-screenshot-echo.json` + `screenshots/hello-prompt-echo.png` — visual proof of the prompt and echoed input +- `tui-record-export-cast.json` + `recordings/hello-prompt.cast` — asciicast replay of the session +- `tui-record-export-webm.json` + `recordings/hello-prompt.webm` — WebM replay of the session +- `tui-inspect-final.json` — confirms the fixture exited before destroy +- `command-log.tsv` — exact commands run, including the screenshot copy and validation step + +## Expected vs actual + +Expected: the refactored `skills list/get/path` surface should expose bundled runtime skills from `skill-data/`, `npm pack --json --dry-run` should include both `skills/` and `skill-data/`, and the new `dogfood-tui` workflow should produce reviewable screenshot and recording artifacts. + +Actual: all required command envelopes parsed successfully with `"ok": true`; `skills list` included both bundled skills; `skills get` content matched the on-disk `skill-data/*/SKILL.md` files byte-for-byte; `skills path` resolved to the runtime `skill-data/` directories; `npm pack --json --dry-run` listed both the bootstrap `skills/agent-tty/SKILL.md` file and the runtime `skill-data/` copies; and the `hello-prompt` dogfood run produced a non-empty PNG screenshot plus both `.cast` and `.webm` recordings. diff --git a/dogfood/20260413-skills-runtime-refactor/npm-pack-skill-files.txt b/dogfood/20260413-skills-runtime-refactor/npm-pack-skill-files.txt new file mode 100644 index 0000000..a1a1709 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/npm-pack-skill-files.txt @@ -0,0 +1,3 @@ +skill-data/agent-tty/SKILL.md +skill-data/dogfood-tui/SKILL.md +skills/agent-tty/SKILL.md diff --git a/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.cast b/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.cast new file mode 100644 index 0000000..0e106bd --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.cast @@ -0,0 +1,11 @@ +{"version":2,"width":80,"height":24,"timestamp":1776074449,"title":"01KP34JQENXVMGTMHQW8GSTHJH","sessionId":"01KP34JQENXVMGTMHQW8GSTHJH","env":{"TERM":"xterm-256color"},"toolVersion":"0.1.1-beta.0"} +[0,"o","READY> "] +[601.826,"o","Agent"] +[602.916,"o","\r\n"] +[602.916,"o","ECHO: Agent\r\nREADY> "] +[609.734,"o","exit"] +[610.928,"o","\r\n"] +[610.929,"o","BYE\r\n"] +[610.949,"o","\\"] +[610.951,"o","\u001b[1G"] +[610.951,"o","\u001b[0K"] diff --git a/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.webm b/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.webm new file mode 100644 index 0000000..c41e359 Binary files /dev/null and b/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.webm differ diff --git a/dogfood/20260413-skills-runtime-refactor/screenshots/hello-prompt-echo.png b/dogfood/20260413-skills-runtime-refactor/screenshots/hello-prompt-echo.png new file mode 100644 index 0000000..6d37d2d Binary files /dev/null and b/dogfood/20260413-skills-runtime-refactor/screenshots/hello-prompt-echo.png differ diff --git a/dogfood/20260413-skills-runtime-refactor/session-id.txt b/dogfood/20260413-skills-runtime-refactor/session-id.txt new file mode 100644 index 0000000..6f673a0 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/session-id.txt @@ -0,0 +1 @@ +01KP34JQENXVMGTMHQW8GSTHJH diff --git a/dogfood/20260413-skills-runtime-refactor/skills-get-agent-tty.json b/dogfood/20260413-skills-runtime-refactor/skills-get-agent-tty.json new file mode 100644 index 0000000..2070f91 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/skills-get-agent-tty.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "command": "skills get", + "timestamp": "2026-04-13T10:00:40.710Z", + "result": { + "name": "agent-tty", + "source": "bundled", + "path": "/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/agent-tty/SKILL.md", + "content": "---\nname: agent-tty\ndescription: Terminal and TUI automation CLI for AI agents. Use when the user needs to create a terminal session, run a command in a terminal, automate an interactive CLI or TUI, wait for terminal output, capture a TUI screenshot, export a terminal recording, or test a CLI workflow with reviewable artifacts.\nadvertise: true\n---\n\n# Terminal Automation with agent-tty\n\nIf `agent-tty` is not already available in the environment, fetch the current `README.md` from `coder/agent-tty` on GitHub and follow its installation instructions before continuing. Otherwise use `agent-tty` directly.\nExamples use `jq` for JSON parsing; any JSON-processing tool works.\nPrefer isolated homes, JSON envelopes, and renderer-backed artifacts so terminal workflows stay reviewable and reproducible.\n\n## Core Workflow\n\nEvery terminal or TUI automation task should follow this pattern:\n\n1. **Create an isolated home** with `--home`.\n2. **Check prerequisites** with `doctor --json` before screenshot or recording work.\n3. **Create a session** with `create --json`.\n4. **Run setup commands** with `run` instead of simulating long shell typing.\n5. **Wait on observable terminal state** with `wait` instead of blind sleeps.\n6. **Inspect the current screen** with `snapshot`.\n7. **Capture proof artifacts** with `screenshot` or `record export`.\n8. **Destroy the session** when finished.\n\n```bash\nAGENT_HOME=\"$(mktemp -d)\"\nagent-tty --home \"$AGENT_HOME\" doctor --json\nSESSION_ID=$(agent-tty --home \"$AGENT_HOME\" create --json -- /bin/bash | jq -r '.result.sessionId')\nagent-tty --home \"$AGENT_HOME\" run \"$SESSION_ID\" 'printf \"ready\\n\"'\nagent-tty --home \"$AGENT_HOME\" wait \"$SESSION_ID\" --text 'ready' --json\nagent-tty --home \"$AGENT_HOME\" snapshot \"$SESSION_ID\" --format text --json\nagent-tty --home \"$AGENT_HOME\" screenshot \"$SESSION_ID\" --json\nagent-tty --home \"$AGENT_HOME\" record export \"$SESSION_ID\" --format webm --json\nagent-tty --home \"$AGENT_HOME\" destroy \"$SESSION_ID\" --json\n```\n\n## Essential Commands\n\n```bash\n# Environment and lifecycle\nagent-tty --home doctor --json\nagent-tty --home create --json -- /bin/bash\nagent-tty --home inspect --json\nagent-tty --home destroy --json\n\n# In-session control\nagent-tty --home run 'command here' --json\nagent-tty --home type 'literal text' --json\nagent-tty --home paste 'multiline payload' --json\nagent-tty --home send-keys Enter Ctrl+C --json\n\n# Observation and proof\nagent-tty --home wait --text 'ready' --json\nagent-tty --home wait --screen-stable-ms 1000 --json\nagent-tty --home snapshot --format text --json\nagent-tty --home screenshot --json\nagent-tty --home record export --format webm --json\n```\n\n## Common Patterns\n\n### Bootstrap a shell session\n\n```bash\nAGENT_HOME=\"$(mktemp -d)\"\nSESSION_ID=$(agent-tty --home \"$AGENT_HOME\" create --json -- /bin/bash | jq -r '.result.sessionId')\nagent-tty --home \"$AGENT_HOME\" run \"$SESSION_ID\" 'pwd && ls -la' --json\nagent-tty --home \"$AGENT_HOME\" snapshot \"$SESSION_ID\" --format text --json\n```\n\n### Drive an interactive CLI or TUI\n\n```bash\nAGENT_HOME=\"$(mktemp -d)\"\nSESSION_ID=$(agent-tty --home \"$AGENT_HOME\" create --json -- /bin/bash | jq -r '.result.sessionId')\nagent-tty --home \"$AGENT_HOME\" run \"$SESSION_ID\" '' --no-wait --json\nagent-tty --home \"$AGENT_HOME\" wait \"$SESSION_ID\" --screen-stable-ms 1000 --json\nagent-tty --home \"$AGENT_HOME\" send-keys \"$SESSION_ID\" Down Down Enter --json\nagent-tty --home \"$AGENT_HOME\" screenshot \"$SESSION_ID\" --json\n```\n\n### Export reviewer-facing artifacts\n\n```bash\nAGENT_HOME=\"$(mktemp -d)\"\nSESSION_ID=$(agent-tty --home \"$AGENT_HOME\" create --json -- /bin/bash | jq -r '.result.sessionId')\nagent-tty --home \"$AGENT_HOME\" run \"$SESSION_ID\" 'printf \"artifact proof\\n\"' --json\nagent-tty --home \"$AGENT_HOME\" wait \"$SESSION_ID\" --text 'artifact proof' --json\nagent-tty --home \"$AGENT_HOME\" screenshot \"$SESSION_ID\" --json\nagent-tty --home \"$AGENT_HOME\" record export \"$SESSION_ID\" --format asciicast --json\nagent-tty --home \"$AGENT_HOME\" record export \"$SESSION_ID\" --format webm --json\n```\n\n## Anti-Patterns\n\n- **Do not reach for `tmux`, `screen`, or ad hoc PTY wrappers first** when `agent-tty` can provide an isolated, inspectable session.\n- **Do not rely on blind `sleep` calls** when `wait --text`, `wait --idle-ms`, or `wait --screen-stable-ms` can observe terminal readiness directly.\n- **Do not bypass `--json`** when another tool or agent needs machine-readable results.\n- **Do not use external screenshot tools as the primary proof path** when `agent-tty screenshot` and `agent-tty record export` can produce renderer-backed artifacts tied to the session timeline.\n- **Do not leave sessions running after the task ends**; destroy them explicitly.\n- **Do not rewrite public examples into repo-local development invocations**; the public workflow should stay `agent-tty ...`.\n" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/skills-get-dogfood-tui.json b/dogfood/20260413-skills-runtime-refactor/skills-get-dogfood-tui.json new file mode 100644 index 0000000..5c84715 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/skills-get-dogfood-tui.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "command": "skills get", + "timestamp": "2026-04-13T10:00:41.936Z", + "result": { + "name": "dogfood-tui", + "source": "bundled", + "path": "/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/dogfood-tui/SKILL.md", + "content": "---\nname: dogfood-tui\ndescription: Structured TUI dogfooding and QA workflow using agent-tty. Use for exploratory testing, bug hunting, release-readiness validation, and UX review of terminal applications.\nadvertise: true\n---\n\n# Dogfooding TUIs with agent-tty\n\nUse this skill when the user wants structured exploratory testing, bug hunting, release-readiness validation, or UX review for a terminal application or TUI.\n\n## Prerequisites\n\nThis workflow assumes the `agent-tty` core skill is already loaded.\nIf it is not, load it first with `agent-tty skills get agent-tty`.\nUse this skill as the specialized QA layer on top of the core terminal automation workflow.\n\n## Dogfooding Workflow\n\n1. **Create an isolated home** so artifacts and session state stay reviewable and do not pollute the real user environment.\n2. **Check renderer and browser prerequisites** with `doctor --json` before any screenshot or recording work.\n3. **Create the session** with a known shell or launcher command and capture the returned session ID.\n4. **Launch the target app intentionally**:\n - Use `run` for fast setup commands or scripted launches.\n - Use `type` for literal keystroke-by-keystroke text entry that should appear in the session.\n - Use `send-keys` for control input such as arrows, Enter, Escape, Ctrl+C, or function-key navigation.\n5. **Wait on observable state** with `wait` instead of blind sleeps:\n - `--text` when a label, prompt, or status message should appear.\n - `--screen-stable-ms` when the UI is animating or repainting.\n - `--idle-ms` when command completion matters more than screen text.\n6. **Capture the current screen state** with `snapshot --format text --json` for searchable text evidence.\n7. **Capture visual proof** with `screenshot --json` whenever layout, color, cursor, or rendering quality matters.\n8. **Export motion proof** with `record export --format webm --json` when the issue involves navigation, animation, resize behavior, focus handling, or transient corruption.\n9. **Repeat the loop** for every meaningful scenario: startup, first-run prompts, resize, help flows, error handling, and teardown.\n10. **Destroy the session** when the investigation is complete.\n\n## Recommended Session Skeleton\n\n```bash\nDOGFOOD_HOME=\"$(mktemp -d)\"\nagent-tty --home \"$DOGFOOD_HOME\" doctor --json\nSESSION_ID=$(agent-tty --home \"$DOGFOOD_HOME\" create --json -- /bin/bash | jq -r '.result.sessionId')\nagent-tty --home \"$DOGFOOD_HOME\" run \"$SESSION_ID\" '' --no-wait --json\nagent-tty --home \"$DOGFOOD_HOME\" wait \"$SESSION_ID\" --screen-stable-ms 1000 --json\nagent-tty --home \"$DOGFOOD_HOME\" snapshot \"$SESSION_ID\" --format text --json\nagent-tty --home \"$DOGFOOD_HOME\" screenshot \"$SESSION_ID\" --json\nagent-tty --home \"$DOGFOOD_HOME\" record export \"$SESSION_ID\" --format webm --json\nagent-tty --home \"$DOGFOOD_HOME\" destroy \"$SESSION_ID\" --json\n```\n\n## Evidence Checklist\n\nCollect enough evidence that another reviewer can reproduce the result without guessing:\n\n- Exact repro commands, including the launch command and every subsequent `run`, `type`, or `send-keys` action.\n- Terminal dimensions used for the repro, especially if layout or wrapping is part of the issue.\n- At least one screenshot path for the failing or noteworthy state.\n- A WebM export path when motion, navigation sequence, or transient rendering matters.\n- Snapshot text for the most relevant terminal states so reviewers can search output quickly.\n- Expected behavior versus actual behavior written in plain language.\n- Whether the issue reproduces consistently, intermittently, or only after a specific setup sequence.\n- Cleanup notes, especially if the app leaves background state, temp files, or a broken terminal mode behind.\n\n## Issue Taxonomy\n\nUse consistent labels and notes so findings can be triaged quickly:\n\n- **Rendering corruption** — garbled characters, color loss, double paint, cursor artifacts, or stale cells.\n- **Resize/layout** — wrapping bugs, clipped panes, overlapping widgets, incorrect recompute after resize, or unusable small-screen behavior.\n- **Focus/input** — lost keystrokes, wrong focused widget, modal traps, broken shortcuts, or incorrect key interpretation.\n- **Scrollback** — missing history, jumpy scrolling, incorrect paging, or broken mouse wheel behavior.\n- **Alt-screen** — failure to enter or exit cleanly, leaked UI frames, or shell prompt corruption after exit.\n- **Copy/paste** — paste corruption, bracketed-paste issues, selection problems, or unsafe multiline submission behavior.\n- **Performance/startup** — slow launch, delayed first paint, high-latency navigation, or visible stutter.\n- **Crash/recovery/state loss** — panic, unexpected exit, broken resume path, lost form state, or inconsistent restoration after restart.\n\n## Report Template\n\nUse this structure when handing findings back to the user or a maintainer:\n\n- **Title:** concise issue summary\n- **Environment:** OS, shell, app version/commit, terminal dimensions, and whether `doctor --json` passed\n- **Reproduction steps:** numbered sequence with the exact `agent-tty` commands and key inputs used\n- **Evidence bundle:** paths to snapshot text, screenshot PNG, WebM, and any additional logs\n- **Expected behavior:** what should have happened\n- **Actual behavior:** what happened instead\n- **Impact:** severity, user-facing risk, and whether it blocks release-readiness\n- **Workaround:** any known mitigation or safer path\n- **Regression suspicion:** whether this looks new, long-standing, or tied to a recent change\n\nPrefer concise, repeatable repros and artifact-backed findings over narrative descriptions. The goal is a reviewable proof bundle, not just an anecdote.\n" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/skills-list.json b/dogfood/20260413-skills-runtime-refactor/skills-list.json new file mode 100644 index 0000000..f278ba8 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/skills-list.json @@ -0,0 +1,19 @@ +{ + "ok": true, + "command": "skills list", + "timestamp": "2026-04-13T10:00:39.624Z", + "result": { + "skills": [ + { + "name": "agent-tty", + "description": "Terminal and TUI automation CLI for AI agents. Use when the user needs to create a terminal session, run a command in a terminal, automate an interactive CLI or TUI, wait for terminal output, capture a TUI screenshot, export a terminal recording, or test a CLI workflow with reviewable artifacts.", + "source": "bundled" + }, + { + "name": "dogfood-tui", + "description": "Structured TUI dogfooding and QA workflow using agent-tty. Use for exploratory testing, bug hunting, release-readiness validation, and UX review of terminal applications.", + "source": "bundled" + } + ] + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/skills-path-agent-tty.json b/dogfood/20260413-skills-runtime-refactor/skills-path-agent-tty.json new file mode 100644 index 0000000..9bbcb84 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/skills-path-agent-tty.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "skills path", + "timestamp": "2026-04-13T10:00:43.043Z", + "result": { + "name": "agent-tty", + "source": "bundled", + "path": "/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/agent-tty" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/skills-path-dogfood-tui.json b/dogfood/20260413-skills-runtime-refactor/skills-path-dogfood-tui.json new file mode 100644 index 0000000..bf758c5 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/skills-path-dogfood-tui.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "skills path", + "timestamp": "2026-04-13T10:00:44.099Z", + "result": { + "name": "dogfood-tui", + "source": "bundled", + "path": "/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/skill-data/dogfood-tui" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-create.json b/dogfood/20260413-skills-runtime-refactor/tui-create.json new file mode 100644 index 0000000..3d5211b --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-13T10:00:49.066Z", + "result": { + "sessionId": "01KP34JQENXVMGTMHQW8GSTHJH", + "createdAt": "2026-04-13T10:00:48.344Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-destroy.json b/dogfood/20260413-skills-runtime-refactor/tui-destroy.json new file mode 100644 index 0000000..eb0b9b5 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-13T10:11:08.457Z", + "result": { + "sessionId": "01KP34JQENXVMGTMHQW8GSTHJH", + "destroyed": true + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-doctor.json b/dogfood/20260413-skills-runtime-refactor/tui-doctor.json new file mode 100644 index 0000000..21b0122 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-doctor.json @@ -0,0 +1,130 @@ +{ + "ok": true, + "command": "doctor", + "timestamp": "2026-04-13T10:00:47.260Z", + "result": { + "ok": true, + "checks": { + "environment": [ + { + "name": "node-runtime", + "status": "pass", + "message": "Node 24.14.1 ok", + "durationMs": 0 + }, + { + "name": "cwd-access", + "status": "pass", + "message": "cwd read/write: /home/coder/.mux/src/agent-terminal/agent_exec_db10489d06", + "durationMs": 1 + }, + { + "name": "temp-dir", + "status": "pass", + "message": "temp dir ok: /tmp", + "durationMs": 1 + }, + { + "name": "home_isolation", + "status": "pass", + "message": "Agent-terminal home is isolated from system home: /tmp/tmp.FCAJKW974f", + "durationMs": 0 + }, + { + "name": "home-writable", + "status": "pass", + "message": "home writable: /tmp/tmp.FCAJKW974f", + "durationMs": 1 + }, + { + "name": "pty-spawn", + "status": "pass", + "message": "spawned /home/coder/.local/share/mise/installs/node/24.14.1/bin/node", + "durationMs": 27 + }, + { + "name": "socket-viable", + "status": "pass", + "message": "socket ok: /tmp/tmp.FCAJKW974f/sessions/doctor-445696-mnx0v9yi-2/host.sock", + "durationMs": 4 + }, + { + "name": "artifact-atomicity", + "status": "pass", + "message": "atomic rename ok: /tmp/tmp.FCAJKW974f/sessions/doctor-445696-mnx0v9yl-3/artifacts", + "durationMs": 2 + }, + { + "name": "event-log-writable", + "status": "pass", + "message": "append ok: /tmp/tmp.FCAJKW974f/sessions/doctor-445696-mnx0v9yn-5/events.jsonl", + "durationMs": 1 + } + ], + "renderer": [ + { + "name": "playwright_available", + "status": "pass", + "message": "available", + "durationMs": 1 + }, + { + "name": "browser_cache_accessible", + "status": "pass", + "message": "browser cache accessible: /home/coder/.cache/ms-playwright", + "durationMs": 1 + }, + { + "name": "browser_launch", + "status": "pass", + "message": "chromium launches", + "durationMs": 143 + }, + { + "name": "ghostty_web_available", + "status": "pass", + "message": "WASM available", + "durationMs": 79 + }, + { + "name": "screenshot_viable", + "status": "pass", + "message": "viable", + "durationMs": 138 + } + ] + }, + "capabilities": [ + { + "name": "snapshot", + "status": "available", + "reason": "built-in capability", + "detail": "available without external renderer dependencies" + }, + { + "name": "wait", + "status": "available", + "reason": "built-in capability", + "detail": "available without external renderer dependencies" + }, + { + "name": "screenshot", + "status": "available", + "reason": "renderer smoke checks passed", + "detail": "playwright_available: available; browser_launch: chromium launches; ghostty_web_available: WASM available; screenshot_viable: viable" + }, + { + "name": "record-export-asciicast", + "status": "available", + "reason": "built-in capability", + "detail": "available without external renderer dependencies" + }, + { + "name": "record-export-webm", + "status": "available", + "reason": "browser-backed export dependencies available", + "detail": "playwright_available: available; browser_launch: chromium launches; ghostty_web_available: WASM available" + } + ] + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-inspect-final.json b/dogfood/20260413-skills-runtime-refactor/tui-inspect-final.json new file mode 100644 index 0000000..56264a8 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-inspect-final.json @@ -0,0 +1,46 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-04-13T10:11:02.730Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KP34JQENXVMGTMHQW8GSTHJH", + "createdAt": "2026-04-13T10:00:48.344Z", + "updatedAt": "2026-04-13T10:11:00.407Z", + "status": "exited", + "command": ["npx", "tsx", "test/fixtures/apps/hello-prompt/main.ts"], + "cwd": "/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06", + "shell": "/bin/bash", + "term": "xterm-256color", + "cols": 80, + "rows": 24, + "creationCols": 80, + "creationRows": 24, + "hostPid": 446058, + "childPid": 446071, + "exitCode": 0, + "exitSignal": null + }, + "eventCount": 15, + "uptime": 612063, + "lastEventSeq": 14, + "terminationCategory": "clean-exit", + "artifacts": { + "total": 2, + "byKind": { + "snapshot": 1, + "screenshot": 1 + }, + "missingCount": 0, + "health": "healthy" + }, + "usedOfflineReplay": false, + "rendererRuntime": { + "backend": "ghostty-web", + "mode": "offline-replay", + "status": "fallback", + "reason": "session-not-running" + } + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-record-export-cast.json b/dogfood/20260413-skills-runtime-refactor/tui-record-export-cast.json new file mode 100644 index 0000000..6c02631 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-record-export-cast.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-13T10:11:03.780Z", + "result": { + "sessionId": "01KP34JQENXVMGTMHQW8GSTHJH", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.cast", + "bytes": 436, + "sha256": "dceb4cf6e38ff57bf2c9ad896cf2c025e31398bc18579ad43be4a0246c2377dd", + "capturedAtSeq": 14, + "durationMs": 610962, + "metadata": { + "width": 80, + "height": 24, + "title": "01KP34JQENXVMGTMHQW8GSTHJH", + "timestamp": 1776074449, + "outputEventCount": 10, + "resizeEventCount": 0, + "markerCount": 0 + } + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-record-export-webm.json b/dogfood/20260413-skills-runtime-refactor/tui-record-export-webm.json new file mode 100644 index 0000000..f6d90fe --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-record-export-webm.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-13T10:11:07.328Z", + "result": { + "sessionId": "01KP34JQENXVMGTMHQW8GSTHJH", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent_exec_db10489d06/dogfood/20260413-skills-runtime-refactor/recordings/hello-prompt.webm", + "bytes": 18705, + "sha256": "29d3f9f97ef3e399eadf5eeb168852672f0de7b01f5a592116829a7a36b19734", + "capturedAtSeq": 14, + "durationMs": 610962, + "metadata": { + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "accelerated", + "outputEventCount": 10, + "resizeEventCount": 0 + } + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-screenshot-echo.json b/dogfood/20260413-skills-runtime-refactor/tui-screenshot-echo.json new file mode 100644 index 0000000..602cfd0 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-screenshot-echo.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-04-13T10:10:58.056Z", + "result": { + "sessionId": "01KP34JQENXVMGTMHQW8GSTHJH", + "capturedAtSeq": 5, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/tmp.FCAJKW974f/sessions/01KP34JQENXVMGTMHQW8GSTHJH/artifacts/screenshot-5-reference-dark.png", + "pngSizeBytes": 4882, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "sha256": "0d8b4c7c373acbd2951088ba5fd2b4e55cf1376ca1ba6ea9a704d8bf03aeac18", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-send-enter-exit.json b/dogfood/20260413-skills-runtime-refactor/tui-send-enter-exit.json new file mode 100644 index 0000000..5f1f802 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-send-enter-exit.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-04-13T10:11:00.376Z", + "result": { + "accepted": ["Enter"], + "bytesWritten": 1, + "seq": 8 + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-send-enter.json b/dogfood/20260413-skills-runtime-refactor/tui-send-enter.json new file mode 100644 index 0000000..0b4656e --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-send-enter.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-04-13T10:10:52.363Z", + "result": { + "accepted": ["Enter"], + "bytesWritten": 1, + "seq": 3 + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-snapshot-text.json b/dogfood/20260413-skills-runtime-refactor/tui-snapshot-text.json new file mode 100644 index 0000000..4009db7 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-snapshot-text.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-04-13T10:10:56.842Z", + "result": { + "format": "text", + "sessionId": "01KP34JQENXVMGTMHQW8GSTHJH", + "capturedAtSeq": 5, + "cols": 80, + "rows": 24, + "cursorRow": 2, + "cursorCol": 7, + "text": "READY> Agent\nECHO: Agent\nREADY>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-type-agent.json b/dogfood/20260413-skills-runtime-refactor/tui-type-agent.json new file mode 100644 index 0000000..38cf625 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-type-agent.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-04-13T10:10:51.273Z", + "result": {} +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-type-exit.json b/dogfood/20260413-skills-runtime-refactor/tui-type-exit.json new file mode 100644 index 0000000..d8ae769 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-type-exit.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-04-13T10:10:59.181Z", + "result": {} +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-wait-echo.json b/dogfood/20260413-skills-runtime-refactor/tui-wait-echo.json new file mode 100644 index 0000000..9d56467 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-wait-echo.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-13T10:10:53.803Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "ECHO: Agent", + "cursorRow": 2, + "cursorCol": 7, + "capturedAtSeq": 5 + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-wait-exit.json b/dogfood/20260413-skills-runtime-refactor/tui-wait-exit.json new file mode 100644 index 0000000..fb80bf7 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-wait-exit.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-13T10:11:01.561Z", + "result": { + "timedOut": false, + "exitCode": 0 + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-wait-ready.json b/dogfood/20260413-skills-runtime-refactor/tui-wait-ready.json new file mode 100644 index 0000000..5184353 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-wait-ready.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-13T10:10:50.100Z", + "result": { + "matched": false, + "timedOut": true, + "capturedAtSeq": 0 + } +} diff --git a/dogfood/20260413-skills-runtime-refactor/tui-wait-stable.json b/dogfood/20260413-skills-runtime-refactor/tui-wait-stable.json new file mode 100644 index 0000000..2a54481 --- /dev/null +++ b/dogfood/20260413-skills-runtime-refactor/tui-wait-stable.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-04-13T10:10:55.736Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 2, + "cursorCol": 7, + "capturedAtSeq": 5 + } +} diff --git a/package.json b/package.json index ae0d7e0..bbcbe2c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "files": [ "dist", "skills", + "skill-data", "!skills/_artifacts" ], "main": "./dist/index.js", diff --git a/skill-data/agent-tty/SKILL.md b/skill-data/agent-tty/SKILL.md new file mode 100644 index 0000000..cb1cee0 --- /dev/null +++ b/skill-data/agent-tty/SKILL.md @@ -0,0 +1,102 @@ +--- +name: agent-tty +description: Terminal and TUI automation CLI for AI agents. Use when the user needs to create a terminal session, run a command in a terminal, automate an interactive CLI or TUI, wait for terminal output, capture a TUI screenshot, export a terminal recording, or test a CLI workflow with reviewable artifacts. +advertise: true +--- + +# Terminal Automation with agent-tty + +If `agent-tty` is not already available in the environment, fetch the current `README.md` from `coder/agent-tty` on GitHub and follow its installation instructions before continuing. Otherwise use `agent-tty` directly. +Examples use `jq` for JSON parsing; any JSON-processing tool works. +Prefer isolated homes, JSON envelopes, and renderer-backed artifacts so terminal workflows stay reviewable and reproducible. + +## Core Workflow + +Every terminal or TUI automation task should follow this pattern: + +1. **Create an isolated home** with `--home`. +2. **Check prerequisites** with `doctor --json` before screenshot or recording work. +3. **Create a session** with `create --json`. +4. **Run setup commands** with `run` instead of simulating long shell typing. +5. **Wait on observable terminal state** with `wait` instead of blind sleeps. +6. **Inspect the current screen** with `snapshot`. +7. **Capture proof artifacts** with `screenshot` or `record export`. +8. **Destroy the session** when finished. + +```bash +AGENT_HOME="$(mktemp -d)" +agent-tty --home "$AGENT_HOME" doctor --json +SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') +agent-tty --home "$AGENT_HOME" run "$SESSION_ID" 'printf "ready\n"' +agent-tty --home "$AGENT_HOME" wait "$SESSION_ID" --text 'ready' --json +agent-tty --home "$AGENT_HOME" snapshot "$SESSION_ID" --format text --json +agent-tty --home "$AGENT_HOME" screenshot "$SESSION_ID" --json +agent-tty --home "$AGENT_HOME" record export "$SESSION_ID" --format webm --json +agent-tty --home "$AGENT_HOME" destroy "$SESSION_ID" --json +``` + +## Essential Commands + +```bash +# Environment and lifecycle +agent-tty --home doctor --json +agent-tty --home create --json -- /bin/bash +agent-tty --home inspect --json +agent-tty --home destroy --json + +# In-session control +agent-tty --home run 'command here' --json +agent-tty --home type 'literal text' --json +agent-tty --home paste 'multiline payload' --json +agent-tty --home send-keys Enter Ctrl+C --json + +# Observation and proof +agent-tty --home wait --text 'ready' --json +agent-tty --home wait --screen-stable-ms 1000 --json +agent-tty --home snapshot --format text --json +agent-tty --home screenshot --json +agent-tty --home record export --format webm --json +``` + +## Common Patterns + +### Bootstrap a shell session + +```bash +AGENT_HOME="$(mktemp -d)" +SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') +agent-tty --home "$AGENT_HOME" run "$SESSION_ID" 'pwd && ls -la' --json +agent-tty --home "$AGENT_HOME" snapshot "$SESSION_ID" --format text --json +``` + +### Drive an interactive CLI or TUI + +```bash +AGENT_HOME="$(mktemp -d)" +SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') +agent-tty --home "$AGENT_HOME" run "$SESSION_ID" '' --no-wait --json +agent-tty --home "$AGENT_HOME" wait "$SESSION_ID" --screen-stable-ms 1000 --json +agent-tty --home "$AGENT_HOME" send-keys "$SESSION_ID" Down Down Enter --json +agent-tty --home "$AGENT_HOME" screenshot "$SESSION_ID" --json +``` + +### Export reviewer-facing artifacts + +```bash +AGENT_HOME="$(mktemp -d)" +SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') +agent-tty --home "$AGENT_HOME" run "$SESSION_ID" 'printf "artifact proof\n"' --json +agent-tty --home "$AGENT_HOME" wait "$SESSION_ID" --text 'artifact proof' --json +agent-tty --home "$AGENT_HOME" screenshot "$SESSION_ID" --json +agent-tty --home "$AGENT_HOME" record export "$SESSION_ID" --format asciicast --json +agent-tty --home "$AGENT_HOME" record export "$SESSION_ID" --format webm --json +``` + +## Anti-Patterns + +- **Do not reach for `tmux`, `screen`, or ad hoc PTY wrappers first** when `agent-tty` can provide an isolated, inspectable session. +- **Do not rely on blind `sleep` calls** when `wait --text`, `wait --idle-ms`, or `wait --screen-stable-ms` can observe terminal readiness directly. +- **Do not bypass `--json`** when another tool or agent needs machine-readable results. +- **Do not use external screenshot tools as the primary proof path** when `agent-tty screenshot` and `agent-tty record export` can produce renderer-backed artifacts tied to the session timeline. +- **Do not leave sessions running after the task ends**; destroy them explicitly. +- **Do not rewrite public examples into repo-local development invocations**; the public workflow should stay `agent-tty ...`. diff --git a/skill-data/dogfood-tui/SKILL.md b/skill-data/dogfood-tui/SKILL.md new file mode 100644 index 0000000..0de8e92 --- /dev/null +++ b/skill-data/dogfood-tui/SKILL.md @@ -0,0 +1,90 @@ +--- +name: dogfood-tui +description: Structured TUI dogfooding and QA workflow using agent-tty. Use for exploratory testing, bug hunting, release-readiness validation, and UX review of terminal applications. +advertise: true +--- + +# Dogfooding TUIs with agent-tty + +Use this skill when the user wants structured exploratory testing, bug hunting, release-readiness validation, or UX review for a terminal application or TUI. + +## Prerequisites + +This workflow assumes the `agent-tty` core skill is already loaded. +If it is not, load it first with `agent-tty skills get agent-tty`. +Use this skill as the specialized QA layer on top of the core terminal automation workflow. + +## Dogfooding Workflow + +1. **Create an isolated home** so artifacts and session state stay reviewable and do not pollute the real user environment. +2. **Check renderer and browser prerequisites** with `doctor --json` before any screenshot or recording work. +3. **Create the session** with a known shell or launcher command and capture the returned session ID. +4. **Launch the target app intentionally**: + - Use `run` for fast setup commands or scripted launches. + - Use `type` for literal keystroke-by-keystroke text entry that should appear in the session. + - Use `send-keys` for control input such as arrows, Enter, Escape, Ctrl+C, or function-key navigation. +5. **Wait on observable state** with `wait` instead of blind sleeps: + - `--text` when a label, prompt, or status message should appear. + - `--screen-stable-ms` when the UI is animating or repainting. + - `--idle-ms` when command completion matters more than screen text. +6. **Capture the current screen state** with `snapshot --format text --json` for searchable text evidence. +7. **Capture visual proof** with `screenshot --json` whenever layout, color, cursor, or rendering quality matters. +8. **Export motion proof** with `record export --format webm --json` when the issue involves navigation, animation, resize behavior, focus handling, or transient corruption. +9. **Repeat the loop** for every meaningful scenario: startup, first-run prompts, resize, help flows, error handling, and teardown. +10. **Destroy the session** when the investigation is complete. + +## Recommended Session Skeleton + +```bash +DOGFOOD_HOME="$(mktemp -d)" +agent-tty --home "$DOGFOOD_HOME" doctor --json +SESSION_ID=$(agent-tty --home "$DOGFOOD_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') +agent-tty --home "$DOGFOOD_HOME" run "$SESSION_ID" '' --no-wait --json +agent-tty --home "$DOGFOOD_HOME" wait "$SESSION_ID" --screen-stable-ms 1000 --json +agent-tty --home "$DOGFOOD_HOME" snapshot "$SESSION_ID" --format text --json +agent-tty --home "$DOGFOOD_HOME" screenshot "$SESSION_ID" --json +agent-tty --home "$DOGFOOD_HOME" record export "$SESSION_ID" --format webm --json +agent-tty --home "$DOGFOOD_HOME" destroy "$SESSION_ID" --json +``` + +## Evidence Checklist + +Collect enough evidence that another reviewer can reproduce the result without guessing: + +- Exact repro commands, including the launch command and every subsequent `run`, `type`, or `send-keys` action. +- Terminal dimensions used for the repro, especially if layout or wrapping is part of the issue. +- At least one screenshot path for the failing or noteworthy state. +- A WebM export path when motion, navigation sequence, or transient rendering matters. +- Snapshot text for the most relevant terminal states so reviewers can search output quickly. +- Expected behavior versus actual behavior written in plain language. +- Whether the issue reproduces consistently, intermittently, or only after a specific setup sequence. +- Cleanup notes, especially if the app leaves background state, temp files, or a broken terminal mode behind. + +## Issue Taxonomy + +Use consistent labels and notes so findings can be triaged quickly: + +- **Rendering corruption** — garbled characters, color loss, double paint, cursor artifacts, or stale cells. +- **Resize/layout** — wrapping bugs, clipped panes, overlapping widgets, incorrect recompute after resize, or unusable small-screen behavior. +- **Focus/input** — lost keystrokes, wrong focused widget, modal traps, broken shortcuts, or incorrect key interpretation. +- **Scrollback** — missing history, jumpy scrolling, incorrect paging, or broken mouse wheel behavior. +- **Alt-screen** — failure to enter or exit cleanly, leaked UI frames, or shell prompt corruption after exit. +- **Copy/paste** — paste corruption, bracketed-paste issues, selection problems, or unsafe multiline submission behavior. +- **Performance/startup** — slow launch, delayed first paint, high-latency navigation, or visible stutter. +- **Crash/recovery/state loss** — panic, unexpected exit, broken resume path, lost form state, or inconsistent restoration after restart. + +## Report Template + +Use this structure when handing findings back to the user or a maintainer: + +- **Title:** concise issue summary +- **Environment:** OS, shell, app version/commit, terminal dimensions, and whether `doctor --json` passed +- **Reproduction steps:** numbered sequence with the exact `agent-tty` commands and key inputs used +- **Evidence bundle:** paths to snapshot text, screenshot PNG, WebM, and any additional logs +- **Expected behavior:** what should have happened +- **Actual behavior:** what happened instead +- **Impact:** severity, user-facing risk, and whether it blocks release-readiness +- **Workaround:** any known mitigation or safer path +- **Regression suspicion:** whether this looks new, long-standing, or tied to a recent change + +Prefer concise, repeatable repros and artifact-backed findings over narrative descriptions. The goal is a reviewable proof bundle, not just an anecdote. diff --git a/skills/agent-tty/SKILL.md b/skills/agent-tty/SKILL.md index cb1cee0..53e7b1e 100644 --- a/skills/agent-tty/SKILL.md +++ b/skills/agent-tty/SKILL.md @@ -4,99 +4,15 @@ description: Terminal and TUI automation CLI for AI agents. Use when the user ne advertise: true --- -# Terminal Automation with agent-tty +`agent-tty` is a terminal and TUI automation CLI that creates inspectable sessions and reviewable artifacts for agents. -If `agent-tty` is not already available in the environment, fetch the current `README.md` from `coder/agent-tty` on GitHub and follow its installation instructions before continuing. Otherwise use `agent-tty` directly. -Examples use `jq` for JSON parsing; any JSON-processing tool works. -Prefer isolated homes, JSON envelopes, and renderer-backed artifacts so terminal workflows stay reviewable and reproducible. +Load the full canonical core skill from the CLI before doing terminal automation: +`agent-tty skills get agent-tty` -## Core Workflow +Discover additional built-in skills with: +`agent-tty skills list` -Every terminal or TUI automation task should follow this pattern: +For structured QA and TUI dogfooding work, load: +`agent-tty skills get dogfood-tui` -1. **Create an isolated home** with `--home`. -2. **Check prerequisites** with `doctor --json` before screenshot or recording work. -3. **Create a session** with `create --json`. -4. **Run setup commands** with `run` instead of simulating long shell typing. -5. **Wait on observable terminal state** with `wait` instead of blind sleeps. -6. **Inspect the current screen** with `snapshot`. -7. **Capture proof artifacts** with `screenshot` or `record export`. -8. **Destroy the session** when finished. - -```bash -AGENT_HOME="$(mktemp -d)" -agent-tty --home "$AGENT_HOME" doctor --json -SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') -agent-tty --home "$AGENT_HOME" run "$SESSION_ID" 'printf "ready\n"' -agent-tty --home "$AGENT_HOME" wait "$SESSION_ID" --text 'ready' --json -agent-tty --home "$AGENT_HOME" snapshot "$SESSION_ID" --format text --json -agent-tty --home "$AGENT_HOME" screenshot "$SESSION_ID" --json -agent-tty --home "$AGENT_HOME" record export "$SESSION_ID" --format webm --json -agent-tty --home "$AGENT_HOME" destroy "$SESSION_ID" --json -``` - -## Essential Commands - -```bash -# Environment and lifecycle -agent-tty --home doctor --json -agent-tty --home create --json -- /bin/bash -agent-tty --home inspect --json -agent-tty --home destroy --json - -# In-session control -agent-tty --home run 'command here' --json -agent-tty --home type 'literal text' --json -agent-tty --home paste 'multiline payload' --json -agent-tty --home send-keys Enter Ctrl+C --json - -# Observation and proof -agent-tty --home wait --text 'ready' --json -agent-tty --home wait --screen-stable-ms 1000 --json -agent-tty --home snapshot --format text --json -agent-tty --home screenshot --json -agent-tty --home record export --format webm --json -``` - -## Common Patterns - -### Bootstrap a shell session - -```bash -AGENT_HOME="$(mktemp -d)" -SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') -agent-tty --home "$AGENT_HOME" run "$SESSION_ID" 'pwd && ls -la' --json -agent-tty --home "$AGENT_HOME" snapshot "$SESSION_ID" --format text --json -``` - -### Drive an interactive CLI or TUI - -```bash -AGENT_HOME="$(mktemp -d)" -SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') -agent-tty --home "$AGENT_HOME" run "$SESSION_ID" '' --no-wait --json -agent-tty --home "$AGENT_HOME" wait "$SESSION_ID" --screen-stable-ms 1000 --json -agent-tty --home "$AGENT_HOME" send-keys "$SESSION_ID" Down Down Enter --json -agent-tty --home "$AGENT_HOME" screenshot "$SESSION_ID" --json -``` - -### Export reviewer-facing artifacts - -```bash -AGENT_HOME="$(mktemp -d)" -SESSION_ID=$(agent-tty --home "$AGENT_HOME" create --json -- /bin/bash | jq -r '.result.sessionId') -agent-tty --home "$AGENT_HOME" run "$SESSION_ID" 'printf "artifact proof\n"' --json -agent-tty --home "$AGENT_HOME" wait "$SESSION_ID" --text 'artifact proof' --json -agent-tty --home "$AGENT_HOME" screenshot "$SESSION_ID" --json -agent-tty --home "$AGENT_HOME" record export "$SESSION_ID" --format asciicast --json -agent-tty --home "$AGENT_HOME" record export "$SESSION_ID" --format webm --json -``` - -## Anti-Patterns - -- **Do not reach for `tmux`, `screen`, or ad hoc PTY wrappers first** when `agent-tty` can provide an isolated, inspectable session. -- **Do not rely on blind `sleep` calls** when `wait --text`, `wait --idle-ms`, or `wait --screen-stable-ms` can observe terminal readiness directly. -- **Do not bypass `--json`** when another tool or agent needs machine-readable results. -- **Do not use external screenshot tools as the primary proof path** when `agent-tty screenshot` and `agent-tty record export` can produce renderer-backed artifacts tied to the session timeline. -- **Do not leave sessions running after the task ends**; destroy them explicitly. -- **Do not rewrite public examples into repo-local development invocations**; the public workflow should stay `agent-tty ...`. +This bootstrap intentionally stays minimal so the CLI remains the source of truth. diff --git a/src/cli/commands/skill.ts b/src/cli/commands/skill.ts deleted file mode 100644 index d1c6aba..0000000 --- a/src/cli/commands/skill.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import process from 'node:process'; -import { fileURLToPath } from 'node:url'; - -import { emitSuccess } from '../output.js'; - -import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; -import { assertString, invariant } from '../../util/assert.js'; - -const COMMAND_NAME = 'skill'; -const SKILL_NAME = 'agent-tty'; -const SKILL_SOURCE = 'packaged-file'; - -export interface SkillResult { - name: string; - source: typeof SKILL_SOURCE; - content: string; -} - -export interface SkillDependencies { - readFile: (path: URL, encoding: 'utf8') => Promise; - skillFileUrl: URL; -} - -const DEFAULT_SKILL_DEPENDENCIES: SkillDependencies = { - readFile: (path, encoding) => readFile(path, encoding), - skillFileUrl: new URL('../../../skills/agent-tty/SKILL.md', import.meta.url), -}; - -export async function loadPackagedSkillContent( - dependencies: Partial = {}, -): Promise { - const resolvedDependencies: SkillDependencies = { - ...DEFAULT_SKILL_DEPENDENCIES, - ...dependencies, - }; - const skillPath = fileURLToPath(resolvedDependencies.skillFileUrl); - let content: string; - - try { - content = await resolvedDependencies.readFile( - resolvedDependencies.skillFileUrl, - 'utf8', - ); - } catch (error: unknown) { - throw makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { - message: `Failed to read packaged skill at ${skillPath}.`, - details: { - skillPath, - }, - cause: error, - }); - } - - assertString(content, 'packaged skill content must be a string'); - invariant(content.length > 0, 'packaged skill content must not be empty'); - return content; -} - -export async function buildSkillResult( - dependencies: Partial = {}, -): Promise { - const content = await loadPackagedSkillContent(dependencies); - - return { - name: SKILL_NAME, - source: SKILL_SOURCE, - content, - }; -} - -export async function runSkillCommand(options: { - json: boolean; -}): Promise { - const result = await buildSkillResult(); - - if (!options.json) { - process.stdout.write(result.content); - return; - } - - emitSuccess({ - command: COMMAND_NAME, - json: options.json, - result, - lines: [result.content], - }); -} diff --git a/src/cli/commands/skills/get.ts b/src/cli/commands/skills/get.ts new file mode 100644 index 0000000..9b609bf --- /dev/null +++ b/src/cli/commands/skills/get.ts @@ -0,0 +1,34 @@ +import process from 'node:process'; + +import { emitSuccess } from '../../output.js'; + +import { getBundledSkill } from '../../../skills/index.js'; +import type { SkillGetResult } from '../../../skills/index.js'; + +const COMMAND_NAME = 'skills get'; + +export function runSkillsGetCommand( + name: string, + options: { json: boolean }, +): Promise { + const skill = getBundledSkill(name); + const result: SkillGetResult = { + name: skill.frontmatter.name, + source: skill.source, + path: skill.path, + content: skill.content, + }; + + if (!options.json) { + process.stdout.write(result.content); + return Promise.resolve(); + } + + emitSuccess({ + command: COMMAND_NAME, + json: options.json, + result, + lines: [result.content], + }); + return Promise.resolve(); +} diff --git a/src/cli/commands/skills/list.ts b/src/cli/commands/skills/list.ts new file mode 100644 index 0000000..8aa8394 --- /dev/null +++ b/src/cli/commands/skills/list.ts @@ -0,0 +1,22 @@ +import { emitSuccess } from '../../output.js'; + +import { listBundledSkills } from '../../../skills/index.js'; +import type { SkillListResult } from '../../../skills/index.js'; + +const COMMAND_NAME = 'skills list'; + +export function runSkillsListCommand(options: { + json: boolean; +}): Promise { + const skills = listBundledSkills(); + const lines = skills.map((skill) => `${skill.name} ${skill.description}`); + const result: SkillListResult = { skills }; + + emitSuccess({ + command: COMMAND_NAME, + json: options.json, + result, + lines, + }); + return Promise.resolve(); +} diff --git a/src/cli/commands/skills/path.ts b/src/cli/commands/skills/path.ts new file mode 100644 index 0000000..418556a --- /dev/null +++ b/src/cli/commands/skills/path.ts @@ -0,0 +1,34 @@ +import { dirname } from 'node:path'; +import process from 'node:process'; + +import { emitSuccess } from '../../output.js'; + +import { getBundledSkill } from '../../../skills/index.js'; +import type { SkillPathResult } from '../../../skills/index.js'; + +const COMMAND_NAME = 'skills path'; + +export function runSkillsPathCommand( + name: string, + options: { json: boolean }, +): Promise { + const skill = getBundledSkill(name); + const result: SkillPathResult = { + name: skill.frontmatter.name, + source: skill.source, + path: dirname(skill.path), + }; + + if (!options.json) { + process.stdout.write(`${result.path}\n`); + return Promise.resolve(); + } + + emitSuccess({ + command: COMMAND_NAME, + json: options.json, + result, + lines: [result.path], + }); + return Promise.resolve(); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 1dedcd4..479b1b3 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -20,7 +20,9 @@ import { runResizeCommand } from './commands/resize.js'; import { runScreenshotCommand } from './commands/screenshot.js'; import { runSendKeysCommand } from './commands/send-keys.js'; import { runSignalCommand } from './commands/signal.js'; -import { runSkillCommand } from './commands/skill.js'; +import { runSkillsGetCommand } from './commands/skills/get.js'; +import { runSkillsListCommand } from './commands/skills/list.js'; +import { runSkillsPathCommand } from './commands/skills/path.js'; import { runSnapshotCommand } from './commands/snapshot.js'; import { runTypeCommand } from './commands/type.js'; import { runVersionCommand } from './commands/version.js'; @@ -108,11 +110,9 @@ function wrapAction( } const CODING_AGENT_HELP_INTRO = - 'MANDATORY FOR CODING AGENTS: read the `agent-tty` skill first. If your agent already loaded that skill, follow it; otherwise run `agent-tty skill` before any other agent-tty command.'; + 'MANDATORY FOR CODING AGENTS: read the `agent-tty` skill first. If your agent already loaded that skill, follow it; otherwise run `agent-tty skills get agent-tty` before any other agent-tty command.'; const CODING_AGENT_HELP_OUTRO = - 'Coding agents: use the preloaded `agent-tty` skill when available; otherwise call `agent-tty skill` before using session commands.'; -const SKILL_COMMAND_DESCRIPTION = - 'Fallback first step for coding agents: print the packaged skill if it is not already loaded'; + 'Coding agents: use the preloaded `agent-tty` skill when available; otherwise call `agent-tty skills get agent-tty` before using session commands.'; async function main(): Promise { const program = new Command() @@ -156,16 +156,56 @@ async function main(): Promise { program.addHelpText('beforeAll', `${CODING_AGENT_HELP_INTRO}\n\n`); program.addHelpText('afterAll', `\n${CODING_AGENT_HELP_OUTRO}\n`); - program - .command('skill') - .description(SKILL_COMMAND_DESCRIPTION) + const skillsCommand = program + .command('skills') + .description('Manage built-in skills'); + + skillsCommand + .command('list') + .description('List all bundled skills') .option('--json', 'Emit a JSON command envelope', false) .action( wrapAction( - 'skill', + 'skills list', async (options: { json: boolean }, context: CommandContext) => { void context; - await runSkillCommand(options); + await runSkillsListCommand(options); + }, + ), + ); + + skillsCommand + .command('get ') + .description('Print a bundled skill by name') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'skills get', + async ( + name: string, + options: { json: boolean }, + context: CommandContext, + ) => { + void context; + await runSkillsGetCommand(name, options); + }, + ), + ); + + skillsCommand + .command('path ') + .description('Print the directory path of a bundled skill') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'skills path', + async ( + name: string, + options: { json: boolean }, + context: CommandContext, + ) => { + void context; + await runSkillsPathCommand(name, options); }, ), ); diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts index 3d43950..48797f3 100644 --- a/src/protocol/errors.ts +++ b/src/protocol/errors.ts @@ -21,6 +21,7 @@ export const ERROR_CODES = { PROTOCOL_ERROR: 'PROTOCOL_ERROR', EXPORT_ERROR: 'EXPORT_ERROR', REPLAY_ERROR: 'REPLAY_ERROR', + SKILL_NOT_FOUND: 'SKILL_NOT_FOUND', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; @@ -45,6 +46,7 @@ export const DEFAULT_ERROR_MESSAGES: Record = { [ERROR_CODES.PROTOCOL_ERROR]: 'Unexpected response from host.', [ERROR_CODES.EXPORT_ERROR]: 'Export failed.', [ERROR_CODES.REPLAY_ERROR]: 'Replay failed.', + [ERROR_CODES.SKILL_NOT_FOUND]: 'Skill not found.', [ERROR_CODES.INTERNAL_ERROR]: 'Internal error.', }; diff --git a/src/skills/frontmatter.ts b/src/skills/frontmatter.ts new file mode 100644 index 0000000..9bdbd56 --- /dev/null +++ b/src/skills/frontmatter.ts @@ -0,0 +1,112 @@ +import type { ZodError } from 'zod'; + +import { assertString, invariant } from '../util/assert.js'; + +import { + ParsedSkillDocumentSchema, + SkillFrontmatterSchema, + type ParsedSkillDocument, +} from './types.js'; + +const FRONTMATTER_OPENING_DELIMITER = '---'; +const FRONTMATTER_BLOCK_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u; + +type RawFrontmatterValue = boolean | string; + +function formatFrontmatterError(error: ZodError): string { + return error.issues + .map((issue) => { + if (issue.code === 'unrecognized_keys') { + return `unrecognized frontmatter keys: ${issue.keys.join(', ')}`; + } + + const path = issue.path.length > 0 ? issue.path.join('.') : 'frontmatter'; + return `${path}: ${issue.message}`; + }) + .join('; '); +} + +function parseFrontmatterLine( + line: string, + lineNumber: number, +): [key: string, value: RawFrontmatterValue] { + const separatorIndex = line.indexOf(':'); + + invariant( + separatorIndex > 0, + `Invalid skill frontmatter line ${String(lineNumber)}: expected "key: value".`, + ); + + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 1).trim(); + + invariant( + key.length > 0, + `Skill frontmatter line ${String(lineNumber)} must include a key.`, + ); + + if (key === 'advertise') { + invariant( + rawValue === 'true' || rawValue === 'false', + `Skill frontmatter "advertise" must be true or false on line ${String(lineNumber)}.`, + ); + return [key, rawValue === 'true']; + } + + return [key, rawValue]; +} + +export function parseSkillFrontmatter(markdown: string): ParsedSkillDocument { + assertString(markdown, 'skill markdown content must be a string'); + invariant(markdown.length > 0, 'skill markdown content must not be empty'); + invariant( + markdown.startsWith(`${FRONTMATTER_OPENING_DELIMITER}\n`) || + markdown.startsWith(`${FRONTMATTER_OPENING_DELIMITER}\r\n`), + 'skill markdown must start with YAML frontmatter delimited by "---".', + ); + + const match = FRONTMATTER_BLOCK_PATTERN.exec(markdown); + + invariant( + match !== null, + 'skill markdown frontmatter must end with a closing "---" line.', + ); + + const rawFrontmatter = match[1]; + invariant( + rawFrontmatter !== undefined, + 'skill markdown frontmatter block must be present when delimiters match.', + ); + + const frontmatterValues: Record = {}; + + for (const [index, line] of rawFrontmatter.split(/\r?\n/u).entries()) { + const trimmedLine = line.trim(); + const lineNumber = index + 2; + + if (trimmedLine.length === 0) { + continue; + } + + const [key, value] = parseFrontmatterLine(trimmedLine, lineNumber); + + invariant( + !Object.hasOwn(frontmatterValues, key), + `Duplicate skill frontmatter key "${key}" on line ${String(lineNumber)}.`, + ); + frontmatterValues[key] = value; + } + + const parsedFrontmatter = SkillFrontmatterSchema.safeParse(frontmatterValues); + + if (!parsedFrontmatter.success) { + throw new Error( + `Invalid skill frontmatter: ${formatFrontmatterError(parsedFrontmatter.error)}`, + ); + } + + return ParsedSkillDocumentSchema.parse({ + frontmatter: parsedFrontmatter.data, + body: markdown.slice(match[0].length), + }); +} diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 0000000..151e8a3 --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,28 @@ +export { parseSkillFrontmatter } from './frontmatter.js'; +export { getSkillDataRoot, getSkillFilePath, getSkillPath } from './paths.js'; +export { + discoverBundledSkills, + getBundledSkill, + listBundledSkills, +} from './registry.js'; +export { + BundledSkillSchema, + ParsedSkillDocumentSchema, + SkillFrontmatterSchema, + SkillGetResultSchema, + SkillListResultSchema, + SkillPathResultSchema, + SkillSourceSchema, + SkillSummarySchema, +} from './types.js'; +export type { BundledSkillRegistryOptions } from './registry.js'; +export type { + BundledSkill, + ParsedSkillDocument, + SkillFrontmatter, + SkillGetResult, + SkillListResult, + SkillPathResult, + SkillSource, + SkillSummary, +} from './types.js'; diff --git a/src/skills/paths.ts b/src/skills/paths.ts new file mode 100644 index 0000000..c21c443 --- /dev/null +++ b/src/skills/paths.ts @@ -0,0 +1,73 @@ +import { dirname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { assertString, invariant } from '../util/assert.js'; + +const SKILL_DATA_DIRECTORY_NAME = 'skill-data'; +const SKILL_FILENAME = 'SKILL.md'; + +function assertNonEmptyString( + value: string, + label: string, +): asserts value is string { + assertString(value, `${label} must be a string`); + invariant(value.length > 0, `${label} must be a non-empty string`); +} + +function assertAbsolutePath(pathValue: string, label: string): void { + assertNonEmptyString(pathValue, label); + invariant(isAbsolute(pathValue), `${label} must be an absolute path`); +} + +function assertSkillName(name: string): void { + assertNonEmptyString(name, 'skill name'); + invariant(name !== '.', 'skill name must not be "."'); + invariant(name !== '..', 'skill name must not be ".."'); + invariant( + !name.includes('/') && !name.includes('\\'), + 'skill name must not contain path separators', + ); +} + +function resolveSkillDataRoot(skillDataRoot?: string): string { + if (skillDataRoot === undefined) { + const packageRoot = resolve( + fileURLToPath(new URL('../../', import.meta.url)), + ); + assertAbsolutePath(packageRoot, 'package root'); + return resolve(packageRoot, SKILL_DATA_DIRECTORY_NAME); + } + + assertAbsolutePath(skillDataRoot, 'skillDataRoot'); + return skillDataRoot; +} + +export function getSkillDataRoot(): string { + return resolveSkillDataRoot(); +} + +export function getSkillPath(name: string, skillDataRoot?: string): string { + assertSkillName(name); + + const resolvedSkillDataRoot = resolveSkillDataRoot(skillDataRoot); + const skillPath = resolve(resolvedSkillDataRoot, name); + + invariant( + dirname(skillPath) === resolvedSkillDataRoot, + 'skill directory must stay within the skill-data root', + ); + + return skillPath; +} + +export function getSkillFilePath(name: string, skillDataRoot?: string): string { + const skillPath = getSkillPath(name, skillDataRoot); + const skillFilePath = resolve(skillPath, SKILL_FILENAME); + + invariant( + dirname(skillFilePath) === skillPath, + `${SKILL_FILENAME} must stay within the skill directory`, + ); + + return skillFilePath; +} diff --git a/src/skills/registry.ts b/src/skills/registry.ts new file mode 100644 index 0000000..452a7b7 --- /dev/null +++ b/src/skills/registry.ts @@ -0,0 +1,130 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; + +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import { assertString, invariant } from '../util/assert.js'; + +import { parseSkillFrontmatter } from './frontmatter.js'; +import { getSkillDataRoot, getSkillFilePath } from './paths.js'; +import { + BundledSkillSchema, + SkillSummarySchema, + type BundledSkill, + type SkillSummary, +} from './types.js'; + +export interface BundledSkillRegistryOptions { + skillDataRoot?: string; +} + +function resolveRegistrySkillDataRoot( + options: BundledSkillRegistryOptions, +): string { + if (options.skillDataRoot === undefined) { + return getSkillDataRoot(); + } + + assertString(options.skillDataRoot, 'skillDataRoot must be a string'); + invariant( + options.skillDataRoot.length > 0, + 'skillDataRoot must be a non-empty string', + ); + invariant( + isAbsolute(options.skillDataRoot), + 'skillDataRoot must be an absolute path', + ); + return options.skillDataRoot; +} + +function loadBundledSkill( + skillDirectoryName: string, + skillDataRoot: string, +): BundledSkill { + const skillFilePath = getSkillFilePath(skillDirectoryName, skillDataRoot); + const content = readFileSync(skillFilePath, 'utf8'); + + assertString( + content, + `bundled skill "${skillDirectoryName}" content must be a string`, + ); + invariant( + content.length > 0, + `bundled skill "${skillDirectoryName}" content must not be empty`, + ); + + const parsedDocument = parseSkillFrontmatter(content); + + invariant( + parsedDocument.body.trim().length > 0, + `bundled skill "${parsedDocument.frontmatter.name}" body must not be empty`, + ); + + return BundledSkillSchema.parse({ + frontmatter: parsedDocument.frontmatter, + source: 'bundled', + path: skillFilePath, + content, + body: parsedDocument.body, + }); +} + +export function discoverBundledSkills( + options: BundledSkillRegistryOptions = {}, +): BundledSkill[] { + const skillDataRoot = resolveRegistrySkillDataRoot(options); + const discoveredSkills: BundledSkill[] = []; + const seenNames = new Set(); + const skillDirectoryNames = readdirSync(skillDataRoot, { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); + + for (const skillDirectoryName of skillDirectoryNames) { + const skill = loadBundledSkill(skillDirectoryName, skillDataRoot); + + invariant( + !seenNames.has(skill.frontmatter.name), + `Duplicate bundled skill name "${skill.frontmatter.name}".`, + ); + seenNames.add(skill.frontmatter.name); + discoveredSkills.push(skill); + } + + return discoveredSkills.sort((left, right) => + left.frontmatter.name.localeCompare(right.frontmatter.name), + ); +} + +export function listBundledSkills( + options: BundledSkillRegistryOptions = {}, +): SkillSummary[] { + return discoverBundledSkills(options).map((skill) => + SkillSummarySchema.parse({ + name: skill.frontmatter.name, + description: skill.frontmatter.description, + source: skill.source, + }), + ); +} + +export function getBundledSkill( + name: string, + options: BundledSkillRegistryOptions = {}, +): BundledSkill { + assertString(name, 'skill name must be a string'); + invariant(name.length > 0, 'skill name must be a non-empty string'); + + const skill = discoverBundledSkills(options).find( + (entry) => entry.frontmatter.name === name, + ); + + if (skill === undefined) { + throw makeCliError(ERROR_CODES.SKILL_NOT_FOUND, { + details: { name }, + }); + } + + return skill; +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..89234f3 --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +const NonEmptyStringSchema = z.string().min(1); + +export const SkillSourceSchema = z.literal('bundled'); +export type SkillSource = z.infer; + +export const SkillFrontmatterSchema = z + .object({ + name: NonEmptyStringSchema, + description: NonEmptyStringSchema, + advertise: z.boolean().optional().default(true), + }) + .strict(); +export type SkillFrontmatter = z.infer; + +export const ParsedSkillDocumentSchema = z + .object({ + frontmatter: SkillFrontmatterSchema, + body: z.string(), + }) + .strict(); +export type ParsedSkillDocument = z.infer; + +export const SkillSummarySchema = z + .object({ + name: NonEmptyStringSchema, + description: NonEmptyStringSchema, + source: SkillSourceSchema, + }) + .strict(); +export type SkillSummary = z.infer; + +export const SkillListResultSchema = z + .object({ + skills: z.array(SkillSummarySchema), + }) + .strict(); +export type SkillListResult = z.infer; + +export const SkillGetResultSchema = z + .object({ + name: NonEmptyStringSchema, + source: SkillSourceSchema, + path: NonEmptyStringSchema, + content: NonEmptyStringSchema, + }) + .strict(); +export type SkillGetResult = z.infer; + +export const SkillPathResultSchema = z + .object({ + name: NonEmptyStringSchema, + source: SkillSourceSchema, + path: NonEmptyStringSchema, + }) + .strict(); +export type SkillPathResult = z.infer; + +export const BundledSkillSchema = z + .object({ + frontmatter: SkillFrontmatterSchema, + source: SkillSourceSchema, + path: NonEmptyStringSchema, + content: NonEmptyStringSchema, + body: NonEmptyStringSchema, + }) + .strict(); +export type BundledSkill = z.infer; diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 9c1bc43..d1e49bd 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -13,6 +13,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { runCli, type SuccessEnvelope } from '../helpers.js'; import type { CommandErrorEnvelope } from '../../src/protocol/envelope.js'; +import { + SkillGetResultSchema, + SkillListResultSchema, + SkillPathResultSchema, +} from '../../src/skills/index.js'; interface ErrorEnvelope { ok: false; @@ -36,8 +41,8 @@ function parseErrorEnvelope(output: string): CommandErrorEnvelope { return JSON.parse(output) as CommandErrorEnvelope; } -function readPackagedSkill(): string { - return readFileSync('skills/agent-tty/SKILL.md', 'utf8'); +function readBundledSkill(name: string): string { + return readFileSync(join('skill-data', name, 'SKILL.md'), 'utf8'); } const SEMVER_WITH_OPTIONAL_PRERELEASE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; @@ -69,53 +74,116 @@ describe('CLI integration', () => { expect(parsed.result.rendererBackends).toEqual(['ghostty-web']); }); - it('prints the packaged skill verbatim', () => { - const result = runCli(['skill'], testEnv()); + it('lists bundled skills in human output', () => { + const result = runCli(['skills', 'list'], testEnv()); expect(result.status).toBe(0); expect(result.stderr).toBe(''); - expect(result.stdout).toBe(readPackagedSkill()); + expect(result.stdout).toContain('agent-tty'); + expect(result.stdout).toContain('dogfood-tui'); }); - it('prints a JSON envelope for skill', () => { - const result = runCli(['skill', '--json'], testEnv()); + it('prints a JSON envelope for skills list', () => { + const result = runCli(['skills', 'list', '--json'], testEnv()); expect(result.status).toBe(0); expect(result.stderr).toBe(''); - const parsed = JSON.parse(result.stdout) as SuccessEnvelope<{ - name: string; - source: string; - content: string; - }>; + const parsed = JSON.parse(result.stdout) as SuccessEnvelope; + + expect(parsed.ok).toBe(true); + expect(parsed.command).toBe('skills list'); + expect(SkillListResultSchema.safeParse(parsed.result).success).toBe(true); + expect(SkillListResultSchema.parse(parsed.result).skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'agent-tty' }), + expect.objectContaining({ name: 'dogfood-tui' }), + ]), + ); + }); + + it('prints the requested bundled skill verbatim', () => { + const result = runCli(['skills', 'get', 'agent-tty'], testEnv()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toBe(readBundledSkill('agent-tty')); + }); + + it('prints a JSON envelope for skills get', () => { + const result = runCli(['skills', 'get', 'agent-tty', '--json'], testEnv()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const parsed = JSON.parse(result.stdout) as SuccessEnvelope; + + expect(parsed.ok).toBe(true); + expect(parsed.command).toBe('skills get'); + expect(SkillGetResultSchema.safeParse(parsed.result).success).toBe(true); + expect(SkillGetResultSchema.parse(parsed.result).content).toBe( + readBundledSkill('agent-tty'), + ); + }); + + it('reports SKILL_NOT_FOUND for unknown skills', () => { + const result = runCli(['skills', 'get', 'nonexistent'], testEnv()); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('SKILL_NOT_FOUND: Skill not found.'); + }); + + it('prints the bundled skill directory path', () => { + const result = runCli(['skills', 'path', 'agent-tty'], testEnv()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('skill-data/agent-tty'); + }); + + it('prints a JSON envelope for skills path', () => { + const result = runCli(['skills', 'path', 'agent-tty', '--json'], testEnv()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const parsed = JSON.parse(result.stdout) as SuccessEnvelope; expect(parsed.ok).toBe(true); - expect(parsed.command).toBe('skill'); - expect(parsed.result).toEqual({ - name: 'agent-tty', - source: 'packaged-file', - content: readPackagedSkill(), - }); + expect(parsed.command).toBe('skills path'); + expect(SkillPathResultSchema.safeParse(parsed.result).success).toBe(true); + expect(SkillPathResultSchema.parse(parsed.result).path).toContain( + 'skill-data/agent-tty', + ); }); - it('makes the packaged skill guidance prominent in top-level help', () => { + it('makes the bundled skill guidance prominent in top-level help', () => { const result = runCli(['--help'], testEnv()); expect(result.status).toBe(0); expect(result.stderr).toBe(''); expect(result.stdout.startsWith('MANDATORY FOR CODING AGENTS:')).toBe(true); expect(result.stdout).toContain( - 'If your agent already loaded that skill, follow it; otherwise run `agent-tty skill` before any other agent-tty command.', - ); - expect(result.stdout).toContain('skill [options]'); - expect(result.stdout).toContain( - 'Fallback first step for coding agents: print the packaged skill if it is not already loaded', + 'If your agent already loaded that skill, follow it; otherwise run `agent-tty skills get agent-tty` before any other agent-tty command.', ); + expect(result.stdout).toMatch(/\n {2}skills\s+Manage built-in skills\n/u); + expect(result.stdout).not.toContain('skill [options]'); + expect(result.stdout).not.toContain('`agent-tty skill`'); expect(result.stdout).toContain( - 'Coding agents: use the preloaded `agent-tty` skill when available; otherwise call `agent-tty skill` before using session commands.', + 'Coding agents: use the preloaded `agent-tty` skill when available; otherwise call `agent-tty skills get agent-tty` before using session commands.', ); }); + it('rejects the removed singular skill command', () => { + const result = runCli(['skill'], testEnv()); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain("unknown command 'skill'"); + expect(result.stderr).toContain('(Did you mean skills?)'); + }); + it('accepts --append-newline for type', () => { const result = runCli( ['type', 'session-01', 'hello', '--append-newline', '--json'], diff --git a/test/unit/commands/golden-envelopes.test.ts b/test/unit/commands/golden-envelopes.test.ts index 03dae35..059b0c9 100644 --- a/test/unit/commands/golden-envelopes.test.ts +++ b/test/unit/commands/golden-envelopes.test.ts @@ -1,8 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; -import { buildSkillResult } from '../../../src/cli/commands/skill.js'; import { buildVersionResult } from '../../../src/cli/commands/version.js'; +import { + SkillGetResultSchema, + SkillListResultSchema, + SkillPathResultSchema, + getBundledSkill, + getSkillPath, + listBundledSkills, +} from '../../../src/skills/index.js'; import { createErrorEnvelope, createSuccessEnvelope, @@ -42,14 +49,6 @@ const VersionResultSchema = z }) .strict(); -const SkillResultSchema = z - .object({ - name: z.literal('agent-tty'), - source: z.literal('packaged-file'), - content: z.string().min(1), - }) - .strict(); - // CreateResultSchema is defined locally because create does not go through // the RPC layer — it constructs the result from the session manifest. // This schema acts as the golden contract lock for the create result shape. @@ -989,11 +988,36 @@ describe('JSON envelope contracts', () => { expect(InspectResultSchema.safeParse(result).success).toBe(true); }); - it('locks the skill success envelope shape', async () => { - const result = await buildSkillResult(); + it('locks the skills list success envelope shape', () => { + const result = { skills: listBundledSkills() }; + + expectLockedSuccessEnvelope('skills list', result); + expect(SkillListResultSchema.safeParse(result).success).toBe(true); + }); + + it('locks the skills get success envelope shape', () => { + const skill = getBundledSkill('agent-tty'); + const result = { + name: skill.frontmatter.name, + source: skill.source, + path: skill.path, + content: skill.content, + }; + + expectLockedSuccessEnvelope('skills get', result); + expect(SkillGetResultSchema.safeParse(result).success).toBe(true); + }); + + it('locks the skills path success envelope shape', () => { + const skill = getBundledSkill('agent-tty'); + const result = { + name: skill.frontmatter.name, + source: skill.source, + path: getSkillPath('agent-tty'), + }; - expectLockedSuccessEnvelope('skill', result); - expect(SkillResultSchema.safeParse(result).success).toBe(true); + expectLockedSuccessEnvelope('skills path', result); + expect(SkillPathResultSchema.safeParse(result).success).toBe(true); }); it('locks the version success envelope shape', async () => { diff --git a/test/unit/commands/skill.test.ts b/test/unit/commands/skill.test.ts deleted file mode 100644 index 2d30804..0000000 --- a/test/unit/commands/skill.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { describe, expect, it } from 'vitest'; - -import { CliError } from '../../../src/cli/errors.js'; -import { ERROR_CODES } from '../../../src/protocol/errors.js'; -import { - buildSkillResult, - loadPackagedSkillContent, -} from '../../../src/cli/commands/skill.js'; - -describe('skill command', () => { - it('loads the packaged skill content', async () => { - const expectedContent = await readFile('skills/agent-tty/SKILL.md', 'utf8'); - const content = await loadPackagedSkillContent(); - - expect(content).toBe(expectedContent); - expect(content.length).toBeGreaterThan(0); - }); - - it('builds the skill result payload', async () => { - const result = await buildSkillResult(); - - expect(result.name).toBe('agent-tty'); - expect(result.source).toBe('packaged-file'); - expect(result.content.length).toBeGreaterThan(0); - }); - - it('maps packaged skill read failures to STORAGE_READ_ERROR', async () => { - const readFailure = Object.assign(new Error('missing skill'), { - code: 'ENOENT', - }); - - try { - await loadPackagedSkillContent({ - readFile: () => Promise.reject(readFailure), - }); - throw new Error('expected loadPackagedSkillContent to reject'); - } catch (error: unknown) { - expect(error).toBeInstanceOf(CliError); - if (!(error instanceof CliError)) { - throw error; - } - - const skillPath = error.details.skillPath; - expect(error.code).toBe(ERROR_CODES.STORAGE_READ_ERROR); - expect(error.message).toContain('Failed to read packaged skill'); - expect(error.cause).toBe(readFailure); - expect(typeof skillPath).toBe('string'); - if (typeof skillPath !== 'string') { - throw new Error('skillPath detail must be a string', { - cause: error, - }); - } - expect(skillPath).toContain('skills/agent-tty/SKILL.md'); - } - }); -}); diff --git a/test/unit/commands/skills-get.test.ts b/test/unit/commands/skills-get.test.ts new file mode 100644 index 0000000..2926f2a --- /dev/null +++ b/test/unit/commands/skills-get.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runSkillsGetCommand } from '../../../src/cli/commands/skills/get.js'; +import { CliError } from '../../../src/cli/errors.js'; +import { ERROR_CODES } from '../../../src/protocol/errors.js'; +import { + SkillGetResultSchema, + getBundledSkill, +} from '../../../src/skills/index.js'; +import type { SuccessEnvelope } from '../../helpers.js'; + +function readBundledSkill(name: string): string { + return readFileSync(`skill-data/${name}/SKILL.md`, 'utf8'); +} + +function getWrittenStdout(calls: readonly unknown[][]): string { + expect(calls).toHaveLength(1); + const [output] = calls[0] ?? []; + expect(typeof output).toBe('string'); + if (typeof output !== 'string') { + throw new Error('expected stdout to be written as a string'); + } + return output; +} + +describe('skills get command', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('prints the raw SKILL.md content in human output', async () => { + const stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockReturnValue(true); + + await runSkillsGetCommand('agent-tty', { json: false }); + + const output = getWrittenStdout(stdoutWriteSpy.mock.calls as unknown[][]); + + expect(output).toBe(readBundledSkill('agent-tty')); + }); + + it('emits a JSON envelope matching SkillGetResultSchema', async () => { + const stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockReturnValue(true); + const skill = getBundledSkill('agent-tty'); + + await runSkillsGetCommand('agent-tty', { json: true }); + + const output = getWrittenStdout(stdoutWriteSpy.mock.calls as unknown[][]); + const parsed = JSON.parse(output) as SuccessEnvelope; + + expect(parsed.ok).toBe(true); + expect(parsed.command).toBe('skills get'); + expect(SkillGetResultSchema.safeParse(parsed.result).success).toBe(true); + expect(SkillGetResultSchema.parse(parsed.result)).toEqual({ + name: skill.frontmatter.name, + source: skill.source, + path: skill.path, + content: skill.content, + }); + }); + + it('throws SKILL_NOT_FOUND for unknown skills', () => { + try { + void runSkillsGetCommand('missing-skill', { json: false }); + throw new Error('expected runSkillsGetCommand to throw'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(CliError); + if (!(error instanceof CliError)) { + throw error; + } + + expect(error.code).toBe(ERROR_CODES.SKILL_NOT_FOUND); + expect(error.message).toBe('Skill not found.'); + expect(error.details).toEqual({ name: 'missing-skill' }); + } + }); +}); diff --git a/test/unit/commands/skills-list.test.ts b/test/unit/commands/skills-list.test.ts new file mode 100644 index 0000000..2f5e8bf --- /dev/null +++ b/test/unit/commands/skills-list.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runSkillsListCommand } from '../../../src/cli/commands/skills/list.js'; +import { + SkillListResultSchema, + SkillSummarySchema, + listBundledSkills, +} from '../../../src/skills/index.js'; +import type { SuccessEnvelope } from '../../helpers.js'; + +function getWrittenStdout(calls: readonly unknown[][]): string { + expect(calls).toHaveLength(1); + const [output] = calls[0] ?? []; + expect(typeof output).toBe('string'); + if (typeof output !== 'string') { + throw new Error('expected stdout to be written as a string'); + } + return output; +} + +describe('skills list command', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('prints one line per bundled skill in human output', async () => { + const stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockReturnValue(true); + const expectedSkills = listBundledSkills(); + + await runSkillsListCommand({ json: false }); + + const output = getWrittenStdout(stdoutWriteSpy.mock.calls as unknown[][]); + const expectedLines = expectedSkills.map((skill) => { + expect(SkillSummarySchema.safeParse(skill).success).toBe(true); + return `${skill.name} ${skill.description}`; + }); + + expect(output).toBe(`${expectedLines.join('\n')}\n`); + expect(output).toContain('agent-tty '); + expect(output).toContain('dogfood-tui '); + }); + + it('emits a JSON envelope matching SkillListResultSchema', async () => { + const stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockReturnValue(true); + + await runSkillsListCommand({ json: true }); + + const output = getWrittenStdout(stdoutWriteSpy.mock.calls as unknown[][]); + const parsed = JSON.parse(output) as SuccessEnvelope; + + expect(parsed.ok).toBe(true); + expect(parsed.command).toBe('skills list'); + expect(SkillListResultSchema.safeParse(parsed.result).success).toBe(true); + expect(SkillListResultSchema.parse(parsed.result).skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'agent-tty' }), + expect.objectContaining({ name: 'dogfood-tui' }), + ]), + ); + }); +}); diff --git a/test/unit/commands/skills-path.test.ts b/test/unit/commands/skills-path.test.ts new file mode 100644 index 0000000..c9f17e9 --- /dev/null +++ b/test/unit/commands/skills-path.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runSkillsPathCommand } from '../../../src/cli/commands/skills/path.js'; +import { CliError } from '../../../src/cli/errors.js'; +import { ERROR_CODES } from '../../../src/protocol/errors.js'; +import { + SkillPathResultSchema, + getBundledSkill, + getSkillPath, +} from '../../../src/skills/index.js'; +import type { SuccessEnvelope } from '../../helpers.js'; + +function getWrittenStdout(calls: readonly unknown[][]): string { + expect(calls).toHaveLength(1); + const [output] = calls[0] ?? []; + expect(typeof output).toBe('string'); + if (typeof output !== 'string') { + throw new Error('expected stdout to be written as a string'); + } + return output; +} + +describe('skills path command', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('prints the absolute skill directory path in human output', async () => { + const stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockReturnValue(true); + const expectedPath = getSkillPath('agent-tty'); + + await runSkillsPathCommand('agent-tty', { json: false }); + + const output = getWrittenStdout(stdoutWriteSpy.mock.calls as unknown[][]); + + expect(output).toBe(`${expectedPath}\n`); + expect(output).toContain('skill-data/agent-tty'); + }); + + it('emits a JSON envelope matching SkillPathResultSchema', async () => { + const stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockReturnValue(true); + const skill = getBundledSkill('agent-tty'); + const expectedPath = getSkillPath('agent-tty'); + + await runSkillsPathCommand('agent-tty', { json: true }); + + const output = getWrittenStdout(stdoutWriteSpy.mock.calls as unknown[][]); + const parsed = JSON.parse(output) as SuccessEnvelope; + + expect(parsed.ok).toBe(true); + expect(parsed.command).toBe('skills path'); + expect(SkillPathResultSchema.safeParse(parsed.result).success).toBe(true); + expect(SkillPathResultSchema.parse(parsed.result)).toEqual({ + name: skill.frontmatter.name, + source: skill.source, + path: expectedPath, + }); + }); + + it('throws SKILL_NOT_FOUND for unknown skills', () => { + try { + void runSkillsPathCommand('missing-skill', { json: false }); + throw new Error('expected runSkillsPathCommand to throw'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(CliError); + if (!(error instanceof CliError)) { + throw error; + } + + expect(error.code).toBe(ERROR_CODES.SKILL_NOT_FOUND); + expect(error.message).toBe('Skill not found.'); + expect(error.details).toEqual({ name: 'missing-skill' }); + } + }); +}); diff --git a/test/unit/skills/frontmatter.test.ts b/test/unit/skills/frontmatter.test.ts new file mode 100644 index 0000000..708544a --- /dev/null +++ b/test/unit/skills/frontmatter.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSkillFrontmatter } from '../../../src/skills/frontmatter.js'; + +describe('parseSkillFrontmatter', () => { + it('parses valid frontmatter and defaults advertise to true', () => { + const parsed = parseSkillFrontmatter(`--- +name: agent-tty +description: Terminal automation for agents +--- +# Agent TTY +`); + + expect(parsed).toEqual({ + frontmatter: { + name: 'agent-tty', + description: 'Terminal automation for agents', + advertise: true, + }, + body: '# Agent TTY\n', + }); + }); + + it('parses explicit advertise values', () => { + const parsed = parseSkillFrontmatter(`--- +name: dogfood-tui +description: TUI QA workflow +advertise: false +--- +Use this skill. +`); + + expect(parsed.frontmatter).toEqual({ + name: 'dogfood-tui', + description: 'TUI QA workflow', + advertise: false, + }); + expect(parsed.body).toBe('Use this skill.\n'); + }); + + it('rejects missing frontmatter', () => { + expect(() => parseSkillFrontmatter('# Missing frontmatter\n')).toThrow( + /must start with YAML frontmatter/u, + ); + }); + + it('rejects malformed frontmatter lines', () => { + expect(() => + parseSkillFrontmatter(`--- +name agent-tty +description: Missing separator +--- +# Body +`), + ).toThrow(/expected "key: value"/u); + }); + + it('rejects missing required fields', () => { + expect(() => + parseSkillFrontmatter(`--- +name: agent-tty +--- +# Body +`), + ).toThrow(/description/u); + }); + + it('rejects unknown fields in strict mode', () => { + expect(() => + parseSkillFrontmatter(`--- +name: agent-tty +description: Terminal automation +extra: true +--- +# Body +`), + ).toThrow(/extra/u); + }); +}); diff --git a/test/unit/skills/paths.test.ts b/test/unit/skills/paths.test.ts new file mode 100644 index 0000000..a4fd719 --- /dev/null +++ b/test/unit/skills/paths.test.ts @@ -0,0 +1,32 @@ +import { basename, dirname, isAbsolute, join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + getSkillDataRoot, + getSkillFilePath, + getSkillPath, +} from '../../../src/skills/paths.js'; + +describe('skill paths', () => { + it('builds absolute paths under the skill-data root', () => { + const skillDataRoot = getSkillDataRoot(); + const skillPath = getSkillPath('agent-tty'); + const skillFilePath = getSkillFilePath('agent-tty'); + + expect(isAbsolute(skillDataRoot)).toBe(true); + expect(basename(skillDataRoot)).toBe('skill-data'); + expect(skillPath).toBe(join(skillDataRoot, 'agent-tty')); + expect(skillFilePath).toBe(join(skillPath, 'SKILL.md')); + expect(dirname(skillPath)).toBe(skillDataRoot); + expect(dirname(skillFilePath)).toBe(skillPath); + }); + + it('rejects invalid skill names', () => { + expect(() => getSkillPath('')).toThrow( + /skill name must be a non-empty string/u, + ); + expect(() => getSkillPath('../escape')).toThrow(/path separators/u); + expect(() => getSkillPath('nested/name')).toThrow(/path separators/u); + }); +}); diff --git a/test/unit/skills/registry.test.ts b/test/unit/skills/registry.test.ts new file mode 100644 index 0000000..094e78c --- /dev/null +++ b/test/unit/skills/registry.test.ts @@ -0,0 +1,171 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { CliError } from '../../../src/cli/errors.js'; +import { ERROR_CODES } from '../../../src/protocol/errors.js'; +import { + discoverBundledSkills, + getBundledSkill, + listBundledSkills, +} from '../../../src/skills/registry.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function createSkillDataRoot(): Promise { + const skillDataRoot = await mkdtemp(join(tmpdir(), 'agent-tty-skills-')); + temporaryDirectories.push(skillDataRoot); + return skillDataRoot; +} + +async function writeSkill( + skillDataRoot: string, + directoryName: string, + content: string, +): Promise { + const skillDirectory = join(skillDataRoot, directoryName); + await mkdir(skillDirectory, { recursive: true }); + await writeFile(join(skillDirectory, 'SKILL.md'), content, 'utf8'); +} + +describe('bundled skill registry', () => { + it('discovers bundled skills and builds summaries', async () => { + const skillDataRoot = await createSkillDataRoot(); + await writeSkill( + skillDataRoot, + 'beta', + `--- +name: beta +description: Second skill +--- +# Beta +`, + ); + await writeSkill( + skillDataRoot, + 'alpha', + `--- +name: alpha +description: First skill +advertise: false +--- +# Alpha +`, + ); + + const discovered = discoverBundledSkills({ skillDataRoot }); + const summaries = listBundledSkills({ skillDataRoot }); + const alphaSkill = getBundledSkill('alpha', { skillDataRoot }); + + expect(discovered.map((skill) => skill.frontmatter.name)).toEqual([ + 'alpha', + 'beta', + ]); + expect(discovered.map((skill) => skill.source)).toEqual([ + 'bundled', + 'bundled', + ]); + expect(alphaSkill.frontmatter).toEqual({ + name: 'alpha', + description: 'First skill', + advertise: false, + }); + expect(alphaSkill.body).toBe('# Alpha\n'); + expect(alphaSkill.path).toBe(join(skillDataRoot, 'alpha', 'SKILL.md')); + expect(summaries).toEqual([ + { + name: 'alpha', + description: 'First skill', + source: 'bundled', + }, + { + name: 'beta', + description: 'Second skill', + source: 'bundled', + }, + ]); + }); + + it('rejects duplicate bundled skill names', async () => { + const skillDataRoot = await createSkillDataRoot(); + await writeSkill( + skillDataRoot, + 'alpha-one', + `--- +name: alpha +description: First alpha +--- +# Alpha one +`, + ); + await writeSkill( + skillDataRoot, + 'alpha-two', + `--- +name: alpha +description: Second alpha +--- +# Alpha two +`, + ); + + expect(() => discoverBundledSkills({ skillDataRoot })).toThrow( + /Duplicate bundled skill name "alpha"\./u, + ); + }); + + it('rejects bundled skills with empty bodies', async () => { + const skillDataRoot = await createSkillDataRoot(); + await writeSkill( + skillDataRoot, + 'empty-body', + `--- +name: empty-body +description: Missing content +--- +`, + ); + + expect(() => discoverBundledSkills({ skillDataRoot })).toThrow( + /body must not be empty/u, + ); + }); + + it('throws SKILL_NOT_FOUND for unknown skills', async () => { + const skillDataRoot = await createSkillDataRoot(); + await writeSkill( + skillDataRoot, + 'agent-tty', + `--- +name: agent-tty +description: Terminal automation +--- +# Agent TTY +`, + ); + + try { + getBundledSkill('missing-skill', { skillDataRoot }); + throw new Error('expected getBundledSkill to throw'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(CliError); + if (!(error instanceof CliError)) { + throw error; + } + + expect(error.code).toBe(ERROR_CODES.SKILL_NOT_FOUND); + expect(error.message).toBe('Skill not found.'); + expect(error.details).toEqual({ name: 'missing-skill' }); + } + }); +});