From f198eaed4d519317e9a9856371dbd971e42ae5c8 Mon Sep 17 00:00:00 2001 From: Pow Date: Wed, 22 Jul 2026 14:09:12 +0700 Subject: [PATCH 1/2] feat(agent)!: align skill install with the Agent Skills standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-agent bespoke install formats with the Agent Skills open standard: one canonical .agents/skills//SKILL.md per project, read directly by universal agents (Codex, Cursor, Cline, Gemini CLI, Copilot, …) and via a relative symlink by every other agent. The bundled registry grows from 8 hand-wired targets to the full standard set (72 agent ids). BREAKING CHANGE: agent install no longer writes per-agent bespoke formats. Codex no longer receives a managed section in AGENTS.md and Windsurf no longer receives a size-capped compact body — both now read the canonical .agents/skills/. The install result schema gains a `mode` (canonical | symlink) field and `path` reflects the new model. Skills written in the old format report stale/modified under `agent status` until refreshed (re-run `agent install`, add --force where needed). The legacy aliases (claude, kiro, copilot) and the claude-code default are preserved, so existing commands keep working. --- DOCUMENTATION.md | 31 +- README.md | 143 +- package-lock.json | 4 +- skills/testsprite-onboard.skill.md | 10 +- skills/testsprite-verify.codex.md | 127 - skills/testsprite-verify.skill.md | 5 + src/commands/agent.test.ts | 3118 ++++------------- src/commands/agent.ts | 1243 +++---- src/commands/doctor.ts | 2 - src/commands/init.test.ts | 108 +- src/commands/init.ts | 24 +- src/lib/agent-targets.test.ts | 1043 ++---- src/lib/agent-targets.ts | 691 ++-- src/lib/skill-nudge.test.ts | 63 +- src/lib/skill-nudge.ts | 107 +- test/__snapshots__/help.snapshot.test.ts.snap | 45 +- test/e2e/agent-install.e2e.test.ts | 754 ++-- test/e2e/setup.e2e.test.ts | 53 +- test/e2e/skill-nudge.e2e.test.ts | 6 +- 19 files changed, 2174 insertions(+), 5403 deletions(-) delete mode 100644 skills/testsprite-verify.codex.md diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index bf6b1b5..c822de2 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -106,30 +106,27 @@ Every artifact in the bundle shares one `snapshotId`; the CLI will not mix a fai ## Agent onboarding (`agent install`) -`testsprite agent install` writes a ready-made skill/instruction file into your project so your coding agent knows the commands, the exit codes, and the failure-bundle layout — no prompt engineering required. It's a pure-local command: no network, no credentials. +`testsprite agent install` writes the TestSprite skills into your project so your coding agent knows the commands, the exit codes, and the failure-bundle layout — no prompt engineering required. It's a pure-local command: no network, no credentials. ```bash -testsprite agent install claude # install the skill for Claude Code -testsprite agent install codex # install into AGENTS.md for Codex (managed-section) -testsprite agent install cursor # .cursor/rules/testsprite-verify.mdc -testsprite agent install cline # .clinerules/testsprite-verify.md -testsprite agent install windsurf # .windsurf/rules/testsprite-verify.md -testsprite agent install antigravity # .agents/skills/testsprite-verify/SKILL.md -testsprite agent install kiro # .kiro/skills/testsprite-verify/SKILL.md -testsprite agent install copilot # .github/instructions/testsprite-verify.instructions.md -testsprite agent list # list all 8 targets with status + mode + path -testsprite agent status # check installed skills against this CLI version +testsprite agent install claude-code # .agents/skills//SKILL.md + .claude/skills/ symlink +testsprite agent install codex # writes the canonical copy (Codex reads .agents/skills directly) +testsprite agent install cursor # universal — reads the canonical copy +testsprite agent install gemini-cli # universal +testsprite agent install github-copilot # universal +testsprite agent install kiro-cli # .kiro/skills/ symlink → canonical +testsprite agent install windsurf # .windsurf/skills/ symlink → canonical +testsprite agent list # every supported agent, its skills folder, and universal vs symlink +testsprite agent status # check installed skills against this CLI version ``` -Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `antigravity` (experimental), `kiro` (experimental), `windsurf` (experimental), `copilot` (experimental). +`--target` accepts any agent id from the standard registry (70+), plus the legacy short aliases (`claude`, `codex`, `cursor`, `cline`, `antigravity`, `kiro`, `windsurf`, `copilot`). Omitting `--target` in a non-interactive shell defaults to `claude-code`; in a terminal the CLI prompts. -Omitting `--target` in a non-interactive shell (CI, agent subprocess) defaults to `claude` with an `[info]` note on stderr; in a terminal the CLI prompts (empty answer = `claude`). +Because every universal agent reads the same `.agents/skills/` folder, **installing for one universal agent makes the skill available to all of them** — `agent install --target codex` also serves Cursor, Cline, Gemini CLI, Copilot, etc. -`agent status` checks every installed skill file against the current CLI version and reports one of `ok`, `stale`, `modified`, `unmarked`, `absent`, or `corrupt` per target. It exits `1` when anything needs attention, so `testsprite agent status && …` can gate a CI step; `--dir ` inspects a different project root. +`agent status` checks the canonical skill file (and each symlinked landing) against the current CLI version and reports `ok`, `stale`, `modified`, or `unmarked` for every agent that has an install (absent agents are omitted to keep output focused). It exits `1` when anything needs attention, so `testsprite agent status && …` can gate a CI step; `--dir ` inspects a different project root. -The `codex` target uses **managed-section mode** — it writes only a sentinel-delimited section inside your existing `AGENTS.md`, so your project instructions are never clobbered. Re-running without `--force` replaces the section in-place; user content outside the sentinels is always preserved. - -Re-running with `--force` on **own-file targets** (claude, cursor, cline, antigravity, kiro, windsurf, copilot) backs up the existing file to `.bak` first. +Re-running with `--force` overwrites a canonical file that has drifted, backing up the existing bytes to `.bak` first; for symlinked landings it replaces a link that points elsewhere. ## Command reference diff --git a/README.md b/README.md index 3bb465e..9c64750 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,13 @@ npm install -g @testsprite/testsprite-cli testsprite setup ``` -`testsprite setup` prompts for your [API key](https://www.testsprite.com), verifies it, and installs the verification-loop skill for your coding agent (`claude`, `cursor`, `cline`, `windsurf`, `antigravity`, `codex`, etc.) — one command, so your agent is wired to verify its own work. Non-interactive (CI / onboarding scripts): +`testsprite setup` prompts for your [API key](https://www.testsprite.com), verifies it, and installs the verification-loop skill for your coding agent (Claude Code, Codex, Cursor, Cline, Gemini CLI, GitHub Copilot, Windsurf, Kiro, Antigravity, and [60+ more](#supported-agents)) — one command, so your agent is wired to verify its own work. Non-interactive (CI / onboarding scripts): ```bash -TESTSPRITE_API_KEY=sk-... testsprite setup --from-env --yes --agent claude +TESTSPRITE_API_KEY=sk-... testsprite setup --from-env --yes --agent claude-code ``` -> **Pointing a coding agent (Claude Code, Cursor, Codex, Cline, …) at TestSprite?** Have it run `testsprite setup` first — that installs the verification skill, so the agent knows how to create, run, and triage tests on its own (instead of guessing from this README). New here? Start with the **[getting-started overview](https://docs.testsprite.com/cli/getting-started/overview)**. +> **Pointing a [coding agent](#supported-agents) (Claude Code, Cursor, Codex, Cline, …) at TestSprite?** Have it run `testsprite setup` first — that installs the verification skill, so the agent knows how to create, run, and triage tests on its own (instead of guessing from this README). New here? Start with the **[getting-started overview](https://docs.testsprite.com/cli/getting-started/overview)**. > **Privacy note:** interactive runs check the npm registry at most once per 24 h to offer a "new version available" notice — package name only, never your key or data; `TESTSPRITE_NO_UPDATE_NOTIFIER=1` disables it. The backend also advertises its minimum supported CLI version — a below-floor CLI prints a one-line upgrade advisory on stderr, and a too-old client may be rejected with exit 14 (`CLIENT_TOO_OLD`). Details in [DOCUMENTATION.md → Update notice](./DOCUMENTATION.md#update-notice). @@ -91,35 +91,35 @@ Prefer to configure each step by hand (or learn the surface offline with `--dry- ## Commands -| Group | Command | What it does | -| --------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Setup** | `setup` | **Start here** — one command: configure your API key, verify it, and install the agent verification skill | -| | `doctor` | Environment diagnostic — CLI/Node versions, profile, endpoint, credentials, connectivity, agent skill; exits non-zero on failure | -| **Auth** | `auth status` | Resolve the active profile to its user, key, env, and scopes | -| | `auth remove` | Remove the active profile from the credentials file | -| | `usage` (alias `credits`) | Account pre-flight: identity, plus credit balance / plan info when the backend supplies them | -| **Read** | `project list` / `project get` | List projects / fetch one by id | -| | `test list` / `test get` | List tests under a project / fetch one by id | -| | `test code get` | Print (or write) the generated test source | -| | `test steps` | List the latest run's steps with screenshot / DOM pointers | -| | `test result` | Latest result; `--history` lists a test's prior runs | -| | `test failure get` | The agent entry point: one self-contained latest-failure bundle | -| | `test failure summary` | One-screen triage card (no media download) | -| | `test diff` | Compare two runs — verdict, failure kind, per-step status flips, code-version drift | -| **Write** | `test scaffold` / `test lint` | Author plans locally: emit a schema-correct starter, validate plan files offline — no network, no credentials | -| | `test create` / `test create-batch` | Create a test (or bulk-create from a plan file); `--produces` / `--needs` / `--category` wire BE dependency metadata | -| | `test update` / `test delete` / `test delete-batch` | Edit metadata and BE dependency declarations (`--produces` / `--needs` / `--category`) / permanently delete (no restore window; `--confirm` required) | -| | `test code put` | Replace generated code (etag-guarded) | -| | `test plan put` | Replace a frontend test's plan-steps | -| | `project create` / `project update` / `project delete` | Manage projects; `delete` removes a project and everything under it (`--confirm` required, no restore window) | -| | `project credential` / `project auto-auth` | Configure backend-test auth: a static injected credential, or auto-refresh login (Pro) | -| **Run** | `test run` | Trigger a fresh run; `--wait` blocks until terminal; `--all --project ` runs all tests in a project in wave order | -| | `test rerun` | Cheap replay of one/many tests (FE verbatim; BE with deps); `--all --project ` reruns all tests | -| | `test flaky` | Replay a test several times (auto-heal off) and report a stability score | -| | `test wait` | Block on one or more `runId`s until terminal | -| | `test cancel` | Cancel one or more in-flight runs (Ctrl-C during `--wait` only detaches — `cancel` is the real stop) | -| | `test artifact get` | Download the failure bundle for a specific `runId` | -| **Agent** | `agent install` / `agent list` / `agent status` | Add, list, or health-check coding-agent skills (pure-local): `claude`, `codex`, `cursor`, `cline`, `antigravity`, `kiro`, `windsurf`, `copilot` | +| Group | Command | What it does | +| --------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Setup** | `setup` | **Start here** — one command: configure your API key, verify it, and install the agent verification skill | +| | `doctor` | Environment diagnostic — CLI/Node versions, profile, endpoint, credentials, connectivity, agent skill; exits non-zero on failure | +| **Auth** | `auth status` | Resolve the active profile to its user, key, env, and scopes | +| | `auth remove` | Remove the active profile from the credentials file | +| | `usage` (alias `credits`) | Account pre-flight: identity, plus credit balance / plan info when the backend supplies them | +| **Read** | `project list` / `project get` | List projects / fetch one by id | +| | `test list` / `test get` | List tests under a project / fetch one by id | +| | `test code get` | Print (or write) the generated test source | +| | `test steps` | List the latest run's steps with screenshot / DOM pointers | +| | `test result` | Latest result; `--history` lists a test's prior runs | +| | `test failure get` | The agent entry point: one self-contained latest-failure bundle | +| | `test failure summary` | One-screen triage card (no media download) | +| | `test diff` | Compare two runs — verdict, failure kind, per-step status flips, code-version drift | +| **Write** | `test scaffold` / `test lint` | Author plans locally: emit a schema-correct starter, validate plan files offline — no network, no credentials | +| | `test create` / `test create-batch` | Create a test (or bulk-create from a plan file); `--produces` / `--needs` / `--category` wire BE dependency metadata | +| | `test update` / `test delete` / `test delete-batch` | Edit metadata and BE dependency declarations (`--produces` / `--needs` / `--category`) / permanently delete (no restore window; `--confirm` required) | +| | `test code put` | Replace generated code (etag-guarded) | +| | `test plan put` | Replace a frontend test's plan-steps | +| | `project create` / `project update` / `project delete` | Manage projects; `delete` removes a project and everything under it (`--confirm` required, no restore window) | +| | `project credential` / `project auto-auth` | Configure backend-test auth: a static injected credential, or auto-refresh login (Pro) | +| **Run** | `test run` | Trigger a fresh run; `--wait` blocks until terminal; `--all --project ` runs all tests in a project in wave order | +| | `test rerun` | Cheap replay of one/many tests (FE verbatim; BE with deps); `--all --project ` reruns all tests | +| | `test flaky` | Replay a test several times (auto-heal off) and report a stability score | +| | `test wait` | Block on one or more `runId`s until terminal | +| | `test cancel` | Cancel one or more in-flight runs (Ctrl-C during `--wait` only detaches — `cancel` is the real stop) | +| | `test artifact get` | Download the failure bundle for a specific `runId` | +| **Agent** | `agent install` / `agent list` / `agent status` | Add, list, or health-check [coding-agent skills](#supported-agents) (pure-local): `claude`, `codex`, `cursor`, `cline`, `antigravity`, `kiro`, `windsurf`, `copilot` | > The earlier command names — `init`, `auth configure`, `auth whoami`, `auth logout` — still work as hidden, deprecated aliases (each prints a one-line notice pointing at the new name), so existing scripts keep running. `auth configure` now runs the full `setup` (it also installs the skill). @@ -200,3 +200,82 @@ Pull requests target the `dev` branch. The full guide — build from source, tes ## License [Apache-2.0](./LICENSE) © TestSprite + +## Supported agents + +`agent install --target ` (and `setup --agent `) wires the verification skill into any of the agents below. Those marked _(shared)_ read the single `.agents/skills/` directory directly; every other agent gets a symlink from its own skills folder back to it — one source of truth, no drift between agents. A few agents also accept a **legacy short alias** (shown in parentheses) for backwards compatibility with older scripts — prefer the canonical id. + +| `--target` id | Agent | Skills folder | +| ---------------------------- | ------------------ | --------------------------- | +| `aider-desk` | AiderDesk | `.aider-desk/skills` | +| `amp` | Amp | `.agents/skills` _(shared)_ | +| `antigravity` | Antigravity | `.agents/skills` _(shared)_ | +| `antigravity-cli` | Antigravity CLI | `.agents/skills` _(shared)_ | +| `astrbot` | AstrBot | `data/skills` | +| `autohand-code` | Autohand Code CLI | `.autohand/skills` | +| `augment` | Augment | `.augment/skills` | +| `bob` | IBM Bob | `.bob/skills` | +| `claude-code` (`claude`) | Claude Code | `.claude/skills` | +| `openclaw` | OpenClaw | `skills` | +| `cline` | Cline | `.agents/skills` _(shared)_ | +| `codearts-agent` | CodeArts Agent | `.codeartsdoer/skills` | +| `codebuddy` | CodeBuddy | `.codebuddy/skills` | +| `codemaker` | Codemaker | `.codemaker/skills` | +| `codestudio` | Code Studio | `.codestudio/skills` | +| `codex` | Codex | `.agents/skills` _(shared)_ | +| `command-code` | Command Code | `.commandcode/skills` | +| `continue` | Continue | `.continue/skills` | +| `cortex` | Cortex Code | `.cortex/skills` | +| `crush` | Crush | `.crush/skills` | +| `cursor` | Cursor | `.agents/skills` _(shared)_ | +| `deepagents` | Deep Agents | `.agents/skills` _(shared)_ | +| `devin` | Devin for Terminal | `.devin/skills` | +| `dexto` | Dexto | `.agents/skills` _(shared)_ | +| `droid` | Droid | `.factory/skills` | +| `eve` | Eve | `agent/skills` | +| `firebender` | Firebender | `.agents/skills` _(shared)_ | +| `forgecode` | ForgeCode | `.forge/skills` | +| `gemini-cli` | Gemini CLI | `.agents/skills` _(shared)_ | +| `github-copilot` (`copilot`) | GitHub Copilot | `.agents/skills` _(shared)_ | +| `goose` | Goose | `.goose/skills` | +| `hermes-agent` | Hermes Agent | `.hermes/skills` | +| `inference-sh` | inference.sh | `.inferencesh/skills` | +| `jazz` | Jazz | `.jazz/skills` | +| `junie` | Junie | `.junie/skills` | +| `iflow-cli` | iFlow CLI | `.iflow/skills` | +| `kilo` | Kilo Code | `.kilocode/skills` | +| `kimi-code-cli` | Kimi Code CLI | `.agents/skills` _(shared)_ | +| `kiro-cli` (`kiro`) | Kiro CLI | `.kiro/skills` | +| `kode` | Kode | `.kode/skills` | +| `lingma` | Lingma | `.lingma/skills` | +| `loaf` | Loaf | `.agents/skills` _(shared)_ | +| `mcpjam` | MCPJam | `.mcpjam/skills` | +| `mistral-vibe` | Mistral Vibe | `.vibe/skills` | +| `moxby` | Moxby | `.moxby/skills` | +| `mux` | Mux | `.mux/skills` | +| `opencode` | OpenCode | `.agents/skills` _(shared)_ | +| `openhands` | OpenHands | `.openhands/skills` | +| `ona` | Ona | `.ona/skills` | +| `pi` | Pi | `.pi/skills` | +| `qoder` | Qoder | `.qoder/skills` | +| `qoder-cn` | Qoder CN | `.qoder/skills` | +| `qwen-code` | Qwen Code | `.qwen/skills` | +| `replit` | Replit | `.agents/skills` _(shared)_ | +| `reasonix` | Reasonix | `.reasonix/skills` | +| `roo` | Roo Code | `.roo/skills` | +| `rovodev` | Rovo Dev | `.rovodev/skills` | +| `tabnine-cli` | Tabnine CLI | `.tabnine/agent/skills` | +| `terramind` | Terramind | `.terramind/skills` | +| `tinycloud` | Tinycloud | `.tinycloud/skills` | +| `trae` | Trae | `.trae/skills` | +| `trae-cn` | Trae CN | `.trae/skills` | +| `warp` | Warp | `.agents/skills` _(shared)_ | +| `windsurf` | Windsurf | `.windsurf/skills` | +| `zed` | Zed | `.agents/skills` _(shared)_ | +| `zcode` | ZCode | `.zcode/skills` | +| `zencoder` | Zencoder | `.zencoder/skills` | +| `zenflow` | Zenflow | `.zencoder/skills` | +| `neovate` | Neovate | `.neovate/skills` | +| `pochi` | Pochi | `.pochi/skills` | +| `promptscript` | PromptScript | `.agents/skills` _(shared)_ | +| `adal` | AdaL | `.adal/skills` | diff --git a/package-lock.json b/package-lock.json index 4461016..f9afe03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@testsprite/testsprite-cli", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@testsprite/testsprite-cli", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "commander": "^12.1.0", diff --git a/skills/testsprite-onboard.skill.md b/skills/testsprite-onboard.skill.md index df04c18..13e7835 100644 --- a/skills/testsprite-onboard.skill.md +++ b/skills/testsprite-onboard.skill.md @@ -1,9 +1,7 @@ - +--- +name: testsprite-onboard +description: Stand up a complete, runnable TestSprite test suite for the current repo at first use — create a project (with a target URL and auth), derive a coherent set of tests from the codebase, batch-create them, and smoke-run a few to a green verdict so the user immediately has something worth running. Use ONLY when a repo has no TestSprite tests yet (a fresh project), right after `testsprite setup`, or when the user asks to "set up / bootstrap / seed tests". This is first-run setup, NOT change verification — once a project already has tests, use the testsprite-verify skill instead. +--- # TestSprite: onboard a repo with a seed test suite diff --git a/skills/testsprite-verify.codex.md b/skills/testsprite-verify.codex.md deleted file mode 100644 index d3c5373..0000000 --- a/skills/testsprite-verify.codex.md +++ /dev/null @@ -1,127 +0,0 @@ -# TestSprite Verification Loop - -After finishing a feature or fix in a TestSprite-tested repo, use the `testsprite` -CLI to run the relevant TestSprite tests against the change and inspect any failure -artifacts before reporting the work as done. Use whenever code has changed outside -docs/config and is about to be reported complete. - -## When to run - -Run after a feature or fix lands. Skip only for: docs-only edits, pure -build/config changes, or when the repo has no TestSprite project linked. - -The CLI only tests a reachable deployed URL (it rejects localhost). If the -change is only running locally, hand off to the TestSprite MCP when it's -available — it tunnels your local server; otherwise report the change as -unverified-because-undeployed and stop. If the user explicitly named a tool -(the CLI or the MCP), honor that over this reachability heuristic. - -## Core loop - -### 1. Preflight - -```bash -testsprite --version # CLI installed? -testsprite auth status # credentials valid? -``` - -If `--version` fails, tell the user to install the CLI and stop. -If `auth status` fails, tell the user to run `testsprite setup` and stop. - -### 2. Find the project - -In order: `$TESTSPRITE_PROJECT_ID` → `.testsprite/config.json` → `testsprite project list --output json`. - -### 3. Run - -```bash -# New frontend test from plan (most common) -testsprite test create --plan-from plan.json --run --wait \ - --target-url https://staging.example.com --timeout 600 --output json - -# Existing test -testsprite test run --target-url https://staging.example.com \ - --wait --timeout 600 --output json - -# New backend test from Python assertion file -testsprite test create --type backend --name "Login rejects empty password" \ - --project --code-file /tmp/test.py --run --wait --timeout 600 - -# Replay (cheaper than a fresh run — reuses saved test code) -testsprite test rerun --wait --output json - -# Backend tests sharing state: declare the dependency graph at create time; -# the wave engine orders runs (producers → consumers → teardown last) -testsprite test create --type backend --project --code-file /tmp/login.py \ - --name "login issues an auth token" --produces auth_token -testsprite test create --type backend --project --code-file /tmp/profile.py \ - --name "profile update accepts the token" --needs auth_token -testsprite test create --type backend --project --code-file /tmp/cleanup.py \ - --name "fixture user is deleted" --category teardown - -# Wave-ordered batch fresh run (BE tests, all or filtered) -testsprite test run --all --project [--filter ] \ - --wait --max-concurrency 4 --output json -``` - -**Key behaviors:** - -- `--target-url` must be publicly reachable (no localhost / RFC1918) and must - already have the change deployed (e.g. a CI preview deploy) — the CLI tests a - deployed URL, it doesn't host your environment. Running earlier verifies the - previous build. -- Backend `--code-file`: the runner executes the file top-to-bottom (not `pytest`), so **call your `test_*` function(s) at the end of the file** — a defined-but-uncalled test silently passes. -- Backend sandbox has only stdlib + `requests` + `pytest` + `numpy` + `scipy`. Test the API over HTTP with `requests`; do **not** `import` the project's own source modules or other packages (e.g. `torch`) — they aren't installed and the test won't run. -- `--wait` long-polls until terminal. Do not wrap it in a retry loop. -- Exit `0` = passed; `1` = failed/blocked; `7` = timeout (resume with `test wait `). -- BE dependency flags (`--produces`/`--needs`/`--category`) are backend-only and - **create-only** — they can't be read back or edited later (delete + recreate to - change the graph). Don't hand-sequence `test run` calls to fake ordering; use - `test run --all` so the engine passes captured variables between waves. -- A BE `test rerun` dispatches the whole producer/teardown closure, side effects - included; `--skip-dependencies` reruns only the named test. If a producer failed - in the same closure, the consumer's failure is starvation (missing token/fixture) - — triage the producer first; it does not implicate your change. -- `create` and `--wait` output include a `dashboardUrl` — if the user wants to - inspect a test or run themselves, point them there. - -### 4. On failure — download the artifact - -```bash -testsprite test artifact get --out ./.testsprite/runs// -``` - -Inspect the bundle (failing step, screenshots, root-cause hypothesis) before -deciding whether your change caused the failure. - -### 5. One more tool — dry-run for learning - -Every command works without credentials under `--dry-run`: - -```bash -testsprite test run --dry-run --output json -testsprite test create --plan-from plan.json --dry-run --output json -``` - -## Exit-code quick reference - -| Code | Meaning | -| ---- | ------------------------------------------------- | -| 0 | Success (passed) | -| 1 | Failed / blocked / cancelled | -| 3 | Auth error | -| 4 | Not found | -| 5 | Validation error | -| 6 | Conflict (already running) | -| 7 | Timeout — resume: `testsprite test wait ` | -| 11 | Rate limited (retriable) | -| 12 | Insufficient credits | - -## Bootstrap (first-time setup) - -```bash -npm install -g @testsprite/testsprite-cli -testsprite setup # configure + verify + install agent skill in one shot -``` - -Verify your setup anytime: `testsprite auth status`. diff --git a/skills/testsprite-verify.skill.md b/skills/testsprite-verify.skill.md index 40d60f2..01cc104 100644 --- a/skills/testsprite-verify.skill.md +++ b/skills/testsprite-verify.skill.md @@ -1,3 +1,8 @@ +--- +name: testsprite-verify +description: TestSprite verification loop — after finishing a feature or fix in a TestSprite-tested repo, use the `testsprite` CLI to run the relevant TestSprite tests against the change and inspect any failure artifacts before reporting the work as done. Use whenever code has changed outside docs/config and is about to be reported complete — by running an existing test that covers the change, or by creating a new TestSprite test (a frontend plan, or a backend Python assertion) and running it to a terminal verdict. +--- + # TestSprite Verification Loop The verification loop that flies your just-shipped feature through the diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 7326aeb..2b3155a 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -1,2581 +1,889 @@ -import { existsSync, mkdtempSync, readFileSync, symlinkSync } from 'node:fs'; -import { tmpdir } from 'node:os'; import path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; -import { ApiError, CLIError } from '../lib/errors.js'; +import { describe, expect, it } from 'vitest'; import { DEFAULT_SKILLS, - MANAGED_SECTION_BEGIN, - MANAGED_SECTION_END, - ONBOARD_CODEX_LINE, SKILLS, - buildSkillMarker, - pathFor, - renderForTarget, - renderOwnFileWithMarker, TARGETS, type AgentTarget, + buildSkillMarker, + canonicalSkillFile, + loadSkillFull, + pathFor, + renderCanonicalWithMarker, + targetLandingDir, } from '../lib/agent-targets.js'; import type { AgentDeps, AgentFs, InstallResult, ListResult, StatusResult } from './agent.js'; -import { - AGENTS_MD_CODEX_BUDGET_BYTES, - createAgentCommand, - runInstall, - runList, - runStatus, -} from './agent.js'; +import { createAgentCommand, runInstall, runList, runStatus } from './agent.js'; // --------------------------------------------------------------------------- -// In-memory AgentFs backed by a Map +// In-memory AgentFs (files + dirs + symlinks), platform-agnostic // --------------------------------------------------------------------------- -function makeMemFs(): { - store: Map; - fs: AgentFs; - mkdirCalls: string[]; - writeCalls: string[]; - seedFile: (p: string, content: string) => void; - seedDir: (p: string) => void; - seedSymlink: (p: string) => void; -} { - const store = new Map(); // regular files: path -> content - const dirs = new Set(); // directories - const symlinks = new Set(); // symlinks (we only need to know it IS one) +interface MemNode { + kind: 'file' | 'symlink'; + content?: string; // file + target?: string; // symlink (stored form, relative or absolute) +} + +function makeMemFs() { + const files = new Map(); + const dirs = new Set(); const mkdirCalls: string[] = []; const writeCalls: string[] = []; + const symlinkCalls: { target: string; link: string }[] = []; - // Record `p` and all of its ancestors as directories, modelling a real fs - // tree so the per-component lstat walk in inspectTargetPath can traverse. const addAncestorDirs = (p: string) => { let cur = path.dirname(p); - while (cur !== path.dirname(cur)) { + let guard = 0; + while (cur !== path.dirname(cur) && guard++ < 64) { dirs.add(cur); cur = path.dirname(cur); } - dirs.add(cur); // filesystem root + dirs.add(cur); }; const agentFs: AgentFs = { - async lstat(p: string) { - if (symlinks.has(p)) return { isFile: false, isSymbolicLink: true }; - if (store.has(p)) return { isFile: true, isSymbolicLink: false }; + async lstat(p) { + const node = files.get(p); + if (node) return { isFile: node.kind === 'file', isSymbolicLink: node.kind === 'symlink' }; if (dirs.has(p)) return { isFile: false, isSymbolicLink: false }; return null; }, - async readFile(p: string) { - const v = store.get(p); - if (v === undefined) throw Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' }); - return v; + async readFile(p) { + const node = files.get(p); + if (!node || node.kind !== 'file') { + throw Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' }); + } + return node.content ?? ''; }, - async writeFile(p: string, data: string, opts?: { exclusive?: boolean }) { - if (opts?.exclusive && (store.has(p) || dirs.has(p) || symlinks.has(p))) { + async writeFile(p, data, opts) { + if (opts?.exclusive && (files.has(p) || dirs.has(p))) { throw Object.assign(new Error(`EEXIST: ${p}`), { code: 'EEXIST' }); } writeCalls.push(p); - store.set(p, data); + files.set(p, { kind: 'file', content: data }); addAncestorDirs(p); }, - async mkdir(p: string) { + async mkdir(p) { mkdirCalls.push(p); dirs.add(p); addAncestorDirs(p); }, + async readlink(p) { + const node = files.get(p); + if (!node || node.kind !== 'symlink') return null; + return node.target ?? null; + }, + async symlink(target, linkPath) { + symlinkCalls.push({ target, link: linkPath }); + if (files.has(linkPath) || dirs.has(linkPath)) { + throw Object.assign(new Error(`EEXIST: ${linkPath}`), { code: 'EEXIST' }); + } + files.set(linkPath, { kind: 'symlink', target }); + addAncestorDirs(linkPath); + }, + async unlink(p) { + files.delete(p); + }, + async rm(p) { + files.delete(p); + // remove anything beneath p + for (const k of [...files.keys()]) { + if (k.startsWith(p + path.sep)) files.delete(k); + } + for (const d of [...dirs]) { + if (d === p || d.startsWith(p + path.sep)) dirs.delete(d); + } + }, + }; + + const seedFile = (p: string, content: string) => { + files.set(path.resolve(p), { kind: 'file', content }); + addAncestorDirs(path.resolve(p)); + }; + const seedDir = (p: string) => { + dirs.add(path.resolve(p)); + addAncestorDirs(path.resolve(p)); + }; + const seedSymlink = (p: string, target: string) => { + files.set(path.resolve(p), { kind: 'symlink', target }); + addAncestorDirs(path.resolve(p)); }; return { - store, - fs: agentFs, + agentFs, + files, + dirs, mkdirCalls, writeCalls, - seedFile: (p, content) => { - store.set(p, content); - addAncestorDirs(p); - }, - seedDir: p => { - dirs.add(p); - addAncestorDirs(p); - }, - seedSymlink: p => { - symlinks.add(p); - addAncestorDirs(p); - }, + symlinkCalls, + seedFile, + seedDir, + seedSymlink, }; } // --------------------------------------------------------------------------- -// Captured output helper +// Harness // --------------------------------------------------------------------------- -interface CapturedOutput { - stdout: string[]; - stderr: string[]; -} +const ROOT = path.resolve('/testsprite-proj'); -function makeCapture(): { capture: CapturedOutput; deps: Pick } { - const capture: CapturedOutput = { stdout: [], stderr: [] }; +function deps(fs: ReturnType, opts: { isTTY?: boolean } = {}): AgentDeps { return { - capture, - deps: { - stdout: line => capture.stdout.push(line), - stderr: line => capture.stderr.push(line), - }, + cwd: ROOT, + fs: fs.agentFs, + isTTY: opts.isTTY ?? false, + stderr: () => {}, + stdout: () => {}, }; } -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- +/** Full CommonOptions base so every runInstall/runList/runStatus call typechecks. */ +const COMMON = { + profile: 'default', + output: 'json' as const, + endpointUrl: undefined, + debug: false, + verbose: false, + dryRun: false, +}; + +async function runInstallJson( + fs: ReturnType, + args: { target?: string[]; skills?: string[]; force?: boolean; dir?: string; dryRun?: boolean }, +): Promise { + const stdout: string[] = []; + await runInstall( + { + ...COMMON, + target: args.target ?? [], + skills: args.skills, + force: args.force ?? false, + dir: args.dir, + dryRun: args.dryRun ?? false, + }, + { ...deps(fs), stdout: (l: string) => stdout.push(l) }, + ); + return JSON.parse(stdout.join('')); +} -const CWD = '/test-project'; -const ALL_TARGETS = Object.keys(TARGETS) as AgentTarget[]; -/** own-file targets (not managed-section — codex has different install semantics) */ -const OWN_FILE_TARGETS = ALL_TARGETS.filter(t => TARGETS[t].mode === 'own-file'); +const canonicalPath = (skill: string) => path.resolve(ROOT, canonicalSkillFile(skill)); +const canonicalContent = (skill: string) => + renderCanonicalWithMarker(skill, buildSkillMarker(skill, loadSkillFull(skill))); +const landingPath = (target: AgentTarget, skill: string) => + path.resolve(ROOT, targetLandingDir(target, skill)); // --------------------------------------------------------------------------- -// runInstall — fresh install per target +// runInstall — canonical + symlink model // --------------------------------------------------------------------------- -describe('runInstall — fresh install', () => { - it.each(OWN_FILE_TARGETS)('writes correct file for own-file target %s', async t => { - const { store, fs: agentFs, mkdirCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: [t], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const { path: relPath, content } = renderForTarget(t, 'testsprite-verify'); - const abs = path.resolve(CWD, relPath); +describe('runInstall — universal target (codex) writes only the canonical file', () => { + it('writes .agents/skills//SKILL.md and reports mode=canonical', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: ['codex'] }); + const verify = res.find(r => r.skills.includes('testsprite-verify'))!; + expect(verify.target).toBe('codex'); + expect(verify.mode).toBe('canonical'); + expect(verify.action).toBe('written'); + expect(verify.path).toBe('.agents/skills/testsprite-verify/SKILL.md'); + expect(fs.files.has(canonicalPath('testsprite-verify'))).toBe(true); + // No symlink created for a universal target. + expect(fs.symlinkCalls.length).toBe(0); + }); - // File was written to store - expect(store.get(abs)).toBe(content); + it('installs both default skills', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: ['codex'] }); + expect(res.map(r => r.skills[0]).sort()).toEqual([...DEFAULT_SKILLS].sort()); + }); +}); - // mkdir was called for the parent directory - expect(mkdirCalls.some(d => d === path.dirname(abs))).toBe(true); +describe('runInstall — symlinked target (claude-code) writes canonical + a symlink back to it', () => { + it('creates .claude/skills/ → .agents/skills/', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: ['claude-code'] }); + const verify = res.find(r => r.skills.includes('testsprite-verify'))!; + expect(verify.target).toBe('claude-code'); + expect(verify.mode).toBe('symlink'); + expect(verify.action).toBe('written'); + expect(verify.path).toBe('.claude/skills/testsprite-verify/SKILL.md'); - // stdout contains 'written' - expect(capture.stdout.join('\n')).toContain('written'); - expect(capture.stdout.join('\n')).toContain(relPath); + // canonical exists + expect(fs.files.has(canonicalPath('testsprite-verify'))).toBe(true); + // symlink exists and resolves to canonical dir + const link = path.resolve(ROOT, '.claude/skills/testsprite-verify'); + const node = fs.files.get(link); + expect(node?.kind).toBe('symlink'); + const resolved = path.resolve(path.dirname(link), node!.target!); + expect(resolved).toBe(path.resolve(ROOT, '.agents/skills/testsprite-verify')); }); - it('written content equals renderForTarget exactly', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); + it('symlink target is relative (portable when the project moves)', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code'] }); + const link = path.resolve(ROOT, '.claude/skills/testsprite-verify'); + const target = fs.files.get(link)!.target!; + expect(target).not.toBe(path.resolve(ROOT, '.agents/skills/testsprite-verify')); + expect(path.isAbsolute(target)).toBe(false); + }); +}); - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); +describe('runInstall — alias resolution', () => { + it('--target claude resolves to claude-code and lands under .claude/skills', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: ['claude'] }); + expect(res[0]!.target).toBe('claude-code'); + expect(res[0]!.path).toBe('.claude/skills/testsprite-verify/SKILL.md'); + }); - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(CWD, relPath); - expect(store.get(abs)).toBe(content); + it('--target copilot resolves to github-copilot (universal)', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: ['copilot'] }); + expect(res[0]!.target).toBe('github-copilot'); + expect(res[0]!.mode).toBe('canonical'); }); - it('writes to the correct matrix paths (claude and antigravity)', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); + it('comma-separated targets and repeats are accepted', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: ['claude-code,codex'] }); + expect([...new Set(res.map(r => r.target))].sort()).toEqual(['claude-code', 'codex']); + }); +}); - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude', 'antigravity'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); +describe('runInstall — unknown target or skill', () => { + it('throws exit 5 with the supported list, nothing written', async () => { + const fs = makeMemFs(); + await expect(runInstallJson(fs, { target: ['nope'] })).rejects.toMatchObject({ + exitCode: 5, + }); + expect(fs.writeCalls.length).toBe(0); + expect(fs.symlinkCalls.length).toBe(0); + }); - const claudeAbs = path.resolve(CWD, TARGETS.claude.path); - const antigravityAbs = path.resolve(CWD, TARGETS.antigravity.path); - expect(store.has(claudeAbs)).toBe(true); - expect(store.has(antigravityAbs)).toBe(true); + it('throws exit 5 for an unknown skill, writing nothing', async () => { + const fs = makeMemFs(); + await expect( + runInstallJson(fs, { target: ['codex'], skills: ['bogus'] }), + ).rejects.toMatchObject({ exitCode: 5 }); + expect(fs.writeCalls.length).toBe(0); + expect(fs.symlinkCalls.length).toBe(0); }); +}); - it('cline landing path .clinerules/testsprite-verify.md', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); +describe('runInstall — default target', () => { + it('non-TTY with no target defaults to claude-code', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, {}); + expect(res[0]!.target).toBe('claude-code'); + }); + it('interactive TTY prompts for targets and uses the answer', async () => { + const fs = makeMemFs(); + const stdout: string[] = []; await runInstall( + { ...COMMON, target: [], force: false }, { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['cline'], - skills: ['testsprite-verify'], - force: false, + ...deps(fs, { isTTY: true }), + stdout: (l: string) => stdout.push(l), + prompt: async () => 'codex', }, - { cwd: CWD, fs: agentFs, ...deps }, ); - - const abs = path.resolve(CWD, '.clinerules/testsprite-verify.md'); - expect(store.has(abs)).toBe(true); + expect((JSON.parse(stdout.join('')) as InstallResult[])[0]!.target).toBe('codex'); }); - it('cursor landing path .cursor/rules/testsprite-verify.mdc', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); - + it('interactive TTY with an empty answer falls back to claude-code', async () => { + const fs = makeMemFs(); + const stdout: string[] = []; await runInstall( + { ...COMMON, target: [], force: false }, { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['cursor'], - skills: ['testsprite-verify'], - force: false, + ...deps(fs, { isTTY: true }), + stdout: (l: string) => stdout.push(l), + prompt: async () => ' ', }, - { cwd: CWD, fs: agentFs, ...deps }, ); - - const abs = path.resolve(CWD, '.cursor/rules/testsprite-verify.mdc'); - expect(store.has(abs)).toBe(true); + expect((JSON.parse(stdout.join('')) as InstallResult[])[0]!.target).toBe('claude-code'); }); }); -// --------------------------------------------------------------------------- -// runInstall — idempotency -// --------------------------------------------------------------------------- - -describe('runInstall — idempotency (skipped)', () => { - it('re-run with identical content → action skipped, no extra write', async () => { - const { store, fs: agentFs, writeCalls, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - // Pre-seed with the canonical content - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(CWD, relPath); - seedFile(abs, content); - - const writeCountBefore = writeCalls.length; - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - // No new write happened - expect(writeCalls.length).toBe(writeCountBefore); - expect(capture.stdout.join('\n')).toContain('skipped'); - // Content unchanged - expect(store.get(abs)).toBe(content); +describe('runInstall — idempotency', () => { + it('re-run with identical content → skipped (canonical), skipped (symlink)', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code', 'codex'] }); + fs.writeCalls.length = 0; + fs.symlinkCalls.length = 0; + const res = await runInstallJson(fs, { target: ['claude-code', 'codex'] }); + expect(res.every(r => r.action === 'skipped')).toBe(true); + expect(fs.writeCalls.length).toBe(0); + expect(fs.symlinkCalls.length).toBe(0); }); }); -// --------------------------------------------------------------------------- -// runInstall — conflict without --force -// --------------------------------------------------------------------------- - -describe('runInstall — conflict (blocked)', () => { - it('exits 6 when file differs and --force not set', async () => { - const { store, fs: agentFs, writeCalls, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const { path: relPath } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(CWD, relPath); - seedFile(abs, 'DIFFERENT CONTENT'); - - const writeCountBefore = writeCalls.length; - - let thrown: unknown; - try { - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(6); +describe('runInstall — blocked without --force', () => { + it('exits 6 when the canonical file differs', async () => { + const fs = makeMemFs(); + fs.seedFile(canonicalPath('testsprite-verify'), 'different content'); + await expect(runInstallJson(fs, { target: ['codex'] })).rejects.toMatchObject({ + exitCode: 6, + }); + }); - // File not written - expect(writeCalls.length).toBe(writeCountBefore); - expect(store.get(abs)).toBe('DIFFERENT CONTENT'); + it('exits 6 when a symlink landing points elsewhere', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code'] }); + fs.files.set(landingPath('claude-code', 'testsprite-verify'), { + kind: 'symlink', + target: '/somewhere/else', + }); + await expect(runInstallJson(fs, { target: ['claude-code'] })).rejects.toMatchObject({ + exitCode: 6, + }); + }); - // stderr has a --force hint - expect(capture.stderr.join('\n')).toContain('--force'); - // stdout has action:blocked - expect(capture.stdout.join('\n')).toContain('blocked'); + it('exits 6 when a regular file occupies the symlink landing', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + fs.seedFile(landingPath('claude-code', 'testsprite-verify'), 'stray'); + await expect(runInstallJson(fs, { target: ['claude-code'] })).rejects.toMatchObject({ + exitCode: 6, + }); }); }); -// --------------------------------------------------------------------------- -// runInstall — --force -// --------------------------------------------------------------------------- - describe('runInstall — --force', () => { - it('backs up to .bak and writes canonical content', async () => { - const { store, fs: agentFs, writeCalls, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(CWD, relPath); - const oldContent = 'OLD CONTENT'; - seedFile(abs, oldContent); + it('backs up the canonical file to .bak and overwrites', async () => { + const fs = makeMemFs(); + fs.seedFile(canonicalPath('testsprite-verify'), 'old bytes'); + const res = await runInstallJson(fs, { target: ['codex'], force: true }); + expect(res.find(r => r.skills.includes('testsprite-verify'))!.action).toBe('updated'); + expect(fs.files.has(`${canonicalPath('testsprite-verify')}.bak`)).toBe(true); + expect(fs.files.get(`${canonicalPath('testsprite-verify')}.bak`)!.content).toBe('old bytes'); + }); + + it('replaces a symlink that points elsewhere', async () => { + const fs = makeMemFs(); + // canonical first + await runInstallJson(fs, { target: ['claude-code'] }); + // sabotage the symlink to point elsewhere + const link = path.resolve(ROOT, '.claude/skills/testsprite-verify'); + fs.files.set(link, { kind: 'symlink', target: '/somewhere/else' }); + const res = await runInstallJson(fs, { target: ['claude-code'], force: true }); + expect(res.find(r => r.skills.includes('testsprite-verify'))!.action).toBe('updated'); + const resolved = path.resolve(path.dirname(link), fs.files.get(link)!.target!); + expect(resolved).toBe(path.resolve(ROOT, '.agents/skills/testsprite-verify')); + }); + + it('replaces a regular file at the landing, backing it up to .bak', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + const link = landingPath('claude-code', 'testsprite-verify'); + fs.seedFile(link, 'stray'); + const res = await runInstallJson(fs, { target: ['claude-code'], force: true }); + expect(res.find(r => r.skills.includes('testsprite-verify'))!.action).toBe('updated'); + expect(fs.files.get(`${link}.bak`)!.content).toBe('stray'); + expect(fs.files.get(link)?.kind).toBe('symlink'); + }); + + it('uses .bak.1 when .bak already exists, leaving the prior backup intact', async () => { + const fs = makeMemFs(); + const cp = canonicalPath('testsprite-verify'); + fs.seedFile(cp, 'tampered'); + fs.seedFile(`${cp}.bak`, 'old backup'); + const res = await runInstallJson(fs, { target: ['codex'], force: true }); + expect(res.find(r => r.skills.includes('testsprite-verify'))!.action).toBe('updated'); + expect(fs.files.get(`${cp}.bak.1`)!.content).toBe('tampered'); + expect(fs.files.get(`${cp}.bak`)!.content).toBe('old backup'); + }); +}); +describe('runInstall — --dry-run', () => { + it('writes nothing; reports would-write lines on stderr', async () => { + const fs = makeMemFs(); + const stderrLines: string[] = []; await runInstall( { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: true, + ...COMMON, + target: ['claude-code'], + force: false, + dryRun: true, }, - { cwd: CWD, fs: agentFs, ...deps }, + { ...deps(fs), stderr: (l: string) => stderrLines.push(l) }, ); - - // .bak was written via writeFile - expect(writeCalls).toContain(`${abs}.bak`); - - // .bak has old content - expect(store.get(`${abs}.bak`)).toBe(oldContent); - - // File now has canonical content - expect(store.get(abs)).toBe(content); - - // Action reported as updated - expect(capture.stdout.join('\n')).toContain('updated'); + expect(fs.writeCalls.length).toBe(0); + expect(fs.symlinkCalls.length).toBe(0); + expect(stderrLines.some(l => l.includes('[dry-run]'))).toBe(true); }); - it('double --force preserves the first backup and writes a numbered .bak.1', async () => { - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { deps: deps1 } = makeCapture(); - - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(CWD, relPath); - const firstEdit = 'FIRST EDIT'; - seedFile(abs, firstEdit); - - // First --force + it('classifies an existing correct symlink as skipped in the note', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code'] }); + const stderrLines: string[] = []; await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: true, - }, - { cwd: CWD, fs: agentFs, ...deps1 }, + { ...COMMON, target: ['claude-code'], force: false, dryRun: true }, + { ...deps(fs), stderr: (l: string) => stderrLines.push(l) }, ); + const note = stderrLines.find(l => l.includes('symlink →')); + expect(note).toBeTruthy(); + expect(note!.includes('(skipped)')).toBe(true); + }); - // .bak holds firstEdit; abs holds canonical - expect(store.get(`${abs}.bak`)).toBe(firstEdit); - expect(store.get(abs)).toBe(content); - - // Now mutate the file again (simulate user editing after first --force) - const secondEdit = 'SECOND EDIT'; - seedFile(abs, secondEdit); - - const { deps: deps2 } = makeCapture(); - // Second --force + it('classifies a symlink pointing elsewhere as dry-run (not skipped)', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code'] }); + fs.files.set(landingPath('claude-code', 'testsprite-verify'), { + kind: 'symlink', + target: '/elsewhere', + }); + const stderrLines: string[] = []; await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: true, - }, - { cwd: CWD, fs: agentFs, ...deps2 }, + { ...COMMON, target: ['claude-code'], force: false, dryRun: true }, + { ...deps(fs), stderr: (l: string) => stderrLines.push(l) }, ); - - // First backup preserved (not clobbered); the second lands at .bak.1. - expect(store.get(`${abs}.bak`)).toBe(firstEdit); - expect(store.get(`${abs}.bak.1`)).toBe(secondEdit); - // abs still holds canonical - expect(store.get(abs)).toBe(content); + const note = stderrLines.find(l => l.includes('symlink →')); + expect(note).toBeTruthy(); + expect(note!.includes('(dry-run)')).toBe(true); + expect(note!.includes('(skipped)')).toBe(false); }); }); -// --------------------------------------------------------------------------- -// runInstall — --dry-run -// --------------------------------------------------------------------------- - -describe('runInstall — --dry-run', () => { - it('writes nothing to fs; emits banner + would-write lines on stderr', async () => { - const { store, fs: agentFs, writeCalls, mkdirCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: true, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); +describe('runInstall — path safety', () => { + it('refuses (exit 5) when an ancestor of the canonical path is a symlink', async () => { + const fs = makeMemFs(); + // Plant a symlink at .agents — the canonical write must refuse to traverse it. + fs.seedSymlink(path.resolve(ROOT, '.agents'), '/elsewhere'); + await expect(runInstallJson(fs, { target: ['codex'] })).rejects.toMatchObject({ + exitCode: 5, + }); + }); - // No writes - expect(writeCalls.length).toBe(0); - expect(mkdirCalls.length).toBe(0); - expect(store.size).toBe(0); - - const stderrOut = capture.stderr.join('\n'); - // Banner present - expect(stderrOut).toContain('[dry-run] no files written'); - // Would-write line present - expect(stderrOut).toContain('would write'); - // bytes count present (positive integer) - expect(stderrOut).toMatch(/\(\d+ bytes\)/); - - // stdout contains 'dry-run' action - expect(capture.stdout.join('\n')).toContain('dry-run'); + it('refuses (exit 5) when an ancestor of a symlink landing is a symlink', async () => { + const fs = makeMemFs(); + fs.seedSymlink(path.resolve(ROOT, '.claude'), '/elsewhere'); + await expect(runInstallJson(fs, { target: ['claude-code'] })).rejects.toMatchObject({ + exitCode: 5, + }); }); -}); -// --------------------------------------------------------------------------- -// runInstall — --dir override -// --------------------------------------------------------------------------- + it('refuses (exit 5) when an ancestor of the canonical path is a regular file', async () => { + const fs = makeMemFs(); + // .agents is a file → cannot descend into .agents/skills/... + fs.seedFile(path.resolve(ROOT, '.agents'), 'oops'); + await expect(runInstallJson(fs, { target: ['codex'] })).rejects.toMatchObject({ + exitCode: 5, + }); + }); -describe('runInstall — --dir override', () => { - it('writes under --dir instead of cwd', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); + it('refuses (exit 5) when an ancestor of the landing path is a regular file', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + fs.seedFile(path.resolve(ROOT, '.claude'), 'oops'); + await expect(runInstallJson(fs, { target: ['claude-code'] })).rejects.toMatchObject({ + exitCode: 5, + }); + }); - const customDir = '/custom-dir'; + it('refuses (exit 5) when the canonical path itself is a directory', async () => { + const fs = makeMemFs(); + fs.seedDir(canonicalPath('testsprite-verify')); + await expect(runInstallJson(fs, { target: ['codex'] })).rejects.toMatchObject({ + exitCode: 5, + }); + }); +}); - await runInstall( +describe('runInstall — copy fallback when symlink is unavailable', () => { + it('writes a real SKILL.md under the agent dir and reports copy-fallback', async () => { + const fs = makeMemFs(); + // Make symlink reject (simulate Windows without Developer Mode). + const base = fs.agentFs; + const noLinkFs: AgentFs = { + ...base, + symlink: async () => { + throw new Error('EPERM: symlinks unavailable'); + }, + }; + const stderrLines: string[] = []; + const stdout: string[] = []; + const res = await runInstall( { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], + ...COMMON, + target: ['claude-code'], force: false, - dir: customDir, }, - { cwd: CWD, fs: agentFs, ...deps }, + { + cwd: ROOT, + fs: noLinkFs, + isTTY: false, + stderr: l => stderrLines.push(l), + stdout: (l: string) => stdout.push(l), + }, ); - - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(customDir, relPath); - expect(store.get(abs)).toBe(content); - // Not written under CWD - const cwdAbs = path.resolve(CWD, relPath); - expect(store.has(cwdAbs)).toBe(false); + void res; + const parsed = JSON.parse(stdout.join('')) as InstallResult[]; + const verify = parsed.find(r => r.skills.includes('testsprite-verify'))!; + expect(verify.action).toBe('copy-fallback'); + // A real SKILL.md exists under the landing dir. + const copied = path.resolve(ROOT, '.claude/skills/testsprite-verify/SKILL.md'); + expect(fs.files.has(copied)).toBe(true); + expect(stderrLines.some(l => l.includes('copied instead'))).toBe(true); + // canonical still exists (source of truth) + expect(fs.files.has(canonicalPath('testsprite-verify'))).toBe(true); }); }); -// --------------------------------------------------------------------------- -// runInstall — unknown target -// --------------------------------------------------------------------------- - -describe('runInstall — unknown target', () => { - it('throws exit 5 with supported list, nothing written', async () => { - const { store, fs: agentFs, writeCalls } = makeMemFs(); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['bogus'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } +// A prior copy-fallback leaves a real directory at the landing (not a symlink). +// Re-running install must treat that directory like any other landing: idempotent +// when it matches, blocked when it differs, refreshed with --force. +describe('runInstall — copy-fallback directory (re-install)', () => { + const seedCopyDir = (fs: ReturnType, skillMdContent: string | null): void => { + const dir = landingPath('claude-code', 'testsprite-verify'); + fs.seedDir(dir); + if (skillMdContent !== null) fs.seedFile(path.join(dir, 'SKILL.md'), skillMdContent); + }; - expect(thrown).toBeInstanceOf(ApiError); - expect((thrown as ApiError).exitCode).toBe(5); - // localValidationError puts the detail in nextAction; message is always 'Invalid request.' - expect((thrown as ApiError).nextAction).toContain('bogus'); - expect(writeCalls.length).toBe(0); - expect(store.size).toBe(0); + it('skipped when the copy matches the canonical content', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + seedCopyDir(fs, canonicalContent('testsprite-verify')); + const res = await runInstallJson(fs, { target: ['claude-code'] }); + expect(res.find(r => r.skills.includes('testsprite-verify'))!.action).toBe('skipped'); }); -}); -// --------------------------------------------------------------------------- -// runInstall — multi-target -// --------------------------------------------------------------------------- + it('blocked (exit 6) when the copy differs, without --force', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + seedCopyDir(fs, 'tampered'); + await expect(runInstallJson(fs, { target: ['claude-code'] })).rejects.toMatchObject({ + exitCode: 6, + }); + }); -describe('runInstall — multi-target', () => { - it('writes both claude and cursor when both specified', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); + it('updated when the copy differs and --force is given', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + const dir = landingPath('claude-code', 'testsprite-verify'); + seedCopyDir(fs, 'tampered'); + const res = await runInstallJson(fs, { target: ['claude-code'], force: true }); + expect(res.find(r => r.skills.includes('testsprite-verify'))!.action).toBe('updated'); + expect(fs.files.get(path.join(dir, 'SKILL.md'))!.content).toBe( + canonicalContent('testsprite-verify'), + ); + }); - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude', 'cursor'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, + it('written when the directory exists but its SKILL.md is missing', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + const dir = landingPath('claude-code', 'testsprite-verify'); + seedCopyDir(fs, null); + const res = await runInstallJson(fs, { target: ['claude-code'] }); + expect(res.find(r => r.skills.includes('testsprite-verify'))!.action).toBe('written'); + expect(fs.files.get(path.join(dir, 'SKILL.md'))!.content).toBe( + canonicalContent('testsprite-verify'), ); + }); - expect(store.has(path.resolve(CWD, TARGETS.claude.path))).toBe(true); - expect(store.has(path.resolve(CWD, TARGETS.cursor.path))).toBe(true); - expect(capture.stdout.join('\n')).toContain('written'); + it('blocked (exit 6) when SKILL.md is itself a directory — even with --force', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + const dir = landingPath('claude-code', 'testsprite-verify'); + fs.seedDir(dir); + fs.seedDir(path.join(dir, 'SKILL.md')); + await expect(runInstallJson(fs, { target: ['claude-code'] })).rejects.toMatchObject({ + exitCode: 6, + }); + await expect( + runInstallJson(fs, { target: ['claude-code'], force: true }), + ).rejects.toMatchObject({ exitCode: 6 }); }); +}); - it('comma-separated in single --target value works', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); +describe('runInstall — output modes', () => { + it('JSON mode emits the exact result row', async () => { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: ['codex'] }); + expect(res).toContainEqual({ + target: 'codex', + mode: 'canonical', + action: 'written', + path: '.agents/skills/testsprite-verify/SKILL.md', + skills: ['testsprite-verify'], + }); + }); + it('text mode emits one line per result with padded columns', async () => { + const fs = makeMemFs(); + const stdout: string[] = []; await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude,cursor'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, + { ...COMMON, output: 'text', target: ['codex'], force: false }, + { ...deps(fs), stdout: (l: string) => stdout.push(l) }, ); - - expect(store.has(path.resolve(CWD, TARGETS.claude.path))).toBe(true); - expect(store.has(path.resolve(CWD, TARGETS.cursor.path))).toBe(true); + const line = stdout + .join('') + .split('\n') + .find(l => l.includes('codex'))!; + expect(line).toContain('codex'); + expect(line).toContain('canonical'); + expect(line).toContain('.agents/skills/testsprite-verify/SKILL.md'); }); +}); - it('mixed: one blocked, one fresh — fresh is still written, overall exits 6', async () => { - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - // Pre-seed claude with different content - const claudeAbs = path.resolve(CWD, TARGETS.claude.path); - seedFile(claudeAbs, 'DIFFERENT CONTENT'); - - let thrown: unknown; - try { - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude', 'cursor'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; +describe('runInstall — --dir override', () => { + it('writes under --dir instead of cwd', async () => { + const fs = makeMemFs(); + const other = path.resolve('/other-dir'); + await runInstall( + { ...COMMON, target: ['codex'], force: false, dir: other }, + { ...deps(fs), cwd: ROOT }, + ); + expect(fs.files.has(path.resolve(other, '.agents/skills/testsprite-verify/SKILL.md'))).toBe( + true, + ); + }); +}); + +describe('runInstall — landing path for every target', () => { + it('universal targets write canonical only; every other target symlinks to it', async () => { + for (const id of Object.keys(TARGETS) as AgentTarget[]) { + const fs = makeMemFs(); + const res = await runInstallJson(fs, { target: [id], skills: ['testsprite-verify'] }); + const row = res[0]!; + expect(row.path).toBe(pathFor(id, 'testsprite-verify')); + // The canonical source of truth is always written. + expect(fs.files.has(canonicalPath('testsprite-verify'))).toBe(true); + if (TARGETS[id]!.universal) { + expect(row.mode).toBe('canonical'); + expect(fs.symlinkCalls).toHaveLength(0); + } else { + expect(row.mode).toBe('symlink'); + expect(fs.symlinkCalls).toHaveLength(1); + const link = path.resolve(ROOT, targetLandingDir(id, 'testsprite-verify')); + const resolved = path.resolve(path.dirname(link), fs.files.get(link)!.target!); + expect(resolved).toBe(path.resolve(ROOT, '.agents/skills/testsprite-verify')); + } } - - // Overall exit 6 - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(6); - - // cursor was still written - const cursorAbs = path.resolve(CWD, TARGETS.cursor.path); - expect(store.has(cursorAbs)).toBe(true); - - // claude not overwritten - expect(store.get(claudeAbs)).toBe('DIFFERENT CONTENT'); - - // stdout has both blocked and written - const stdoutOut = capture.stdout.join('\n'); - expect(stdoutOut).toContain('blocked'); - expect(stdoutOut).toContain('written'); }); +}); - it('de-duplicates repeated targets', async () => { - const { fs: agentFs, writeCalls } = makeMemFs(); - const { deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude', 'claude'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); +// --------------------------------------------------------------------------- +// runList +// --------------------------------------------------------------------------- - // Only one write - const claudeAbs = path.resolve(CWD, TARGETS.claude.path); - expect(writeCalls.filter(p => p === claudeAbs).length).toBe(1); +describe('runList', () => { + it('lists every target with mode/skillsDir/path matching TARGETS', async () => { + const stdout: string[] = []; + await runList({ ...COMMON }, { ...deps(makeMemFs()), stdout: (l: string) => stdout.push(l) }); + const rows = JSON.parse(stdout.join('')) as ListResult[]; + expect(rows.map(r => r.target)).toEqual(Object.keys(TARGETS)); + for (const row of rows) { + const spec = TARGETS[row.target as AgentTarget]!; + expect(row.displayName).toBe(spec.displayName); + expect(row.mode).toBe(spec.universal ? 'universal' : 'symlink'); + expect(row.skillsDir).toBe(spec.skillsDir); + expect(row.path).toBe(pathFor(row.target as AgentTarget, 'testsprite-verify')); + } }); }); // --------------------------------------------------------------------------- -// runInstall — empty target (TTY / non-TTY) +// runStatus // --------------------------------------------------------------------------- -describe('runInstall — empty target', () => { - it('non-TTY with no target defaults to claude and installs the skill file', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: [], - force: false, - }, - { cwd: CWD, fs: agentFs, isTTY: false, ...deps }, - ); +function seedCanonical(fs: ReturnType, skill: string, content: string) { + fs.seedFile(canonicalPath(skill), content); +} - const claudeAbs = path.resolve(CWD, TARGETS.claude.path); - expect(store.has(claudeAbs)).toBe(true); - expect(capture.stderr.join('\n')).toContain('defaulting to claude'); +describe('runStatus', () => { + it('empty project: no rows, exit 0', async () => { + const fs = makeMemFs(); + await expect(runStatus({ ...COMMON }, deps(fs))).resolves.toBeUndefined(); }); - it('non-TTY default writes the canonical claude content', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); + it('fresh install → ok, exit 0', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + const stdout: string[] = []; + await runStatus({ ...COMMON }, { ...deps(fs), stdout: (l: string) => stdout.push(l) }); + const rows = JSON.parse(stdout.join('')) as StatusResult[]; + const verify = rows.find(r => r.target === 'codex' && r.skill === 'testsprite-verify')!; + expect(verify.state).toBe('ok'); + }); - await runInstall( - { profile: 'default', output: 'text', debug: false, dryRun: false, target: [], force: false }, - { cwd: CWD, fs: agentFs, isTTY: false, ...deps }, + it('stale: marker hash does not match the current content → exit 1', async () => { + const fs = makeMemFs(); + // Build the canonical render but with a WRONG hash in the marker. + const badMarker = ``; + seedCanonical( + fs, + 'testsprite-verify', + renderCanonicalWithMarker('testsprite-verify', badMarker), ); - - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(CWD, relPath); - expect(store.get(abs)).toBe(content); + const stdout: string[] = []; + await expect( + runStatus({ ...COMMON }, { ...deps(fs), stdout: (l: string) => stdout.push(l) }), + ).rejects.toMatchObject({ exitCode: 1 }); + const rows = JSON.parse(stdout.join('')) as StatusResult[]; + expect(rows.find(r => r.skill === 'testsprite-verify')!.state).toBe('stale'); + }); + + it('modified: correct hash but content bytes differ → exit 1', async () => { + const fs = makeMemFs(); + const full = loadSkillFull('testsprite-verify'); + const marker = buildSkillMarker('testsprite-verify', full); + // Re-render then tamper with the body bytes (keeping the marker). + const rendered = renderCanonicalWithMarker('testsprite-verify', marker); + seedCanonical(fs, 'testsprite-verify', rendered + '\n# hand edit\n'); + await expect(runStatus({ ...COMMON }, deps(fs))).rejects.toMatchObject({ exitCode: 1 }); + }); + + it('unmarked: no marker line → exit 1', async () => { + const fs = makeMemFs(); + seedCanonical(fs, 'testsprite-verify', '---\nname: testsprite-verify\n---\n\nno marker here\n'); + await expect(runStatus({ ...COMMON }, deps(fs))).rejects.toMatchObject({ exitCode: 1 }); + }); + + it('symlinked target: ok when linked to canonical', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code'] }); + const stdout: string[] = []; + await runStatus({ ...COMMON }, { ...deps(fs), stdout: (l: string) => stdout.push(l) }); + const rows = JSON.parse(stdout.join('')) as StatusResult[]; + // claude-code reads through the symlink → canonical → ok + const claudeVerify = rows.find( + r => r.target === 'claude-code' && r.skill === 'testsprite-verify', + ); + expect(claudeVerify?.state).toBe('ok'); + }); + + it('symlinked target: modified when the link points elsewhere', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code'] }); + const link = path.resolve(ROOT, '.claude/skills/testsprite-verify'); + fs.files.set(link, { kind: 'symlink', target: '/somewhere/else' }); + await expect(runStatus({ ...COMMON }, deps(fs))).rejects.toMatchObject({ exitCode: 1 }); + }); + + it('canonical tampering reflects through the symlink as modified', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['claude-code'] }); + // Tamper the canonical body while keeping the (now-lying) marker. + const marker = buildSkillMarker('testsprite-verify', loadSkillFull('testsprite-verify')); + const tampered = renderCanonicalWithMarker('testsprite-verify', marker) + '\n# hand edit\n'; + fs.seedFile(canonicalPath('testsprite-verify'), tampered); + const stdout: string[] = []; + await expect( + runStatus({ ...COMMON }, { ...deps(fs), stdout: (l: string) => stdout.push(l) }), + ).rejects.toMatchObject({ exitCode: 1 }); + const rows = JSON.parse(stdout.join('')) as StatusResult[]; + const claudeVerify = rows.find( + r => r.target === 'claude-code' && r.skill === 'testsprite-verify', + ); + expect(claudeVerify?.state).toBe('modified'); }); - it('TTY with injected prompt returning "claude" installs claude', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); + it('directory at the canonical path → unmarked', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + fs.files.delete(canonicalPath('testsprite-verify')); + fs.seedDir(canonicalPath('testsprite-verify')); + const stdout: string[] = []; + await expect( + runStatus({ ...COMMON }, { ...deps(fs), stdout: (l: string) => stdout.push(l) }), + ).rejects.toMatchObject({ exitCode: 1 }); + const rows = JSON.parse(stdout.join('')) as StatusResult[]; + expect(rows.find(r => r.skill === 'testsprite-verify')!.state).toBe('unmarked'); + }); - const promptFn = vi.fn().mockResolvedValue('claude'); + it('rejects an empty --dir with exit 5', async () => { + const fs = makeMemFs(); + await expect(runStatus({ ...COMMON, dir: ' ' }, deps(fs))).rejects.toMatchObject({ + exitCode: 5, + }); + }); - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: [], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, isTTY: true, prompt: promptFn, ...deps }, + it('text mode renders a header + one row per artifact', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + const stdout: string[] = []; + await runStatus( + { ...COMMON, output: 'text' }, + { ...deps(fs), stdout: (l: string) => stdout.push(l) }, ); - - expect(promptFn).toHaveBeenCalledOnce(); - const claudeAbs = path.resolve(CWD, TARGETS.claude.path); - expect(store.has(claudeAbs)).toBe(true); + const text = stdout.join(''); + expect(text).toContain('TARGET'); + expect(text).toContain('STATE'); + expect(text).toContain('codex'); + expect(text).toContain('ok'); }); - it('TTY with empty prompt answer defaults to claude', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); - - const promptFn = vi.fn().mockResolvedValue(''); // empty => default to claude - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: [], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, isTTY: true, prompt: promptFn, ...deps }, - ); + it('regular file at the symlink landing → unmarked', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + fs.seedFile(landingPath('claude-code', 'testsprite-verify'), 'not a skill'); + const stdout: string[] = []; + await expect( + runStatus({ ...COMMON }, { ...deps(fs), stdout: (l: string) => stdout.push(l) }), + ).rejects.toMatchObject({ exitCode: 1 }); + const rows = JSON.parse(stdout.join('')) as StatusResult[]; + expect( + rows.find(r => r.target === 'claude-code' && r.skill === 'testsprite-verify')!.state, + ).toBe('unmarked'); + }); - const claudeAbs = path.resolve(CWD, TARGETS.claude.path); - expect(store.has(claudeAbs)).toBe(true); + it('copy-fallback dir landing is classified via its SKILL.md (ok)', async () => { + const fs = makeMemFs(); + await runInstallJson(fs, { target: ['codex'] }); + const dir = landingPath('claude-code', 'testsprite-verify'); + fs.seedDir(dir); + fs.seedFile(path.join(dir, 'SKILL.md'), canonicalContent('testsprite-verify')); + const stdout: string[] = []; + await runStatus({ ...COMMON }, { ...deps(fs), stdout: (l: string) => stdout.push(l) }); + const rows = JSON.parse(stdout.join('')) as StatusResult[]; + expect( + rows.find(r => r.target === 'claude-code' && r.skill === 'testsprite-verify')!.state, + ).toBe('ok'); }); }); // --------------------------------------------------------------------------- -// runList +// Command wiring // --------------------------------------------------------------------------- -describe('runList', () => { - it('returns all five targets with correct status', async () => { - const { capture, deps } = makeCapture(); - - await runList({ profile: 'default', output: 'text', debug: false, dryRun: false }, deps); - - const out = capture.stdout.join('\n'); - expect(out).toContain('claude'); - expect(out).toContain('cursor'); - expect(out).toContain('cline'); - expect(out).toContain('antigravity'); - expect(out).toContain('kiro'); - expect(out).toContain('codex'); - expect(out).toContain('ga'); - expect(out).toContain('experimental'); - // All matrix paths present - expect(out).toContain(TARGETS.claude.path); - expect(out).toContain(TARGETS.cursor.path); - expect(out).toContain(TARGETS.cline.path); - expect(out).toContain(TARGETS.antigravity.path); - expect(out).toContain(TARGETS.kiro.path); - expect(out).toContain(TARGETS.codex.path); +describe('createAgentCommand wiring', () => { + it('agent install with unknown target via parseAsync → exit 5', async () => { + const program = createAgentCommand(deps(makeMemFs())); + await expect( + program.parseAsync(['install', '--target', 'nope'], { from: 'user' }), + ).rejects.toMatchObject({ exitCode: 5 }); }); - it('JSON mode emits array of {target, skill, status, path, mode}', async () => { - const { capture, deps } = makeCapture(); - - await runList({ profile: 'default', output: 'json', debug: false, dryRun: false }, deps); - - const json = JSON.parse(capture.stdout.join('\n')) as ListResult[]; - expect(Array.isArray(json)).toBe(true); - // 8 targets × 2 default skills = 16 rows - expect(json).toHaveLength(16); - const targets = json.map(r => r.target); - expect(targets).toContain('claude'); - expect(targets).toContain('cursor'); - expect(targets).toContain('cline'); - expect(targets).toContain('windsurf'); - expect(targets).toContain('copilot'); - expect(targets).toContain('antigravity'); - expect(targets).toContain('kiro'); - expect(targets).toContain('codex'); - // skill field present on each row - const skills = json.map(r => r.skill); - expect(skills).toContain('testsprite-verify'); - expect(skills).toContain('testsprite-onboard'); - const claudeEntry = json.find(r => r.target === 'claude' && r.skill === 'testsprite-verify'); - expect(claudeEntry?.status).toBe('ga'); - expect(claudeEntry?.path).toBe(TARGETS.claude.path); - // codex entry has mode: managed-section - const codexEntry = json.find(r => r.target === 'codex' && r.skill === 'testsprite-verify'); - expect(codexEntry?.mode).toBe('managed-section'); + it('agent list via parseAsync → stdout contains claude-code', async () => { + const stdout: string[] = []; + const program = createAgentCommand({ + ...deps(makeMemFs()), + stdout: (l: string) => stdout.push(l), + }); + await program.parseAsync(['list'], { from: 'user' }); + expect(stdout.join('')).toContain('claude-code'); }); - it('text mode has a header row', async () => { - const { capture, deps } = makeCapture(); - - await runList({ profile: 'default', output: 'text', debug: false, dryRun: false }, deps); - - const lines = capture.stdout.join('\n').split('\n'); - expect(lines[0]).toMatch(/TARGET/i); - expect(lines[0]).toMatch(/SKILL/i); - expect(lines[0]).toMatch(/STATUS/i); - expect(lines[0]).toMatch(/PATH/i); + it('agent status via parseAsync → prints the empty-project notice on a clean project', async () => { + const stdout: string[] = []; + const program = createAgentCommand({ + ...deps(makeMemFs()), + stdout: (l: string) => stdout.push(l), + }); + await program.parseAsync(['status'], { from: 'user' }); + expect(stdout.join('')).toContain('No TestSprite skill artifacts'); }); }); // --------------------------------------------------------------------------- -// JSON vs text output shapes for install +// Sanity: SKILLS still ships the expected bodies (compile-time drift guard) // --------------------------------------------------------------------------- -describe('runInstall — output modes', () => { - it('JSON mode emits array of {target, path, action, skills}', async () => { - const { fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'json', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const json = JSON.parse(capture.stdout.join('\n')) as InstallResult[]; - expect(Array.isArray(json)).toBe(true); - expect(json[0]).toMatchObject({ - target: 'claude', - action: 'written', - skills: ['testsprite-verify'], - }); - expect(json[0]?.path).toBe(TARGETS.claude.path); - }); - - it('text mode emits one line per target with padded columns', async () => { - const { fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const line = capture.stdout.join('\n'); - // Should contain target name, action, and path - expect(line).toContain('claude'); - expect(line).toContain('written'); - expect(line).toContain(TARGETS.claude.path); - }); -}); - -// --------------------------------------------------------------------------- -// createAgentCommand wiring (parseAsync smoke tests) -// --------------------------------------------------------------------------- - -describe('createAgentCommand wiring', () => { - it('agent install with unknown target via parseAsync → throws CLIError exit 5', async () => { - const { fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); - - const command = createAgentCommand({ cwd: CWD, fs: agentFs, ...deps }); - // We need a parent for optsWithGlobals to work - const parent = new (await import('commander')).Command('testsprite'); - parent.option('--output ', 'output', 'text'); - parent.option('--profile ', 'profile', 'default'); - parent.option('--endpoint-url '); - parent.option('--debug', 'debug', false); - parent.option('--verbose', 'verbose', false); - parent.option('--dry-run', 'dry-run', false); - parent.addCommand(command); - - let thrown: unknown; - try { - await parent.parseAsync(['node', 'ts', 'agent', 'install', '--target=bogus', `--dir=${CWD}`]); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeDefined(); - const isValidationErr = - (thrown instanceof ApiError && thrown.exitCode === 5) || - (thrown instanceof CLIError && thrown.exitCode === 5); - expect(isValidationErr).toBe(true); - }); - - it('agent list via parseAsync → stdout contains all targets', async () => { - const { deps, capture } = makeCapture(); - - const command = createAgentCommand({ cwd: CWD, ...deps }); - const parent = new (await import('commander')).Command('testsprite'); - parent.option('--output ', 'output', 'text'); - parent.option('--profile ', 'profile', 'default'); - parent.option('--endpoint-url '); - parent.option('--debug', 'debug', false); - parent.option('--verbose', 'verbose', false); - parent.option('--dry-run', 'dry-run', false); - parent.addCommand(command); - - await parent.parseAsync(['node', 'ts', 'agent', 'list']); - - const out = capture.stdout.join('\n'); - expect(out).toContain('claude'); - expect(out).toContain('antigravity'); - }); -}); - -// --------------------------------------------------------------------------- -// All own-file targets installed at once -// --------------------------------------------------------------------------- - -describe('runInstall — all own-file targets', () => { - it('installs every own-file target in one invocation', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: [...OWN_FILE_TARGETS], - skills: ['testsprite-verify'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - for (const t of OWN_FILE_TARGETS) { - const abs = path.resolve(CWD, TARGETS[t].path); - expect(store.has(abs)).toBe(true); - } - - const out = capture.stdout.join('\n'); - expect(out).toContain('written'); - }); -}); - -// --------------------------------------------------------------------------- -// Dry-run for all seven own-file targets -// --------------------------------------------------------------------------- - -describe('runInstall — dry-run all own-file targets', () => { - it('writes nothing for any of the seven own-file targets (default 2 skills = 14 would-write lines)', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: true, - target: ['claude', 'cursor', 'cline', 'antigravity', 'kiro', 'windsurf', 'copilot'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - expect(store.size).toBe(0); - const stderrOut = capture.stderr.join('\n'); - // Banner appears once - expect(stderrOut).toContain('[dry-run] no files written'); - // 7 targets × 2 default skills = 14 would-write lines - const wouldWriteLines = stderrOut.split('\n').filter(l => l.includes('would write')); - expect(wouldWriteLines.length).toBe(14); - }); -}); - -// --------------------------------------------------------------------------- -// Default AgentFs adapter (real disk I/O) — covers the adapter functions -// --------------------------------------------------------------------------- - -describe('runInstall — default AgentFs (real disk)', () => { - it('writes file to real tmpdir when no fs is injected', async () => { - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-default-')); - const { capture, deps } = makeCapture(); - - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - dir: tmpRoot, - }, - { ...deps }, // no fs injected → uses defaultAgentFs - ); - - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(tmpRoot, relPath); - // File exists on real disk - expect(readFileSync(abs, 'utf8')).toBe(content); - expect(capture.stdout.join('\n')).toContain('written'); - }); - - it('skips on idempotent re-run with real disk', async () => { - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-idem-')); - const { deps: deps1 } = makeCapture(); - - // First install - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - dir: tmpRoot, - }, - { ...deps1 }, - ); - - const { capture: cap2, deps: deps2 } = makeCapture(); - // Second install → should skip - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - dir: tmpRoot, - }, - { ...deps2 }, - ); - - expect(cap2.stdout.join('\n')).toContain('skipped'); - }); - - it('blocked on real disk when file differs, --force backs up and overwrites', async () => { - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-force-')); - const { deps: deps1 } = makeCapture(); - - // First install - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - dir: tmpRoot, - }, - { ...deps1 }, - ); - - // Mutate the file - const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(tmpRoot, relPath); - const oldContent = 'MODIFIED BY USER'; - // Use default fs to write the modified content - const nodeFs = await import('node:fs/promises'); - await nodeFs.writeFile(abs, oldContent, 'utf8'); - - // --force re-run - const { capture: cap3, deps: deps3 } = makeCapture(); - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: true, - dir: tmpRoot, - }, - { ...deps3 }, - ); - - expect(cap3.stdout.join('\n')).toContain('updated'); - // .bak has old content - expect(readFileSync(`${abs}.bak`, 'utf8')).toBe(oldContent); - // File has canonical content - expect(readFileSync(abs, 'utf8')).toBe(content); - }); - - // `fs.symlinkSync` needs elevated privileges or Developer Mode on Windows - // (EPERM otherwise) — not guaranteed on hosted CI runners. The underlying - // guard (`inspectTargetPath` fail-closing via `lstat`) is exercised on - // POSIX runners; TODO(DEV-356): revisit if/when a reliable Windows - // symlink-creation path (junctions, or an elevated runner) is available. - it.skipIf(process.platform === 'win32')( - 'refuses to write through a symlinked parent dir (real disk) — exit 5', - async () => { - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-')); - const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-')); - // `.claude` is a real symlink to a directory outside the project root. - symlinkSync(outside, path.join(tmpRoot, '.claude'), 'dir'); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: false, - dir: tmpRoot, - }, - { ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - // Nothing was created through the symlink, outside --dir. - expect(existsSync(path.join(outside, 'skills'))).toBe(false); - }, - ); - - // Same Windows symlink-privilege caveat as the test above. - it.skipIf(process.platform === 'win32')( - 'refuses to overwrite a symlinked target file (real disk) with --force — exit 5', - async () => { - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-')); - const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-')); - const { path: relPath } = renderForTarget('claude', 'testsprite-verify'); - const abs = path.resolve(tmpRoot, relPath); - const nodeFs = await import('node:fs/promises'); - await nodeFs.mkdir(path.dirname(abs), { recursive: true }); - // SKILL.md is a real symlink to a file outside the project root. - const outsideFile = path.join(outsideDir, 'secret.txt'); - await nodeFs.writeFile(outsideFile, 'SECRET', 'utf8'); - symlinkSync(outsideFile, abs, 'file'); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - skills: ['testsprite-verify'], - force: true, - dir: tmpRoot, - }, - { ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - // The outside file was NOT overwritten (nor clobbered via the .bak path). - expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET'); - }, - ); -}); - -// --------------------------------------------------------------------------- -// Path safety — non-regular-file + intermediate guards -// --------------------------------------------------------------------------- - -const BASE_OPTS = { - profile: 'default' as const, - output: 'text' as const, - debug: false, - dryRun: false, - skills: ['testsprite-verify'], -}; - -describe('runInstall — path safety', () => { - it('rethrows a non-ENOENT error from fs.lstat as-is', async () => { - // inspectTargetPath lstats the first component; a non-ENOENT failure - // (e.g. EPERM) must propagate unchanged rather than be swallowed. - const permError = Object.assign(new Error('EPERM: operation not permitted'), { - code: 'EPERM', - }); - const badFs: AgentFs = { - lstat: async () => { - throw permError; - }, - readFile: async () => '', - writeFile: async () => undefined, - mkdir: async () => undefined, - }; - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: false }, - { cwd: CWD, fs: badFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBe(permError); - }); - - it('exit 5 when the landing path already exists as a directory', async () => { - const { fs: agentFs, writeCalls, seedDir } = makeMemFs(); - seedDir(path.resolve(CWD, TARGETS.claude.path)); // SKILL.md path is a directory - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toContain('not a regular file'); - expect(writeCalls.length).toBe(0); - }); - - it('--force does NOT bypass the directory-at-landing-path guard', async () => { - const { fs: agentFs, writeCalls, seedDir } = makeMemFs(); - seedDir(path.resolve(CWD, TARGETS.claude.path)); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: true }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect(writeCalls.length).toBe(0); - }); - - it('exit 5 when an intermediate path component is a regular file, not a directory', async () => { - const { fs: agentFs, writeCalls, seedFile } = makeMemFs(); - // `.claude` exists as a FILE, so `.claude/skills/...` cannot be created. - seedFile(path.resolve(CWD, '.claude'), 'oops'); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toContain('not a directory'); - expect(writeCalls.length).toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// Path safety — symlink escape (regression for the adversarial-review finding) -// --------------------------------------------------------------------------- - -describe('runInstall — symlink safety', () => { - it('refuses (exit 5) when a parent path component is a symlink', async () => { - const { fs: agentFs, writeCalls, seedSymlink } = makeMemFs(); - // A planted `.claude` symlink (e.g. -> /etc) would let writes escape --dir. - seedSymlink(path.resolve(CWD, '.claude')); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toContain('symlink'); - expect(writeCalls.length).toBe(0); - }); - - it('refuses (exit 5) when the target file is a symlink, even with --force', async () => { - const { fs: agentFs, writeCalls, seedSymlink } = makeMemFs(); - // SKILL.md itself is a symlink (e.g. -> ~/.bashrc); parents modelled as dirs. - seedSymlink(path.resolve(CWD, TARGETS.claude.path)); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: true }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toContain('symlink'); - expect(writeCalls.length).toBe(0); // never wrote a .bak nor through the link - }); - - it('dry-run: refuses (exit 5) when the target file is a symlink (parity with real install)', async () => { - const { fs: agentFs, writeCalls, seedSymlink } = makeMemFs(); - // Same planted SKILL.md symlink as the real-install case above: dry-run - // must report the same refusal the real install would, not a success. - seedSymlink(path.resolve(CWD, TARGETS.claude.path)); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: false, dryRun: true }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toContain('symlink'); - expect(writeCalls.length).toBe(0); - }); - - it('dry-run: refuses (exit 5) when a parent path component is a symlink', async () => { - const { fs: agentFs, writeCalls, seedSymlink } = makeMemFs(); - seedSymlink(path.resolve(CWD, '.claude')); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: false, dryRun: true }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toContain('symlink'); - expect(writeCalls.length).toBe(0); - }); - - it('does not write through a symlinked .bak slot — backs up to a numbered slot', async () => { - const { store, fs: agentFs, seedFile, seedSymlink } = makeMemFs(); - const abs = path.resolve(CWD, TARGETS.claude.path); - seedFile(abs, 'DIFFERENT CONTENT'); // real file that differs -> overwrite - seedSymlink(`${abs}.bak`); // a planted symlink at the default .bak slot - const { content } = renderForTarget('claude', 'testsprite-verify'); - const { deps } = makeCapture(); - - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: true }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - // Exclusive create never writes through the symlink slot; backup -> .bak.1. - expect(store.has(`${abs}.bak`)).toBe(false); // symlink slot untouched - expect(store.get(`${abs}.bak.1`)).toBe('DIFFERENT CONTENT'); - expect(store.get(abs)).toBe(content); - }); -}); - -// --------------------------------------------------------------------------- -// Backup collision (regression for round-2 finding: don't clobber backups) -// --------------------------------------------------------------------------- - -describe('runInstall — backup collision', () => { - it('--force does not clobber a pre-existing regular .bak; uses .bak.1', async () => { - const { store, fs: agentFs, seedFile } = makeMemFs(); - const abs = path.resolve(CWD, TARGETS.claude.path); - seedFile(abs, 'CURRENT EDIT'); - seedFile(`${abs}.bak`, 'PRECIOUS USER BACKUP'); // a backup the user already has - const { content } = renderForTarget('claude', 'testsprite-verify'); - const { capture, deps } = makeCapture(); - - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: true }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - expect(store.get(`${abs}.bak`)).toBe('PRECIOUS USER BACKUP'); // preserved - expect(store.get(`${abs}.bak.1`)).toBe('CURRENT EDIT'); // our backup - expect(store.get(abs)).toBe(content); - // The actual backup path is reported to the user. - expect(capture.stderr.join('\n')).toContain('.bak.1'); - }); - - it('fresh install: a target that races in after the path check → exit 6', async () => { - const eexist = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }); - const racyFs: AgentFs = { - lstat: async () => null, // the path check sees nothing - readFile: async () => '', - writeFile: async (_p: string, _d: string, o?: { exclusive?: boolean }) => { - if (o?.exclusive) throw eexist; // but it exists by the time we write - }, - mkdir: async () => undefined, - }; - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['claude'], force: false }, - { cwd: CWD, fs: racyFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(6); - }); -}); - -// --------------------------------------------------------------------------- -// runInstall — managed-section (codex target) -// --------------------------------------------------------------------------- - -describe('runInstall — codex managed-section: create (AGENTS.md absent)', () => { - it('creates AGENTS.md with just the section when file is absent', async () => { - const { store, fs: agentFs, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const abs = path.resolve(CWD, TARGETS.codex.path); - expect(store.has(abs)).toBe(true); - const written = store.get(abs)!; - // Section sentinels are present - expect(written).toContain(MANAGED_SECTION_BEGIN); - expect(written).toContain(MANAGED_SECTION_END); - // action reported - const out = capture.stdout.join('\n'); - expect(out).toContain('section-installed'); - expect(writeCalls.some(p => p === abs)).toBe(true); - }); -}); - -describe('runInstall — codex managed-section: append (AGENTS.md exists, no sentinels)', () => { - it('appends the section to existing AGENTS.md content', async () => { - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - const existingContent = '# My Project\n\nSome existing agent notes.\n'; - seedFile(agentsAbs, existingContent); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const written = store.get(agentsAbs)!; - // Original content preserved - expect(written).toContain('# My Project'); - expect(written).toContain('Some existing agent notes.'); - // Section appended - expect(written).toContain(MANAGED_SECTION_BEGIN); - expect(written).toContain(MANAGED_SECTION_END); - // Action reported as section-installed (first-time append = install) - expect(capture.stdout.join('\n')).toContain('section-installed'); - }); - - it('inserts a blank line separator when existing content has no trailing blank line', async () => { - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // No trailing newline after last line - seedFile(agentsAbs, '# Existing\n'); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const written = store.get(agentsAbs)!; - // Separator between existing and section - const beginIdx = written.indexOf(MANAGED_SECTION_BEGIN); - const before = written.slice(0, beginIdx); - // Should end with two newlines (one from the original, one separator) - expect(before.endsWith('\n\n')).toBe(true); - }); -}); - -describe('runInstall — codex managed-section: replace (sentinels already present)', () => { - it('replaces the section when content differs, preserves surrounding text', async () => { - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // Pre-seed with an outdated section - const oldSection = `${MANAGED_SECTION_BEGIN}\nOLD CONTENT\n${MANAGED_SECTION_END}\n`; - const beforeText = '# My Project\n\n'; - const afterText = '\n## Other section\n'; - seedFile(agentsAbs, `${beforeText}${oldSection}${afterText}`); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const written = store.get(agentsAbs)!; - // Before-text preserved - expect(written).toContain('# My Project'); - // After-text preserved - expect(written).toContain('## Other section'); - // Old content replaced - expect(written).not.toContain('OLD CONTENT'); - // New section present - expect(written).toContain(MANAGED_SECTION_BEGIN); - expect(written).toContain(MANAGED_SECTION_END); - // Action reported - expect(capture.stdout.join('\n')).toContain('section-updated'); - }); -}); - -describe('runInstall — codex managed-section: unchanged (byte-identical section)', () => { - it('reports section-unchanged and makes no write when content matches', async () => { - const { store, fs: agentFs, seedFile, writeCalls } = makeMemFs(); - const { deps: deps1 } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - - // First install to get the canonical section bytes - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps1 }, - ); - - // Re-read from the store to get the canonical content - const canonicalContent = store.get(agentsAbs)!; - // Re-seed to simulate no change - seedFile(agentsAbs, canonicalContent); - - const writesBeforeSecondInstall = writeCalls.length; - - const { capture: cap2, deps: deps2 } = makeCapture(); - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps2 }, - ); - - expect(writeCalls.length).toBe(writesBeforeSecondInstall); - expect(cap2.stdout.join('\n')).toContain('section-unchanged'); - // Content untouched - expect(store.get(agentsAbs)).toBe(canonicalContent); - }); -}); - -describe('runInstall — codex managed-section: corrupt sentinel → exit 5', () => { - it('throws exit 5 when BEGIN is present but END is missing', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const { deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // Only BEGIN, no END - seedFile( - agentsAbs, - `# My Project\n\n${MANAGED_SECTION_BEGIN}\n# Partial section without end\n`, - ); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toMatch(/malformed|corrupt|sentinel/i); - }); - - it('throws exit 5 when END appears before BEGIN', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const { deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // END before BEGIN — reversed sentinels - seedFile( - agentsAbs, - `# My Project\n\n${MANAGED_SECTION_END}\nsome content\n${MANAGED_SECTION_BEGIN}\n`, - ); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - }); -}); - -describe('runInstall — codex managed-section: --dry-run', () => { - it('writes nothing; reports managed section in dry-run output', async () => { - const { store, fs: agentFs, writeCalls, mkdirCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { ...BASE_OPTS, dryRun: true, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - expect(store.size).toBe(0); - expect(writeCalls.length).toBe(0); - expect(mkdirCalls.length).toBe(0); - - const stderrOut = capture.stderr.join('\n'); - expect(stderrOut).toContain('[dry-run] no files written'); - expect(stderrOut).toContain('managed section'); - // stdout has dry-run action - expect(capture.stdout.join('\n')).toContain('dry-run'); - }); - - it('dry-run + AGENTS.md already present: still writes nothing', async () => { - const { store, fs: agentFs, seedFile, writeCalls } = makeMemFs(); - const { deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - seedFile(agentsAbs, '# Existing notes\n'); - const contentBefore = store.get(agentsAbs); - - await runInstall( - { ...BASE_OPTS, dryRun: true, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - // Content unchanged - expect(store.get(agentsAbs)).toBe(contentBefore); - expect(writeCalls.length).toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// [P2] AGENTS.md 32 KiB budget warning -// Codex has a documented ~32 KiB load budget per AGENTS.md. When the would-be -// file content exceeds that threshold we emit a [warn] to stderr. We still -// write (warn, not refusal). Small files must not trigger the warning. -// --------------------------------------------------------------------------- - -describe('[codex-P2] AGENTS.md 32 KiB budget warning', () => { - it('no warning when existing file is small (well under 32 KiB)', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - seedFile(agentsAbs, '# Small project notes\n'); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - // No budget warning should appear - const warnLines = capture.stderr.filter(l => l.includes('[warn]') && l.includes('KiB')); - expect(warnLines).toHaveLength(0); - }); - - it('emits [warn] on stderr when resulting file would exceed 32 KiB budget', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // Seed a near-full existing file: 31.5 KiB of content (just under the budget on its own). - // After appending the ~3.5 KiB codex section the total will exceed 32 KiB. - const nearFullContent = '# Project notes\n' + 'x'.repeat(AGENTS_MD_CODEX_BUDGET_BYTES - 512); - seedFile(agentsAbs, nearFullContent); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - // At least one stderr line must be a budget warning - const warnLines = capture.stderr.filter(l => l.includes('[warn]') && l.includes('KiB')); - expect(warnLines.length).toBeGreaterThanOrEqual(1); - - // The warning must mention the resulting byte size and the 32 KiB budget - const warnText = warnLines[0]!; - expect(warnText).toMatch(/\d+ bytes/); - expect(warnText).toContain('32 KiB'); - - // The write must still happen (warning is advisory, not a refusal) - expect(capture.stderr.some(l => l.includes('section-'))).toBe(false); // action only in stdout - expect(capture.stdout.join('\n')).toMatch(/section-installed|section-updated/); - }); -}); - -// --------------------------------------------------------------------------- -// [codex-P2] Sentinel standalone-line matching — Finding 1 hardening -// -// Sentinels must only be recognised when they appear as STANDALONE lines. -// An inline mention inside prose (e.g. documentation quoting the marker) must -// NOT be treated as a managed block. Duplicate standalone pairs must be -// rejected as corrupt (exit 5). -// --------------------------------------------------------------------------- - -describe('[codex-P2] sentinel standalone-line matching', () => { - it('inline-only mention → treated as no sentinels → appends section', async () => { - // The file mentions the sentinel string INSIDE a prose paragraph, not as a - // standalone line. The classifier must see this as "no sentinels" and append. - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // Inline mention: sentinel string is embedded in a longer sentence. - const inlineMentionContent = - '# My Project\n\n' + - `You can identify our managed block by looking for the marker ${MANAGED_SECTION_BEGIN} in the text.\n` + - `Likewise the closing marker is ${MANAGED_SECTION_END} but both are only in this paragraph.\n`; - seedFile(agentsAbs, inlineMentionContent); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const written = store.get(agentsAbs)!; - // Original prose preserved - expect(written).toContain('You can identify our managed block'); - // Section was appended (not replacing the prose) - const beginCount = written.split(MANAGED_SECTION_BEGIN).length - 1; - const endCount = written.split(MANAGED_SECTION_END).length - 1; - // The inline mention + the newly written standalone sentinel = 2 occurrences each - expect(beginCount).toBe(2); - expect(endCount).toBe(2); - // Action must be 'section-installed' (first-time append) - expect(capture.stdout.join('\n')).toContain('section-installed'); - }); - - it('inline mention + real standalone block → replaces only the standalone block', async () => { - // The file has one inline mention AND a real standalone sentinel pair. - // Only the standalone pair is the managed block; the inline mention is - // left untouched. - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - const oldSection = `${MANAGED_SECTION_BEGIN}\nOLD MANAGED CONTENT\n${MANAGED_SECTION_END}\n`; - const content = - '# My Project\n\n' + - // Inline mention (NOT a standalone line — embedded in a paragraph) - `See the marker ${MANAGED_SECTION_BEGIN} for details.\n\n` + - oldSection + - '\n## Other section\n'; - seedFile(agentsAbs, content); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const written = store.get(agentsAbs)!; - // Inline prose mention preserved - expect(written).toContain(`See the marker ${MANAGED_SECTION_BEGIN} for details.`); - // Old content replaced - expect(written).not.toContain('OLD MANAGED CONTENT'); - // Surrounding sections preserved - expect(written).toContain('## Other section'); - // Action must be 'section-updated' - expect(capture.stdout.join('\n')).toContain('section-updated'); - }); - - it('duplicate standalone BEGIN lines → corrupt → exit 5', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const { deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // Two standalone BEGIN lines → ambiguous; must be rejected as corrupt. - const content = - `${MANAGED_SECTION_BEGIN}\nSection content\n${MANAGED_SECTION_END}\n\n` + - `${MANAGED_SECTION_BEGIN}\nDuplicate second block\n${MANAGED_SECTION_END}\n`; - seedFile(agentsAbs, content); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toMatch(/malformed|corrupt|sentinel|ambiguous/i); - }); - - it('duplicate standalone END lines → corrupt → exit 5', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const { deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // One BEGIN but two standalone END lines. - const content = - `${MANAGED_SECTION_BEGIN}\nContent\n${MANAGED_SECTION_END}\n` + - `Stray content\n${MANAGED_SECTION_END}\n`; - seedFile(agentsAbs, content); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - }); - - it('CRLF file with real standalone block → replaces correctly', async () => { - // Regression: sentinel lines in a CRLF file have a trailing \r that must - // be stripped before comparison so they still match. - const { store, fs: agentFs, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // Build a CRLF file: each line ends with \r\n. - const oldSection = `${MANAGED_SECTION_BEGIN}\r\nOLD CRLF CONTENT\r\n${MANAGED_SECTION_END}\r\n`; - const crlfContent = `# My Project\r\n\r\n${oldSection}\r\n## Other\r\n`; - seedFile(agentsAbs, crlfContent); - - await runInstall( - { ...BASE_OPTS, target: ['codex'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const written = store.get(agentsAbs)!; - // Old CRLF content must have been replaced - expect(written).not.toContain('OLD CRLF CONTENT'); - // After-section preserved - expect(written).toContain('## Other'); - // Action reported - expect(capture.stdout.join('\n')).toContain('section-updated'); - }); -}); - -// --------------------------------------------------------------------------- -// [B-E2E-04] Fix 4 regression — codex --dry-run warns when existing file -// + section would exceed the 32 KiB Codex budget -// --------------------------------------------------------------------------- - -describe('[B-E2E-04] codex --dry-run: over-budget warning (Fix 4 regression)', () => { - const BASE_OPTS_DRY = { - profile: 'default' as const, - output: 'text' as const, - debug: false, - dryRun: true, - force: false, - }; - - it('emits [warn] on stderr when existing AGENTS.md + section > 32 KiB; writes nothing', async () => { - const { store, fs: agentFs, seedFile, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - - // Seed an AGENTS.md that is large enough to push the total over budget. - // AGENTS_MD_CODEX_BUDGET_BYTES = 32768. - // We use 31 KiB of existing content so that existing + section > 32 KiB. - const bigExisting = '#'.repeat(31 * 1024); - seedFile(agentsAbs, bigExisting); - - await runInstall({ ...BASE_OPTS_DRY, target: ['codex'] }, { cwd: CWD, fs: agentFs, ...deps }); - - // Must not write anything in dry-run - expect(writeCalls.length).toBe(0); - expect(store.get(agentsAbs)).toBe(bigExisting); // unchanged - - // Must emit a [warn] about the budget - const stderrOut = capture.stderr.join('\n'); - expect(stderrOut).toMatch(/\[warn\].*bytes.*Codex/i); - }); - - it('does NOT warn when AGENTS.md is absent (fresh install would be under budget)', async () => { - const { fs: agentFs, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall({ ...BASE_OPTS_DRY, target: ['codex'] }, { cwd: CWD, fs: agentFs, ...deps }); - - // Dry-run = no writes - expect(writeCalls.length).toBe(0); - - // No over-budget warning when file is absent (section alone is ≤ 32 KiB) - const stderrOut = capture.stderr.join('\n'); - expect(stderrOut).not.toMatch(/\[warn\].*Codex/i); - }); - - it('reports AGENTS_MD_CODEX_BUDGET_BYTES constant publicly (sanity)', () => { - expect(AGENTS_MD_CODEX_BUDGET_BYTES).toBe(32768); - }); -}); - -// --------------------------------------------------------------------------- -// [P2 regression] codex --dry-run: symlinked AGENTS.md must fail-close (exit 5) -// before any readFile call — dry-run must apply the same symlink guard as the -// real install path (inspectTargetPath runs first in both modes). -// --------------------------------------------------------------------------- - -describe('[P2] codex --dry-run: symlink fail-close (same guard as real install)', () => { - const BASE_OPTS_DRY = { - profile: 'default' as const, - output: 'text' as const, - debug: false, - dryRun: true, - force: false, - }; - - it('symlinked AGENTS.md + --dry-run → exit 5, readFile never called', async () => { - // The final AGENTS.md path is a symlink. In dry-run the old code called - // readFile BEFORE inspectTargetPath, so the symlink was followed silently. - // The fix runs inspectTargetPath first in both modes; this test is the - // regression gate. - const { fs: agentFs, writeCalls, seedSymlink } = makeMemFs(); - const { deps } = makeCapture(); - - // Seed AGENTS.md as a symlink at the codex landing path. - seedSymlink(path.resolve(CWD, TARGETS.codex.path)); - - // Also confirm readFile is never called by using a custom fs - let readFileCalled = false; - const spyFs: AgentFs = { - ...agentFs, - async readFile(p: string) { - readFileCalled = true; - return agentFs.readFile(p); - }, - }; - - let thrown: unknown; - try { - await runInstall({ ...BASE_OPTS_DRY, target: ['codex'] }, { cwd: CWD, fs: spyFs, ...deps }); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - expect((thrown as CLIError).message).toContain('symlink'); - expect(writeCalls.length).toBe(0); - // readFile must never have been called on the symlinked path - expect(readFileCalled).toBe(false); - }); - - it('regular AGENTS.md + --dry-run: lstat check passes, readFile called normally', async () => { - // Confirm normal operation is not disrupted: a regular AGENTS.md file - // is lstat-checked (not a symlink), then readFile runs for the budget check. - const { fs: agentFs, writeCalls, seedFile } = makeMemFs(); - const { capture, deps } = makeCapture(); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - // Seed a regular file (not a symlink) - seedFile(agentsAbs, '# My Project\nSome content.\n'); - - await runInstall({ ...BASE_OPTS_DRY, target: ['codex'] }, { cwd: CWD, fs: agentFs, ...deps }); - - // No writes (dry-run) - expect(writeCalls.length).toBe(0); - // Stdout reports dry-run action - expect(capture.stdout.join('\n')).toContain('dry-run'); - }); -}); - -// --------------------------------------------------------------------------- -// [P3 round-2] codex --dry-run budget: measure the COMPOSED result, not -// existing+section (replace must not double-count the old block), and surface -// non-ENOENT read failures instead of treating them as absence. -// --------------------------------------------------------------------------- - -describe('[P3 round-2] codex --dry-run: composed-size precision + read-failure surfacing', () => { - const BASE_OPTS_DRY = { - profile: 'default' as const, - output: 'text' as const, - debug: false, - dryRun: true, - force: false, - }; - - it('replace path: no warn when the composed file is under budget even though existing+section is over', async () => { - const { store, fs: agentFs, seedFile, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - - // Old managed section with a 6 KiB body + 25 KiB of user prose. - // existing (~31.9 KiB) + new section (~6.2 KiB: verify codex body + - // onboard aggregate) > 32 KiB → the OLD formula would warn; the composed - // replace result (25 KiB + new section) is under budget → no warn expected. - const oldSection = `${MANAGED_SECTION_BEGIN}\n${'o'.repeat(6 * 1024)}\n${MANAGED_SECTION_END}\n`; - const userProse = `# My own AGENTS.md\n${'u'.repeat(25 * 1024)}\n`; - seedFile(agentsAbs, `${userProse}\n${oldSection}`); - - await runInstall({ ...BASE_OPTS_DRY, target: ['codex'] }, { cwd: CWD, fs: agentFs, ...deps }); - - expect(writeCalls.length).toBe(0); - expect(store.get(agentsAbs)).toBe(`${userProse}\n${oldSection}`); - expect(capture.stderr.join('\n')).not.toMatch(/\[warn\].*Codex/i); - }); - - it('corrupt sentinel + --dry-run → exit 5 (same outcome the real install would report), no writes', async () => { - const { fs: agentFs, seedFile, writeCalls } = makeMemFs(); - const { deps } = makeCapture(); - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - seedFile(agentsAbs, `user content\n${MANAGED_SECTION_BEGIN}\nno end sentinel here\n`); - - await expect( - runInstall({ ...BASE_OPTS_DRY, target: ['codex'] }, { cwd: CWD, fs: agentFs, ...deps }), - ).rejects.toMatchObject({ exitCode: 5 }); - expect(writeCalls.length).toBe(0); - }); - - it('non-ENOENT read failure (EACCES) on --dry-run → exit 5, not silent success', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const { deps } = makeCapture(); - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - seedFile(agentsAbs, 'unreadable'); - - const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); - const realRead = agentFs.readFile.bind(agentFs); - agentFs.readFile = async (p: string) => - p === agentsAbs ? Promise.reject(denied) : realRead(p); - - await expect( - runInstall({ ...BASE_OPTS_DRY, target: ['codex'] }, { cwd: CWD, fs: agentFs, ...deps }), - ).rejects.toMatchObject({ exitCode: 5 }); - }); -}); - -// --------------------------------------------------------------------------- -// Multi-skill behavior — new tests covering the SKILLS registry refactor -// --------------------------------------------------------------------------- - -describe('runInstall — multi-skill: default install writes BOTH skills (own-file target)', () => { - it('default claude install produces 2 results: verify + onboard, both action:written', async () => { - const { store, fs: agentFs, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - // No skills option → DEFAULT_SKILLS (both verify and onboard); use json output to parse results - await runInstall( - { - ...BASE_OPTS, - output: 'json', - dryRun: false, - skills: undefined, - target: ['claude'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - // Both skill files should be written - const verifyAbs = path.resolve(CWD, pathFor('claude', 'testsprite-verify')); - const onboardAbs = path.resolve(CWD, pathFor('claude', 'testsprite-onboard')); - expect(store.has(verifyAbs)).toBe(true); - expect(store.has(onboardAbs)).toBe(true); - - // Both writes recorded - expect(writeCalls).toContain(verifyAbs); - expect(writeCalls).toContain(onboardAbs); - - // JSON output contains 2 results - const json = JSON.parse(capture.stdout.join('\n')) as InstallResult[]; - - // There must be a result for testsprite-verify - const verifyResult = json.find(r => r.skills.includes('testsprite-verify')); - expect(verifyResult).toBeDefined(); - expect(verifyResult?.target).toBe('claude'); - expect(verifyResult?.action).toBe('written'); - expect(verifyResult?.path).toBe(pathFor('claude', 'testsprite-verify')); - - // There must be a result for testsprite-onboard - const onboardResult = json.find(r => r.skills.includes('testsprite-onboard')); - expect(onboardResult).toBeDefined(); - expect(onboardResult?.target).toBe('claude'); - expect(onboardResult?.action).toBe('written'); - expect(onboardResult?.path).toBe(pathFor('claude', 'testsprite-onboard')); - }); - - it('onboard skill file content contains the onboard H1 heading', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); - - await runInstall( - { ...BASE_OPTS, dryRun: false, skills: undefined, target: ['claude'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const onboardAbs = path.resolve(CWD, pathFor('claude', 'testsprite-onboard')); - const content = store.get(onboardAbs)!; - // The onboard skill body must contain its H1 - expect(content).toContain('# TestSprite: onboard a repo'); - }); - - it('each result has skills:[skill] (one-element array) for own-file targets', async () => { - const { fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - ...BASE_OPTS, - output: 'json', - dryRun: false, - skills: undefined, - target: ['claude'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const json = JSON.parse(capture.stdout.join('\n')) as InstallResult[]; - expect(json.length).toBe(2); - for (const r of json) { - expect(Array.isArray(r.skills)).toBe(true); - expect(r.skills.length).toBe(1); - } - }); - - it('text output FORMAT is unchanged: one row per result (target padEnd(12) action padEnd(12) path), 2 rows for default install', async () => { - const { fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - ...BASE_OPTS, - dryRun: false, - output: 'text', - skills: undefined, - target: ['claude'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const lines = capture.stdout.join('\n').split('\n').filter(Boolean); - // 2 results → 2 lines - expect(lines.length).toBe(2); - // Each line has target, action, and path - for (const line of lines) { - expect(line).toContain('claude'); - expect(line).toContain('written'); - } - // One line for each skill path - expect(lines.some(l => l.includes(pathFor('claude', 'testsprite-verify')))).toBe(true); - expect(lines.some(l => l.includes(pathFor('claude', 'testsprite-onboard')))).toBe(true); - }); -}); - -describe('runInstall — multi-skill: default codex install aggregates BOTH skills in ONE section', () => { - it('creates ONE AGENTS.md managed section containing verify H1 AND onboard one-liner', async () => { - const { store, fs: agentFs, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - // Default (no skills opt) → both skills; json output for result parsing - await runInstall( - { - ...BASE_OPTS, - output: 'json', - dryRun: false, - skills: undefined, - target: ['codex'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const agentsAbs = path.resolve(CWD, TARGETS.codex.path); - expect(writeCalls).toContain(agentsAbs); - - const written = store.get(agentsAbs)!; - // Exactly ONE BEGIN sentinel (not two) - const beginCount = written.split(MANAGED_SECTION_BEGIN).length - 1; - expect(beginCount).toBe(1); - - // Section contains the verify H1 - expect(written).toContain('# TestSprite Verification Loop'); - - // Section contains the onboard one-liner - expect(written).toContain('**First-time setup:**'); - - // The result is a single codex result with skills = both - const json = JSON.parse(capture.stdout.join('\n')) as InstallResult[]; - expect(json.length).toBe(1); - const codexResult = json[0]!; - expect(codexResult.target).toBe('codex'); - expect(codexResult.action).toBe('section-installed'); - expect(codexResult.skills).toContain('testsprite-verify'); - expect(codexResult.skills).toContain('testsprite-onboard'); - }); - - it('single-skill codex install produces a section byte-identical to old single-skill behavior', async () => { - // A ['testsprite-verify']-only codex install should produce the same section - // content as the pre-refactor behavior (single-skill codex body). - const { store: storeA, fs: fsA } = makeMemFs(); - const { deps: depsA } = makeCapture(); - await runInstall( - { - ...BASE_OPTS, - dryRun: false, - skills: ['testsprite-verify'], - target: ['codex'], - force: false, - }, - { cwd: CWD, fs: fsA, ...depsA }, - ); - const verifyOnlyContent = storeA.get(path.resolve(CWD, TARGETS.codex.path))!; - - // The section must contain the verify H1 but NOT the onboard line - expect(verifyOnlyContent).toContain('# TestSprite Verification Loop'); - expect(verifyOnlyContent).not.toContain('**First-time setup:**'); - // Exactly one BEGIN sentinel - expect(verifyOnlyContent.split(MANAGED_SECTION_BEGIN).length - 1).toBe(1); - }); -}); - -describe('runInstall — multi-skill: --skill subset installs only the named skill', () => { - it('skills:[testsprite-onboard] installs ONLY the onboard file (1 result, 1 write)', async () => { - const { store, fs: agentFs, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - ...BASE_OPTS, - output: 'json', - dryRun: false, - skills: ['testsprite-onboard'], - target: ['claude'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const onboardAbs = path.resolve(CWD, pathFor('claude', 'testsprite-onboard')); - const verifyAbs = path.resolve(CWD, pathFor('claude', 'testsprite-verify')); - - // Only onboard written; verify NOT written - expect(store.has(onboardAbs)).toBe(true); - expect(store.has(verifyAbs)).toBe(false); - expect(writeCalls).toContain(onboardAbs); - expect(writeCalls).not.toContain(verifyAbs); - - // Exactly 1 result - const json = JSON.parse(capture.stdout.join('\n')) as InstallResult[]; - expect(json.length).toBe(1); - expect(json[0]?.skills).toEqual(['testsprite-onboard']); - expect(json[0]?.action).toBe('written'); - }); - - it('skills:[testsprite-verify] installs ONLY the verify file (1 result)', async () => { - const { store, fs: agentFs } = makeMemFs(); - const { capture, deps } = makeCapture(); - - await runInstall( - { - ...BASE_OPTS, - output: 'json', - dryRun: false, - skills: ['testsprite-verify'], - target: ['claude'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const verifyAbs = path.resolve(CWD, pathFor('claude', 'testsprite-verify')); - const onboardAbs = path.resolve(CWD, pathFor('claude', 'testsprite-onboard')); - - expect(store.has(verifyAbs)).toBe(true); - expect(store.has(onboardAbs)).toBe(false); - - const json = JSON.parse(capture.stdout.join('\n')) as InstallResult[]; - expect(json.length).toBe(1); - expect(json[0]?.skills).toEqual(['testsprite-verify']); - }); -}); - -describe('runInstall — multi-skill: unknown --skill exits 5', () => { - it('skills:[bogus] → localValidationError exit 5 with documented message', async () => { - const { fs: agentFs, writeCalls } = makeMemFs(); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { ...BASE_OPTS, dryRun: false, skills: ['bogus'], target: ['claude'], force: false }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeInstanceOf(ApiError); - expect((thrown as ApiError).exitCode).toBe(5); - // The nextAction must contain the exact documented message format - const nextAction = (thrown as ApiError).nextAction ?? ''; - expect(nextAction).toContain('unknown skill "bogus"'); - expect(nextAction).toContain('testsprite-verify'); - expect(nextAction).toContain('testsprite-onboard'); - // Nothing written - expect(writeCalls.length).toBe(0); - }); - - it('unknown skill via createAgentCommand parseAsync → exit 5', async () => { - const { fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); - - const command = createAgentCommand({ cwd: CWD, fs: agentFs, ...deps }); - const parent = new (await import('commander')).Command('testsprite'); - parent.option('--output ', 'output', 'text'); - parent.option('--profile ', 'profile', 'default'); - parent.option('--endpoint-url '); - parent.option('--debug', 'debug', false); - parent.option('--verbose', 'verbose', false); - parent.option('--dry-run', 'dry-run', false); - parent.addCommand(command); - - let thrown: unknown; - try { - await parent.parseAsync([ - 'node', - 'ts', - 'agent', - 'install', - '--target=claude', - '--skill=bogus', - `--dir=${CWD}`, - ]); - } catch (err) { - thrown = err; - } - - expect(thrown).toBeDefined(); - const isValidationErr = - (thrown instanceof ApiError && thrown.exitCode === 5) || - (thrown instanceof CLIError && thrown.exitCode === 5); - expect(isValidationErr).toBe(true); - }); -}); - -describe('runInstall — multi-skill: multi-target own-file with default skills', () => { - it('default install to claude + cursor writes 4 files (2 targets × 2 skills)', async () => { - const { store, fs: agentFs, writeCalls } = makeMemFs(); - const { capture, deps } = makeCapture(); - - // No skills → default both; json output for result parsing - await runInstall( - { - ...BASE_OPTS, - output: 'json', - dryRun: false, - skills: undefined, - target: ['claude', 'cursor'], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const expectedPaths = [ - path.resolve(CWD, pathFor('claude', 'testsprite-verify')), - path.resolve(CWD, pathFor('claude', 'testsprite-onboard')), - path.resolve(CWD, pathFor('cursor', 'testsprite-verify')), - path.resolve(CWD, pathFor('cursor', 'testsprite-onboard')), - ]; - - for (const p of expectedPaths) { - expect(store.has(p)).toBe(true); - expect(writeCalls).toContain(p); - } - - const json = JSON.parse(capture.stdout.join('\n')) as InstallResult[]; - expect(json.length).toBe(4); - expect(json.every(r => r.action === 'written')).toBe(true); - }); -}); - -describe('runInstall — SKILLS registry / DEFAULT_SKILLS contract', () => { - it('DEFAULT_SKILLS contains exactly testsprite-verify and testsprite-onboard', () => { - expect(DEFAULT_SKILLS).toContain('testsprite-verify'); - expect(DEFAULT_SKILLS).toContain('testsprite-onboard'); - expect(DEFAULT_SKILLS.length).toBe(2); - }); - - it('SKILLS registry contains both skills with required fields', () => { - expect(SKILLS['testsprite-verify']).toBeDefined(); - expect(SKILLS['testsprite-onboard']).toBeDefined(); - for (const [name, spec] of Object.entries(SKILLS)) { - expect(spec.name).toBe(name); - expect(typeof spec.description).toBe('string'); - expect(spec.description.length).toBeGreaterThan(0); - expect(typeof spec.bodyFile).toBe('string'); - expect(spec.codex).toBeDefined(); - } - }); - - it('ONBOARD_CODEX_LINE is the one-liner used in the codex section', () => { - expect(typeof ONBOARD_CODEX_LINE).toBe('string'); - expect(ONBOARD_CODEX_LINE).toContain('**First-time setup:**'); - }); -}); - -// --------------------------------------------------------------------------- -// runStatus — `agent status` (issue #123) -// --------------------------------------------------------------------------- - -describe('runStatus — agent status (issue #123)', () => { - const statusOpts = { - profile: 'default' as const, - output: 'json' as const, - debug: false, - dryRun: false, - }; - - /** Run status against the given fs and return the printed rows. */ - async function statusRows(agentFs: AgentFs): Promise<{ rows: StatusResult[]; thrown: unknown }> { - const { capture, deps } = makeCapture(); - let thrown: unknown; - try { - await runStatus(statusOpts, { cwd: CWD, fs: agentFs, ...deps }); - } catch (err) { - thrown = err; - } - return { rows: JSON.parse(capture.stdout.join('')) as StatusResult[], thrown }; - } - - it('nothing installed: every row is absent and the command exits 0', async () => { - const { fs: agentFs } = makeMemFs(); - const { rows, thrown } = await statusRows(agentFs); - expect(thrown).toBeUndefined(); - expect(rows).toHaveLength(Object.keys(TARGETS).length * DEFAULT_SKILLS.length); - expect(rows.every(row => row.state === 'absent')).toBe(true); - }); - - it('fresh installs read ok (own-file and codex managed section), exit 0', async () => { - const { fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude', 'codex'], - skills: [...DEFAULT_SKILLS], - force: false, - }, - { cwd: CWD, fs: agentFs, ...deps }, - ); - - const { rows, thrown } = await statusRows(agentFs); - expect(thrown).toBeUndefined(); - for (const skill of DEFAULT_SKILLS) { - expect(rows.find(r => r.target === 'claude' && r.skill === skill)?.state).toBe('ok'); - expect(rows.find(r => r.target === 'codex' && r.skill === skill)?.state).toBe('ok'); - expect(rows.find(r => r.target === 'cursor' && r.skill === skill)?.state).toBe('absent'); - } - }); - - it('stale: a marker whose hash matches an OLDER body reads stale and exits 1', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const oldBody = '# TestSprite Verification Loop\n\nold body from a previous CLI release\n'; - seedFile( - path.resolve(CWD, pathFor('claude', 'testsprite-verify')), - renderOwnFileWithMarker( - 'claude', - 'testsprite-verify', - buildSkillMarker('testsprite-verify', oldBody), - oldBody, - ), - ); - - const { rows, thrown } = await statusRows(agentFs); - expect(rows.find(r => r.target === 'claude' && r.skill === 'testsprite-verify')?.state).toBe( - 'stale', - ); - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(1); - expect((thrown as CLIError).message).toContain('need attention'); - }); - - it('modified: current hash but edited bytes reads modified and exits 1', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - const canonical = renderForTarget('claude', 'testsprite-verify').content; - seedFile( - path.resolve(CWD, pathFor('claude', 'testsprite-verify')), - `${canonical}\n\n`, - ); - - const { rows, thrown } = await statusRows(agentFs); - expect(rows.find(r => r.target === 'claude' && r.skill === 'testsprite-verify')?.state).toBe( - 'modified', - ); - expect((thrown as CLIError).exitCode).toBe(1); - }); - - it('unmarked: an artifact without a marker line reads unmarked and exits 1', async () => { - const { fs: agentFs, seedFile } = makeMemFs(); - seedFile( - path.resolve(CWD, pathFor('claude', 'testsprite-verify')), - '# hand-rolled skill file with no marker\n', - ); - - const { rows, thrown } = await statusRows(agentFs); - expect(rows.find(r => r.target === 'claude' && r.skill === 'testsprite-verify')?.state).toBe( - 'unmarked', - ); - expect((thrown as CLIError).exitCode).toBe(1); - }); - - it('rejects an explicit empty --dir (exit 5), matching the resolve-to-cwd hazard', async () => { - const { fs: agentFs } = makeMemFs(); - const { deps } = makeCapture(); - await expect( - runStatus({ ...statusOpts, dir: ' ' }, { cwd: CWD, fs: agentFs, ...deps }), - ).rejects.toMatchObject({ exitCode: 5 }); +describe('shipped skills', () => { + it('SKILLS has testsprite-verify and testsprite-onboard', () => { + expect(Object.keys(SKILLS).sort()).toEqual(['testsprite-onboard', 'testsprite-verify']); }); }); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 7d7e7c8..bee0b37 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -8,72 +8,85 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js'; import { promptText } from '../lib/prompt.js'; import { type AgentTarget, - TARGETS, - SKILLS, DEFAULT_SKILLS, - MARKER_SKILL_SEPARATOR, - pathFor, - loadSkillBodyFor, + SKILLS, + TARGETS, + acceptedTargetTokens, bodyHash12, - compactBodyFor, - buildCodexAggregate, buildSkillMarker, + canonicalSkillDir, + canonicalSkillFile, + loadSkillFull, parseSkillMarker, - renderForTarget, - renderOwnFileWithMarker, - MANAGED_SECTION_BEGIN, - MANAGED_SECTION_END, + pathFor, + renderCanonicalWithMarker, + resolveTarget, + targetLandingDir, } from '../lib/agent-targets.js'; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** - * Codex loads AGENTS.md files lazily and has a documented 32 KiB load budget - * per file. Content beyond that offset is silently truncated. We warn (but do - * not refuse to write) when a managed-section write would produce a file larger - * than this threshold so operators have early visibility. - */ -export const AGENTS_MD_CODEX_BUDGET_BYTES = 32768; // 32 KiB - // --------------------------------------------------------------------------- // Filesystem port (injectable for tests) // --------------------------------------------------------------------------- +/** lstat does not follow symlinks (null = ENOENT), so the safety walk can see them. */ export interface AgentFs { - // lstat semantics: does NOT follow symlinks (null = ENOENT). Critical for the - // path-safety walk — fs writes follow symlinks, so we must be able to see them. lstat(p: string): Promise<{ isFile: boolean; isSymbolicLink: boolean } | null>; readFile(p: string): Promise; - // exclusive: fail with EEXIST if the path already exists. O_EXCL|O_CREAT does - // not follow a final symlink, so exclusive writes never clobber or traverse a - // planted symlink — used for backups and fresh installs. + /** With `exclusive`, fail with EEXIST if the path exists (never clobbering or following a symlink). */ writeFile(p: string, data: string, opts?: { exclusive?: boolean }): Promise; mkdir(p: string): Promise; // recursive + /** Read a symlink's stored target (null when not a symlink / ENOENT). */ + readlink(p: string): Promise; + symlink(target: string, linkPath: string): Promise; + unlink(p: string): Promise; + /** Remove a file, symlink, or (recursively) a directory. */ + rm(p: string): Promise; } const defaultAgentFs: AgentFs = { - async lstat(p: string): Promise<{ isFile: boolean; isSymbolicLink: boolean } | null> { + async lstat(p) { try { const s = await fs.lstat(p); return { isFile: s.isFile(), isSymbolicLink: s.isSymbolicLink() }; } catch (err: unknown) { - if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } + if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err; } }, - async readFile(p: string): Promise { + async readFile(p) { return fs.readFile(p, 'utf8'); }, - async writeFile(p: string, data: string, opts?: { exclusive?: boolean }): Promise { + async writeFile(p, data, opts) { await fs.writeFile(p, data, { encoding: 'utf8', flag: opts?.exclusive ? 'wx' : 'w' }); }, - async mkdir(p: string): Promise { + async mkdir(p) { await fs.mkdir(p, { recursive: true }); }, + async readlink(p) { + try { + return await fs.readlink(p); + } catch (err: unknown) { + const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined; + if (code === 'ENOENT' || code === 'EINVAL') return null; // missing, or not a symlink + throw err; + } + }, + async symlink(target, linkPath) { + // Windows directory symlinks need a junction to work without Developer Mode/Admin + // rights; junctions require an absolute target. Everywhere else, store a portable + // relative target. + if (process.platform === 'win32') { + await fs.symlink(path.resolve(target), linkPath, 'junction'); + } else { + await fs.symlink(target, linkPath); + } + }, + async unlink(p) { + await fs.unlink(p); + }, + async rm(p) { + await fs.rm(p, { recursive: true, force: true }); + }, }; // --------------------------------------------------------------------------- @@ -81,19 +94,10 @@ const defaultAgentFs: AgentFs = { // --------------------------------------------------------------------------- /** - * Walk each component of `relPath` beneath `root`, refusing to traverse or - * write through a symlink. `fs.mkdir`/`writeFile` follow symlinks, so a planted - * symlink at any existing path component (e.g. `.claude` -> /etc, or the final - * `SKILL.md` -> ~/.bashrc) could place or clobber files outside `--dir`. The - * lexical containment guard in `runInstall` is a string compare and cannot see - * this; only an `lstat`-per-component walk can. Fail-closed: any symlink is - * rejected (exit 5). - * - * Returns the target's `{ isFile }` when it already exists, or `null` when it - * (or any ancestor) does not yet exist — in which case the missing tail is - * created fresh and cannot be a pre-planted symlink. A small TOCTOU window - * remains between this check and the write; that is acceptable for a local, - * single-user CLI and avoids non-portable O_NOFOLLOW / rename gymnastics. + * Walk each component of `relPath` beneath `root`, rejecting any symlink so a + * planted symlink can't redirect a write outside `--dir` (fs writes follow + * symlinks). Returns `{ isFile }` of the final component, or null if it (or an + * ancestor) doesn't exist yet. */ async function inspectTargetPath( agentFs: AgentFs, @@ -106,32 +110,55 @@ async function inspectTargetPath( for (const [i, seg] of segments.entries()) { current = path.join(current, seg); const ls = await agentFs.lstat(current); - if (ls === null) { - // This component and everything below it does not exist yet. - return null; - } + if (ls === null) return null; if (ls.isSymbolicLink) { - const shown = segments.slice(0, i + 1).join('/'); + throw new CLIError(refusesSymlink(segments.slice(0, i + 1).join('/')), 5); + } + if (i < segments.length - 1 && ls.isFile) { throw new CLIError( - `refusing to write through a symlink: "${shown}" — installing here could place files outside --dir. Remove the symlink or choose a different --dir.`, + `cannot create ${relPath}: "${segments.slice(0, i + 1).join('/')}" exists and is not a directory.`, 5, ); } - if (i < segments.length - 1 && ls.isFile) { - const shown = segments.slice(0, i + 1).join('/'); - throw new CLIError(`cannot create ${relPath}: "${shown}" exists and is not a directory.`, 5); - } finalIsFile = ls.isFile; } return { isFile: finalIsFile }; } /** - * Back up the current bytes at `abs` next to it without clobbering any existing - * backup or writing through a symlink. Exclusive create (`wx`) fails with - * EEXIST on an existing regular file OR symlink, so we walk `.bak`, `.bak.1`, - * `.bak.2`, … until a free slot is found. Returns the absolute path used. + * Walk the ancestors of `relPath`, rejecting symlinks, and return the lstat of + * the final component (or null if absent). The final component is allowed to be + * a symlink — for symlink landings that's our own link. */ +async function inspectAncestors( + agentFs: AgentFs, + root: string, + relPath: string, +): Promise<{ isFile: boolean; isSymbolicLink: boolean } | null> { + const segments = relPath.split(/[/\\]+/).filter(Boolean); + const ancestors = segments.slice(0, -1); + let current = root; + for (const [i, seg] of ancestors.entries()) { + current = path.join(current, seg); + const ls = await agentFs.lstat(current); + if (ls === null) break; + if (ls.isSymbolicLink) + throw new CLIError(refusesSymlink(ancestors.slice(0, i + 1).join('/')), 5); + if (ls.isFile) { + throw new CLIError( + `cannot create ${relPath}: "${ancestors.slice(0, i + 1).join('/')}" exists and is not a directory.`, + 5, + ); + } + } + return agentFs.lstat(path.resolve(root, ...segments)); +} + +function refusesSymlink(shown: string): string { + return `refusing to write through a symlink: "${shown}" — installing here could place files outside --dir. Remove the symlink or choose a different --dir.`; +} + +/** Back up `existing` next to `abs` without clobbering a prior backup; return the path used. */ async function writeBackup(agentFs: AgentFs, abs: string, existing: string): Promise { for (let n = 0; n < 100; n++) { const candidate = n === 0 ? `${abs}.bak` : `${abs}.bak.${n}`; @@ -139,9 +166,7 @@ async function writeBackup(agentFs: AgentFs, abs: string, existing: string): Pro await agentFs.writeFile(candidate, existing, { exclusive: true }); return candidate; } catch (err) { - if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') { - continue; - } + if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') continue; throw err; } } @@ -152,154 +177,7 @@ async function writeBackup(agentFs: AgentFs, abs: string, existing: string): Pro } // --------------------------------------------------------------------------- -// Managed-section helpers (codex target) -// --------------------------------------------------------------------------- - -/** - * Build the section block to inject (sentinels + marker + body + trailing - * newline). The provenance marker line sits just inside the BEGIN sentinel so - * `agent status` can fingerprint the section. The same skill set + CLI version - * + body always produce byte-identical output, so the classifySection - * 'unchanged' fast-path keeps working across re-installs. - * Uses \n throughout; the caller handles CRLF normalisation. - */ -function buildSection(body: string, markerLine: string): string { - return `${MANAGED_SECTION_BEGIN}\n${markerLine}\n${body.trimEnd()}\n${MANAGED_SECTION_END}\n`; -} - -/** - * Managed-section install result — what happened to AGENTS.md. - * - * 'create' — file did not exist; write the section as a new file. - * 'append' — file exists, no sentinels; append section at end. - * 'replace' — file exists with sentinels; replace section content in-place. - * 'unchanged' — file exists with sentinels and content is byte-identical. - * 'corrupt' — BEGIN sentinel without matching END; refuse to touch the file. - */ -type SectionState = - | { kind: 'create' } - | { kind: 'append'; existing: string } - | { kind: 'replace'; existing: string; before: string; after: string } - | { kind: 'unchanged' } - | { kind: 'corrupt' }; - -/** - * Inspect an existing AGENTS.md and classify the managed-section state. - * - * Sentinel-matching rules (P2 hardening): - * - Only STANDALONE sentinel lines count (a line that consists solely of the - * marker, optionally followed by whitespace/CR before the LF). This prevents - * inline mentions in prose (e.g. documentation quoting the markers) from - * being mis-classified as a managed block. - * - Multiple standalone BEGIN or END lines → ambiguous → corrupt (exit 5). - * - CRLF files are handled by stripping trailing \r from each line before - * comparison. - */ -function classifySection(existing: string, section: string): SectionState { - // Split on LF; strip trailing CR so CRLF files normalise correctly. - const lines = existing.split('\n'); - - // Collect line INDICES (0-based) where the sentinel appears as the whole line - // (trimEnd removes trailing CR and spaces). - const beginLines: number[] = []; - const endLines: number[] = []; - - for (let i = 0; i < lines.length; i++) { - const stripped = (lines[i] ?? '').trimEnd(); - if (stripped === MANAGED_SECTION_BEGIN) beginLines.push(i); - else if (stripped === MANAGED_SECTION_END) endLines.push(i); - } - - const hasBegin = beginLines.length > 0; - const hasEnd = endLines.length > 0; - - if (!hasBegin && !hasEnd) { - // No standalone sentinels — append path. - return { kind: 'append', existing }; - } - - // Duplicate standalone sentinels are ambiguous — treat as corrupt. - if (beginLines.length > 1) { - return { kind: 'corrupt' }; - } - if (endLines.length > 1) { - return { kind: 'corrupt' }; - } - - if (hasBegin && !hasEnd) { - // BEGIN present but no standalone END — corrupt. - return { kind: 'corrupt' }; - } - - if (!hasBegin && hasEnd) { - // END present but no standalone BEGIN — corrupt. - return { kind: 'corrupt' }; - } - - const beginLineIdx = beginLines[0]!; - const endLineIdx = endLines[0]!; - - if (endLineIdx < beginLineIdx) { - // END appears before BEGIN — corrupt. - return { kind: 'corrupt' }; - } - - // Both sentinels present, in the right order, with no duplicates. - // Reconstruct byte offsets from line positions so we can slice the original - // string (preserving its exact byte content for the before/after split). - // - // lineStart[i] = byte offset of the first character of line i. - let byteOffset = 0; - const lineStart: number[] = []; - for (const line of lines) { - lineStart.push(byteOffset); - byteOffset += line.length + 1; // +1 for the '\n' that split() removed - } - - const beginByteIdx = lineStart[beginLineIdx]!; - - // The END sentinel line ends at: lineStart[endLineIdx] + raw line length. - // We want to include the trailing '\n' after END when present. - const endLineRawLength = (lines[endLineIdx] ?? '').length; - const endOfEndByte = lineStart[endLineIdx]! + endLineRawLength; - // Include one trailing newline after END if present. - const charAfterEnd = existing[endOfEndByte]; - const trailingNewline = charAfterEnd === '\n' ? 1 : charAfterEnd === '\r' ? 2 : 0; - - const before = existing.slice(0, beginByteIdx); - const after = existing.slice(endOfEndByte + trailingNewline); - const currentSection = existing.slice(beginByteIdx, endOfEndByte + trailingNewline); - - if (currentSection === section) { - return { kind: 'unchanged' }; - } - - return { kind: 'replace', existing, before, after }; -} - -/** - * Compose the new AGENTS.md content for the 'append' and 'replace' paths. - * - * 'append': ensure a single blank line separator between existing content - * and the section (but don't add two blank lines if the file already ends - * with one). - * 'replace': splice the new section between `before` and `after`. - */ -function composeManagedFile( - state: SectionState & { kind: 'append' | 'replace' }, - section: string, -): string { - if (state.kind === 'append') { - const existing = state.existing; - const sep = existing.length === 0 || existing.endsWith('\n\n') ? '' : '\n'; - return `${existing}${sep}${section}`; - } - // replace - return `${state.before}${section}${state.after}`; -} - -// --------------------------------------------------------------------------- -// Deps +// Deps / result types / options // --------------------------------------------------------------------------- export interface AgentDeps { @@ -311,46 +189,197 @@ export interface AgentDeps { prompt?: (question: string) => Promise; } -// --------------------------------------------------------------------------- -// Result types -// --------------------------------------------------------------------------- - export type InstallAction = | 'written' | 'skipped' | 'blocked' | 'updated' | 'dry-run' - | 'section-installed' - | 'section-updated' - | 'section-unchanged'; + | 'copy-fallback'; export interface InstallResult { - target: AgentTarget; - path: string; // repo-relative matrix path + target: string; + /** Repo-relative SKILL.md the agent reads. */ + path: string; action: InstallAction; - /** - * Skill(s) this result covers. Own-file targets produce one result per skill - * (`[skill]`); the codex managed-section target produces ONE result whose - * section aggregates every installed skill (`[...skills]`). - */ skills: string[]; + /** 'canonical' = real file at .agents/skills; 'symlink' = linked/copied into the agent's own dir. */ + mode: 'canonical' | 'symlink'; } -// --------------------------------------------------------------------------- -// Options -// --------------------------------------------------------------------------- - type CommonOptions = FactoryCommonOptions; interface InstallOptions extends CommonOptions { target: string[]; - /** Skill subset to install; empty/absent → {@link DEFAULT_SKILLS}. */ skills?: string[]; dir?: string; force: boolean; } +/** Action ensureCanonical can return (canonical writes never copy-fallback). */ +type CanonicalAction = Exclude; + +// --------------------------------------------------------------------------- +// Canonical write + per-agent link +// --------------------------------------------------------------------------- + +/** + * Write or refresh the canonical `.agents/skills//SKILL.md` — the single + * source of truth every agent reads. Path-safe, idempotent, and backs up before + * a `--force` overwrite. + */ +async function ensureCanonical( + agentFs: AgentFs, + root: string, + skill: string, + content: string, + force: boolean, + dryRun: boolean, + stderr: (line: string) => void, +): Promise { + const relPath = canonicalSkillFile(skill); + const abs = path.resolve(root, relPath); + + const st = await inspectTargetPath(agentFs, root, relPath); + if (st !== null && !st.isFile) { + throw new CLIError(`${relPath} exists but is not a regular file — remove it and re-run.`, 5); + } + if (dryRun) return 'dry-run'; + + if (st === null) { + await agentFs.mkdir(path.dirname(abs)); + try { + await agentFs.writeFile(abs, content, { exclusive: true }); + } catch (err) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') { + throw new CLIError( + `${relPath} appeared after the path check — re-run, or pass --force to overwrite.`, + 6, + ); + } + throw err; + } + return 'written'; + } + + const existing = await agentFs.readFile(abs); + if (existing === content) return 'skipped'; + if (!force) return 'blocked'; + + const backupPath = await writeBackup(agentFs, abs, existing); + await agentFs.writeFile(abs, content); + stderr(`backed up ${relPath} to ${path.relative(root, backupPath)}`); + return 'updated'; +} + +/** After removing a wrong link and recreating it, the action is 'updated', not 'written'. */ +function upgraded(action: InstallAction): InstallAction { + return action === 'written' ? 'updated' : action; +} + +/** Create the symlink `linkAbs` → `canonicalAbs`, falling back to a copy if symlinks are unavailable. */ +async function linkOrCopy( + agentFs: AgentFs, + linkAbs: string, + canonicalAbs: string, + landingRel: string, + content: string, + stderr: (line: string) => void, +): Promise { + const relativeTarget = path.relative(path.dirname(linkAbs), canonicalAbs); + try { + await agentFs.symlink(relativeTarget, linkAbs); + return 'written'; + } catch { + // Symlinks unavailable (e.g. Windows without Developer Mode): write a real + // SKILL.md so the agent still discovers the skill. + await agentFs.mkdir(linkAbs); + await agentFs.writeFile(path.join(linkAbs, 'SKILL.md'), content); + stderr( + `[info] could not symlink ${landingRel} → .agents/skills (symlinks unavailable here) — copied instead.`, + ); + return 'copy-fallback'; + } +} + +/** + * Create or refresh a non-universal agent's symlink `/` → + * canonical. Idempotent; `--force` replaces a link/file that points elsewhere. + */ +async function ensureAgentLink( + agentFs: AgentFs, + root: string, + target: AgentTarget, + skill: string, + content: string, + force: boolean, + dryRun: boolean, + stderr: (line: string) => void, +): Promise { + const landingRel = targetLandingDir(target, skill); + const landingAbs = path.resolve(root, landingRel); + const canonicalAbs = path.resolve(root, canonicalSkillDir(skill)); + + const st = await inspectAncestors(agentFs, root, landingRel); + if (dryRun) { + if (st === null) return 'dry-run'; + if (st.isSymbolicLink) { + const resolved = await resolveLink(agentFs, landingAbs); + return resolved === canonicalAbs ? 'skipped' : 'dry-run'; + } + return 'dry-run'; + } + + await agentFs.mkdir(path.dirname(landingAbs)); + + if (st === null) { + return linkOrCopy(agentFs, landingAbs, canonicalAbs, landingRel, content, stderr); + } + + if (st.isSymbolicLink) { + const resolved = await resolveLink(agentFs, landingAbs); + if (resolved === canonicalAbs) return 'skipped'; + if (!force) return 'blocked'; + await agentFs.rm(landingAbs); + return upgraded( + await linkOrCopy(agentFs, landingAbs, canonicalAbs, landingRel, content, stderr), + ); + } + + if (st.isFile) { + if (!force) return 'blocked'; + const existing = await agentFs.readFile(landingAbs); + const backupPath = await writeBackup(agentFs, landingAbs, existing); + stderr(`backed up ${landingRel} to ${path.relative(root, backupPath)}`); + await agentFs.unlink(landingAbs); + return upgraded( + await linkOrCopy(agentFs, landingAbs, canonicalAbs, landingRel, content, stderr), + ); + } + + // Directory at the landing: a previous copy-fallback. Refresh the SKILL.md inside. + const skillMdAbs = path.join(landingAbs, 'SKILL.md'); + const mdStat = await agentFs.lstat(skillMdAbs); + if (mdStat === null) { + await agentFs.writeFile(skillMdAbs, content); + return 'written'; + } + if (!mdStat.isFile) return 'blocked'; + const existing = await agentFs.readFile(skillMdAbs); + if (existing === content) return 'skipped'; + if (!force) return 'blocked'; + const backupPath = await writeBackup(agentFs, skillMdAbs, existing); + stderr(`backed up ${path.relative(root, skillMdAbs)} to ${path.relative(root, backupPath)}`); + await agentFs.writeFile(skillMdAbs, content); + return 'updated'; +} + +/** Resolve a symlink to an absolute path (null if not a link / unreadable). */ +async function resolveLink(agentFs: AgentFs, linkAbs: string): Promise { + const target = await agentFs.readlink(linkAbs); + return target !== null ? path.resolve(path.dirname(linkAbs), target) : null; +} + // --------------------------------------------------------------------------- // runInstall // --------------------------------------------------------------------------- @@ -360,55 +389,46 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); const out = makeOutput(opts.output, deps); - // 1. Parse targets + // 1. Resolve targets (aliases → canonical ids). const rawTargets = opts.target .flatMap(s => s.split(',')) .map(s => s.trim()) .filter(Boolean); - let resolvedTargetStrings: string[]; - if (rawTargets.length === 0) { - const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY); - if (!isTTY) { - stderrFn( - '[info] --target not specified; defaulting to claude. Pass --target= to select a different agent.', - ); - resolvedTargetStrings = ['claude']; - } else { + if (deps.isTTY ?? Boolean(process.stdin.isTTY)) { const promptFn = deps.prompt ?? ((q: string) => promptText(q)); - const answer = (await promptFn('Targets to install (comma-separated) [claude]: ')).trim(); - const defaulted = answer || 'claude'; - resolvedTargetStrings = defaulted + const answer = ( + await promptFn('Targets to install (comma-separated) [claude-code]: ') + ).trim(); + resolvedTargetStrings = (answer || 'claude-code') .split(',') .map(s => s.trim()) .filter(Boolean); + } else { + stderrFn( + '[info] --target not specified; defaulting to claude-code. Pass --target= to select a different agent.', + ); + resolvedTargetStrings = ['claude-code']; } } else { resolvedTargetStrings = rawTargets; } - // 2. Validate targets - const validTargets = Object.keys(TARGETS) as AgentTarget[]; + const targets: AgentTarget[] = []; for (const t of resolvedTargetStrings) { - if (!validTargets.includes(t as AgentTarget)) { + const r = resolveTarget(t); + if (r === null) { throw localValidationError( 'target', - `unknown target "${t}"; supported: ${validTargets.join(', ')}`, + `unknown target "${t}"; supported: ${acceptedTargetTokens().join(', ')}`, ); } + targets.push(r); } + const dedupedTargets = [...new Set(targets)]; - // De-duplicate while preserving first-seen order - const seen = new Set(); - const targets = resolvedTargetStrings.filter(t => { - if (seen.has(t)) return false; - seen.add(t); - return true; - }) as AgentTarget[]; - - // 2b. Resolve + validate the skill set (empty/absent → DEFAULT_SKILLS). - // Accepts comma-separated or repeated --skill values, same shape as --target. + // 2. Resolve skills (default: DEFAULT_SKILLS). const rawSkills = (opts.skills ?? []) .flatMap(s => s.split(',')) .map(s => s.trim()) @@ -422,352 +442,142 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr ); } } - const seenSkill = new Set(); - const skills = (rawSkills.length > 0 ? rawSkills : [...DEFAULT_SKILLS]).filter(s => { - if (seenSkill.has(s)) return false; - seenSkill.add(s); - return true; - }); - - // 3. Resolve dir - const dir = opts.dir ?? deps.cwd ?? process.cwd(); - const root = path.resolve(dir); - - // 4. Lazy asset loaders — only touch disk if a target actually needs it. - // own-file bodies are per-skill (cached); the codex section aggregates EVERY - // installed skill's contribution into ONE managed section. - const skillBodyCache = new Map(); - const bodyForSkill = (skill: string): string => { - let b = skillBodyCache.get(skill); - if (b === undefined) { - b = loadSkillBodyFor(skill); - skillBodyCache.set(skill, b); - } - return b; - }; - // Budget-capped own-file targets (e.g. windsurf) render the compact per-skill - // body so the rule file isn't truncated by the agent. Cached separately; must - // match renderForTarget's default selection so written bytes equal the asserted - // render. - const compactBodyCache = new Map(); - const compactBodyForSkill = (skill: string): string => { - let b = compactBodyCache.get(skill); - if (b === undefined) { - b = compactBodyFor(skill); - compactBodyCache.set(skill, b); - } - return b; - }; - const ownFileBodyFor = (t: AgentTarget, skill: string): string => - TARGETS[t].compactBody ? compactBodyForSkill(skill) : bodyForSkill(skill); - let codexSectionCache: string | undefined; - const getCodexSection = (): string => { - if (codexSectionCache === undefined) { - const aggregate = buildCodexAggregate(skills); - // ONE marker for the whole managed section: it names every aggregated - // skill ('+'-joined) and hashes the canonical aggregate body, so - // `agent status` can attribute and fingerprint the section per skill. - codexSectionCache = buildSection( - aggregate, - buildSkillMarker(skills.join(MARKER_SKILL_SEPARATOR), aggregate), - ); - } - return codexSectionCache; - }; - - const results: InstallResult[] = []; - - // Track bytes for dry-run output - const dryRunLines: { abs: string; bytes: number; note: string }[] = []; - - // 5. Process each target - for (const t of targets) { - const spec = TARGETS[t]; - - // ----------------------------------------------------------------------- - // managed-section mode (codex target) — ONE section aggregating all skills - // ----------------------------------------------------------------------- - if (spec.mode === 'managed-section') { - const relPath = spec.path; // 'AGENTS.md' — skill-independent (all skills merge here) - const abs = path.resolve(root, relPath); - // Path safety: ensure abs is inside root (defense against .. in relPath or dir) - if (abs !== root && !abs.startsWith(root + path.sep)) { - throw new CLIError(`refusing to write outside --dir: ${relPath}`, 5); - } - const section = getCodexSection(); - - if (opts.dryRun) { - // Dry-run: report what would happen without writing disk. - // - // [P2] Apply the SAME symlink fail-close guard as the real install path. - // Without this, a symlinked AGENTS.md gets followed in dry-run even - // though the real install would refuse (exit 5). Run inspectTargetPath - // first; only lstat-check the final file (not write) after that. - const dryRunSt = await inspectTargetPath(agentFs, root, relPath); - if (dryRunSt !== null && !dryRunSt.isFile) { - throw new CLIError( - `${relPath} exists but is not a regular file — remove it and re-run.`, - 5, - ); - } + const skills = rawSkills.length > 0 ? [...new Set(rawSkills)] : [...DEFAULT_SKILLS]; - // We DO read the existing file (if present) to compute the - // would-be byte count and emit the 32 KiB budget warning — without - // this the warning was silently absent on --dry-run runs (Fix 4). - // - // [P3 round-2] Measure the ACTUAL composed result via the same - // classifySection + composeManagedFile pipeline the real install - // uses — `existing + section` double-counts the old block on the - // replace path and misses the append separator. Read failures other - // than ENOENT are surfaced (EACCES/EIO must not read as "absent" — - // absence is already represented by dryRunSt === null). - const bytes = Buffer.byteLength(section, 'utf8'); - let wouldBeContent = section; - if (dryRunSt !== null) { - let existing: string | null = null; - try { - existing = await agentFs.readFile(abs); - } catch (err) { - if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT') { - existing = null; // raced away between lstat and read → would-be = create - } else { - throw new CLIError( - `cannot read ${relPath} for dry-run: ${err instanceof Error ? err.message : String(err)}`, - 5, - ); - } - } - if (existing !== null) { - const state = classifySection(existing, section); - if (state.kind === 'corrupt') { - // The real install would refuse with exit 5 — dry-run reports - // the same outcome rather than a misleading success. - throw new CLIError( - `${relPath} contains a malformed TestSprite sentinel (BEGIN without END or vice-versa). ` + - `Manually remove the partial sentinel block and re-run.`, - 5, - ); - } - wouldBeContent = - state.kind === 'unchanged' - ? existing - : state.kind === 'create' - ? section - : composeManagedFile(state, section); - } - } - const wouldBeBytes = Buffer.byteLength(wouldBeContent, 'utf8'); - if (wouldBeBytes > AGENTS_MD_CODEX_BUDGET_BYTES) { - stderrFn( - `[warn] ${relPath} will be ${wouldBeBytes} bytes after this write — Codex may not load content beyond its 32 KiB (${AGENTS_MD_CODEX_BUDGET_BYTES} byte) budget. Trim AGENTS.md to stay within the limit.`, - ); - } - dryRunLines.push({ abs, bytes, note: 'managed section' }); - results.push({ target: t, path: relPath, action: 'dry-run', skills: [...skills] }); - continue; - } + const root = path.resolve(opts.dir ?? deps.cwd ?? process.cwd()); - // Inspect the target path via lstat walk (symlink-safe, same as own-file). - const st = await inspectTargetPath(agentFs, root, relPath); + // 3. Cached canonical content (raw asset, and rendered with the marker). + const rawCache = new Map(); + const rawContent = (skill: string): string => cached(rawCache, skill, () => loadSkillFull(skill)); + const renderCache = new Map(); + const installContent = (skill: string): string => + cached(renderCache, skill, () => + renderCanonicalWithMarker(skill, buildSkillMarker(skill, rawContent(skill))), + ); - if (st !== null && !st.isFile) { - throw new CLIError( - `${relPath} exists but is not a regular file — remove it and re-run.`, - 5, - ); - } + const results: InstallResult[] = []; + const dryRunLines: { rel: string; bytes: number; note: string }[] = []; + + // 4. Write the canonical file once per skill. A blocked canonical blocks every + // target for that skill (the source of truth couldn't be written). + const canonicalAction = new Map(); + for (const skill of skills) { + const content = installContent(skill); + const action = await ensureCanonical( + agentFs, + root, + skill, + content, + opts.force, + Boolean(opts.dryRun), + stderrFn, + ); + canonicalAction.set(skill, action); + if (opts.dryRun) { + dryRunLines.push({ + rel: canonicalSkillFile(skill), + bytes: Buffer.byteLength(content, 'utf8'), + note: 'canonical', + }); + } + } - /** - * [P2] Emit a stderr warn when the would-be file content exceeds Codex's - * 32 KiB load budget. We still write — this is a warn, not a refusal — - * but the operator needs early visibility so they can trim AGENTS.md. - */ - function warnIfOverBudget(wouldBeContent: string): void { - const byteLen = Buffer.byteLength(wouldBeContent, 'utf8'); - if (byteLen > AGENTS_MD_CODEX_BUDGET_BYTES) { - stderrFn( - `[warn] ${relPath} will be ${byteLen} bytes after this write — Codex may not load content beyond its 32 KiB (${AGENTS_MD_CODEX_BUDGET_BYTES} byte) budget. Trim AGENTS.md to stay within the limit.`, - ); - } - } + // 5. Per-target landing: universal targets read canonical; others get a symlink/copy. + for (const t of dedupedTargets) { + const spec = TARGETS[t]!; + for (const skill of skills) { + const cAction = canonicalAction.get(skill)!; + const mode: 'canonical' | 'symlink' = spec.universal ? 'canonical' : 'symlink'; - if (st === null) { - // File absent → create AGENTS.md containing just the section. - warnIfOverBudget(section); - await agentFs.mkdir(path.dirname(abs)); - try { - await agentFs.writeFile(abs, section, { exclusive: true }); - } catch (err) { - if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') { - throw new CLIError( - `${relPath} appeared after the path check — re-run, or pass --force to overwrite.`, - 6, - ); - } - throw err; - } + if (cAction === 'blocked') { results.push({ target: t, - path: relPath, - action: 'section-installed', - skills: [...skills], + path: pathFor(t, skill), + action: 'blocked', + skills: [skill], + mode, }); - } else { - const existing = await agentFs.readFile(abs); - const state = classifySection(existing, section); - - if (state.kind === 'corrupt') { - // BEGIN without matching END (or vice-versa) — never destroy user content. - throw new CLIError( - `${relPath} contains a malformed TestSprite sentinel (BEGIN without END or vice-versa). ` + - `Manually remove the partial sentinel block and re-run.`, - 5, - ); - } - - if (state.kind === 'unchanged') { - results.push({ - target: t, - path: relPath, - action: 'section-unchanged', - skills: [...skills], - }); - } else if (state.kind === 'create') { - // Shouldn't happen (st !== null means file exists), but guard anyway. - warnIfOverBudget(section); - await agentFs.writeFile(abs, section); - results.push({ - target: t, - path: relPath, - action: 'section-installed', - skills: [...skills], - }); - } else { - // 'append' or 'replace' — write the new content. - // --force has no special meaning for managed-section: we always merge - // rather than replacing the whole file, so force is effectively always - // on for the section (user content is never at risk). - const newContent = composeManagedFile(state, section); - warnIfOverBudget(newContent); - await agentFs.writeFile(abs, newContent); - const action: InstallAction = - state.kind === 'append' ? 'section-installed' : 'section-updated'; - results.push({ target: t, path: relPath, action, skills: [...skills] }); - } - } - continue; - } - - // ----------------------------------------------------------------------- - // own-file mode (all other targets) — one file per skill - // ----------------------------------------------------------------------- - for (const skill of skills) { - const relPath = pathFor(t, skill); - const abs = path.resolve(root, relPath); - // Path safety: ensure abs is inside root (defense against .. in relPath or dir) - if (abs !== root && !abs.startsWith(root + path.sep)) { - throw new CLIError(`refusing to write outside --dir: ${relPath}`, 5); + continue; } - const content = renderForTarget(t, skill, ownFileBodyFor(t, skill)).content; if (opts.dryRun) { - // Apply the SAME symlink fail-close guard as the real install path - // below (the codex managed-section branch already does this). Without - // it, dry-run reports success for a planted symlink that the real - // install would refuse with exit 5. - const dryRunSt = await inspectTargetPath(agentFs, root, relPath); - if (dryRunSt !== null && !dryRunSt.isFile) { - throw new CLIError( - `${relPath} exists but is not a regular file — remove it and re-run.`, - 5, + if (!spec.universal) { + const action = await ensureAgentLink( + agentFs, + root, + t, + skill, + installContent(skill), + opts.force, + true, + stderrFn, ); + dryRunLines.push({ + rel: targetLandingDir(t, skill), + bytes: 0, + note: `symlink → ${canonicalSkillDir(skill)} (${action})`, + }); } - const bytes = Buffer.byteLength(content, 'utf8'); - dryRunLines.push({ abs, bytes, note: '' }); - results.push({ target: t, path: relPath, action: 'dry-run', skills: [skill] }); + results.push({ + target: t, + path: pathFor(t, skill), + action: 'dry-run', + skills: [skill], + mode, + }); continue; } - // Inspect the target path: refuse to traverse or write through a symlink - // (fs writes follow symlinks, which would let a planted symlink escape - // --dir), and reject a non-regular-file landing path. The lexical guard - // above is necessary but not sufficient — it cannot see symlinks. - const st = await inspectTargetPath(agentFs, root, relPath); - - if (st !== null && !st.isFile) { - throw new CLIError( - `${relPath} exists but is not a regular file — remove it and re-run.`, - 5, - ); - } - - if (st === null) { - // Path does not exist — create it. inspectTargetPath verified every - // existing ancestor is a real directory; exclusive create (wx) then - // ensures a file or symlink that races in after the check is not followed - // or silently overwritten. - await agentFs.mkdir(path.dirname(abs)); - try { - await agentFs.writeFile(abs, content, { exclusive: true }); - } catch (err) { - if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') { - throw new CLIError( - `${relPath} appeared after the path check — re-run, or pass --force to overwrite.`, - 6, - ); - } - throw err; - } - results.push({ target: t, path: relPath, action: 'written', skills: [skill] }); + if (spec.universal) { + results.push({ + target: t, + path: pathFor(t, skill), + action: cAction, + skills: [skill], + mode, + }); } else { - const existing = await agentFs.readFile(abs); - if (existing === content) { - // Byte-identical — skip - results.push({ target: t, path: relPath, action: 'skipped', skills: [skill] }); - } else if (!opts.force) { - // Differs and no --force → blocked - results.push({ target: t, path: relPath, action: 'blocked', skills: [skill] }); - } else { - // Differs and --force → back up the current bytes to a fresh slot - // (never clobbering an existing backup or following a symlink), then - // overwrite. The overwrite itself can follow a symlink swapped in after - // the check — an accepted TOCTOU residual for a local, single-user CLI. - const backupPath = await writeBackup(agentFs, abs, existing); - await agentFs.writeFile(abs, content); - if (opts.output === 'text') { - stderrFn(`backed up ${relPath} to ${path.relative(root, backupPath)}`); - } - results.push({ target: t, path: relPath, action: 'updated', skills: [skill] }); - } + let action = await ensureAgentLink( + agentFs, + root, + t, + skill, + installContent(skill), + opts.force, + false, + stderrFn, + ); + // The agent reads canonical THROUGH its link, so a canonical refresh this + // run is a content change for this target even when the link was already correct. + if (action === 'skipped' && (cAction === 'written' || cAction === 'updated')) + action = 'updated'; + results.push({ target: t, path: pathFor(t, skill), action, skills: [skill], mode }); } } } - // 6. Dry-run output if (opts.dryRun) { stderrFn('[dry-run] no files written — preview only'); - for (const { abs, bytes, note } of dryRunLines) { - const suffix = note ? ` (${note}, ${bytes} bytes)` : ` (${bytes} bytes)`; - stderrFn(`[dry-run] would write ${abs}${suffix}`); + for (const { rel, bytes, note } of dryRunLines) { + stderrFn(`[dry-run] would write ${rel} (${note}${bytes > 0 ? `, ${bytes} bytes` : ''})`); } } - // 7. Blocked hints for (const r of results) { if (r.action === 'blocked') { stderrFn( - `${r.path} exists and differs from the canonical skill — re-run with --force to overwrite (the existing file is backed up to .bak).`, + `${r.path} exists and differs from the canonical skill — re-run with --force to overwrite (a .bak is kept).`, ); } } - // 8. Print results out.print(results, data => { const items = data as InstallResult[]; - return items.map(r => `${r.target.padEnd(12)} ${r.action.padEnd(12)} ${r.path}`).join('\n'); + return items + .map(r => `${r.target.padEnd(16)} ${r.mode.padEnd(10)} ${r.action.padEnd(13)} ${r.path}`) + .join('\n'); }); - // 9. Exit with 6 if any blocked if (results.some(r => r.action === 'blocked')) { throw new CLIError( 'one or more targets already exist and differ; re-run with --force to overwrite (a .bak is kept).', @@ -776,86 +586,66 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr } } +/** Memoize a per-skill computation. */ +function cached(cache: Map, skill: string, compute: () => string): string { + let value = cache.get(skill); + if (value === undefined) { + value = compute(); + cache.set(skill, value); + } + return value; +} + // --------------------------------------------------------------------------- // runList // --------------------------------------------------------------------------- export interface ListResult { - target: AgentTarget; - skill: string; - status: string; - mode: string; + target: string; + displayName: string; + mode: 'universal' | 'symlink'; + skillsDir: string; + /** Example landing path for testsprite-verify. */ path: string; } export async function runList(opts: CommonOptions, deps: AgentDeps = {}): Promise { const out = makeOutput(opts.output, deps); - - // One row per (target × default skill). Own-file targets land each skill at a - // distinct path; the codex managed-section target merges all skills into the - // single AGENTS.md (so every codex row shares that path — truthful, since both - // skills' content lands there). - const results: ListResult[] = []; - for (const [t, spec] of Object.entries(TARGETS) as [ - AgentTarget, - { status: string; mode: string }, - ][]) { - for (const skill of DEFAULT_SKILLS) { - results.push({ - target: t, - skill, - status: spec.status, - mode: spec.mode, - path: pathFor(t, skill), - }); - } - } + const results: ListResult[] = (Object.keys(TARGETS) as AgentTarget[]).map(t => { + const spec = TARGETS[t]!; + return { + target: t, + displayName: spec.displayName, + mode: spec.universal ? 'universal' : 'symlink', + skillsDir: spec.skillsDir, + path: pathFor(t, 'testsprite-verify'), + }; + }); out.print(results, data => { const items = data as ListResult[]; - const header = `${'TARGET'.padEnd(14)} ${'SKILL'.padEnd(20)} ${'STATUS'.padEnd(12)} ${'MODE'.padEnd(18)} PATH`; + const header = `${'TARGET'.padEnd(18)} ${'MODE'.padEnd(10)} ${'SKILLS_DIR'.padEnd(22)} PATH`; const rows = items.map( - r => - `${r.target.padEnd(14)} ${r.skill.padEnd(20)} ${r.status.padEnd(12)} ${r.mode.padEnd(18)} ${r.path}`, + r => `${r.target.padEnd(18)} ${r.mode.padEnd(10)} ${r.skillsDir.padEnd(22)} ${r.path}`, ); return [header, ...rows].join('\n'); }); } // --------------------------------------------------------------------------- -// runStatus (issue #123: detect silently stale installed skill files) +// runStatus // --------------------------------------------------------------------------- /** - * Health of one installed skill artifact, as reported by `agent status`. - * - * Decision order (first match wins): - * - 'absent' : nothing at the landing path (codex: no managed section, - * including an AGENTS.md that exists without our sentinels). - * - 'corrupt' : codex only. Dangling or duplicated sentinels, the same - * classification `agent install` refuses on; status REPORTS it - * instead of refusing. - * - 'unmarked' : artifact present but carries no testsprite-skill marker - * (installed before markers existed), or the landing path is - * occupied by a non-regular file (never followed). - * - 'stale' : marker present, but its hash differs from the current - * canonical body: a re-install would change the content. Edits - * on top of an OLD install also read stale (older renders - * cannot be reproduced); the remedy is the same re-install. - * - 'modified' : marker hash matches the current body, but the artifact bytes - * differ from the canonical render carrying that same marker - * line: the user edited the artifact after install. - * - 'ok' : marker hash matches and the bytes equal the canonical render - * with the file's own marker line (a version-string-only lag - * with an unchanged body still reads ok). - * - * For the codex managed section, ONE marker names every aggregated skill - * ('+'-joined); skills not named by the marker report 'absent'. + * Health of one installed skill, reported by `agent status`: + * absent / unmarked (no marker) / stale (marker hash differs) / modified (bytes + * differ from the canonical render) / ok. `agent status` emits only non-absent + * rows and exits 1 when any row needs attention, so it can gate CI. */ -export type SkillArtifactState = 'ok' | 'stale' | 'modified' | 'unmarked' | 'absent' | 'corrupt'; +export type SkillArtifactState = 'ok' | 'stale' | 'modified' | 'unmarked' | 'absent'; export interface StatusResult { - target: AgentTarget; + target: string; skill: string; path: string; state: SkillArtifactState; @@ -865,188 +655,92 @@ interface StatusOptions extends CommonOptions { dir?: string; } -/** - * Classify one own-file artifact per the {@link SkillArtifactState} contract. - * Comparisons are byte-exact, matching the installer's own skipped/blocked - * comparison for own-file targets. - */ -async function classifyOwnFileState( +/** Classify a SKILL.md (canonical or copy-fallback) against the shipped content. */ +async function classifySkillFile( agentFs: AgentFs, abs: string, - target: AgentTarget, skill: string, - bodyForSkill: (skill: string) => string, + contentForSkill: (skill: string) => string, ): Promise { const stat = await agentFs.lstat(abs); if (stat === null) return 'absent'; - // Occupied by a directory or symlink: not something our installer wrote, and - // never followed (mirrors the installer's fail-closed stance on symlinks). if (!stat.isFile) return 'unmarked'; - const existing = await agentFs.readFile(abs); const marker = parseSkillMarker(existing); if (marker === null) return 'unmarked'; - - const canonicalBody = bodyForSkill(skill); - if (marker.hash12 !== bodyHash12(canonicalBody)) return 'stale'; - - // Hash matches the current body: pristine iff the file equals the canonical - // render carrying its own marker line, so a marker whose version string lags - // behind an unchanged body still reads ok. - const reRender = renderOwnFileWithMarker(target, skill, marker.line, canonicalBody); - return existing === reRender ? 'ok' : 'modified'; + if (marker.hash12 !== bodyHash12(contentForSkill(skill))) return 'stale'; + return existing === renderCanonicalWithMarker(skill, marker.line) ? 'ok' : 'modified'; } -/** - * Classify the codex managed section per skill. The section is ONE artifact - * carrying ONE marker that names every aggregated skill, so a single - * inspection answers all skill rows; the returned function maps a skill name - * to its state. Comparisons are CRLF-insensitive on the section bytes. - */ -async function classifyManagedSectionStates( +/** Classify a non-universal landing: a symlink to canonical, a copy-fallback dir, or absent. */ +async function classifySymlinked( agentFs: AgentFs, - abs: string, -): Promise<(skill: string) => SkillArtifactState> { - const constantState = - (state: SkillArtifactState): ((skill: string) => SkillArtifactState) => - () => - state; - - const stat = await agentFs.lstat(abs); - if (stat === null) return constantState('absent'); - // Occupied by a directory or symlink: never followed (fail-closed). - if (!stat.isFile) return constantState('unmarked'); - - const existing = await agentFs.readFile(abs); - - // Current canonical section for the default skill set. classifySection's - // 'unchanged' answers the common all-defaults-fresh case; its - // corrupt/append classification is reused verbatim for status verdicts. - const defaultAggregate = buildCodexAggregate(DEFAULT_SKILLS); - const defaultSection = buildSection( - defaultAggregate, - buildSkillMarker(DEFAULT_SKILLS.join(MARKER_SKILL_SEPARATOR), defaultAggregate), - ); - const sectionState = classifySection(existing, defaultSection); - - if (sectionState.kind === 'corrupt') return constantState('corrupt'); - // No standalone sentinels anywhere: the managed section is not installed. - if (sectionState.kind === 'append') return constantState('absent'); - if (sectionState.kind === 'unchanged') { - // Byte-identical to today's default install. - return skill => ((DEFAULT_SKILLS as readonly string[]).includes(skill) ? 'ok' : 'absent'); - } - if (sectionState.kind !== 'replace') { - // 'create' is unreachable when the file exists; treat defensively as absent. - return constantState('absent'); - } - - // Sentinels are present but the section differs from today's default - // canonical: slice the live section bytes out of the file and inspect its - // own marker (before/after are exact byte prefix/suffix around the section). - const sectionContent = existing.slice( - sectionState.before.length, - existing.length - sectionState.after.length, - ); - const marker = parseSkillMarker(sectionContent); - if (marker === null) return constantState('unmarked'); - - const installedSkills = marker.skill.split(MARKER_SKILL_SEPARATOR); - const coversSkill = (skill: string): boolean => installedSkills.includes(skill); - - // A marker naming a skill this CLI does not ship cannot be re-rendered; - // report the named skills stale (a re-install refreshes the section). - if (installedSkills.some(name => SKILLS[name] === undefined)) { - return skill => (coversSkill(skill) ? 'stale' : 'absent'); - } - - const canonicalAggregate = buildCodexAggregate(installedSkills); - if (marker.hash12 !== bodyHash12(canonicalAggregate)) { - return skill => (coversSkill(skill) ? 'stale' : 'absent'); + root: string, + target: AgentTarget, + skill: string, + contentForSkill: (skill: string) => string, +): Promise { + const landingAbs = path.resolve(root, targetLandingDir(target, skill)); + const stat = await agentFs.lstat(landingAbs); + if (stat === null) return 'absent'; + if (stat.isSymbolicLink) { + const resolved = await resolveLink(agentFs, landingAbs); + if (resolved !== path.resolve(root, canonicalSkillDir(skill))) return 'modified'; + return classifySkillFile( + agentFs, + path.resolve(root, canonicalSkillFile(skill)), + skill, + contentForSkill, + ); } - - // Hash matches the current aggregate: the section is pristine iff its bytes - // equal a re-render carrying its own marker line (version-string-only lag - // with an unchanged body still reads ok). - const pristine = - sectionContent.replace(/\r\n/g, '\n') === buildSection(canonicalAggregate, marker.line); - return skill => (coversSkill(skill) ? (pristine ? 'ok' : 'modified') : 'absent'); + if (stat.isFile) return 'unmarked'; + return classifySkillFile(agentFs, path.join(landingAbs, 'SKILL.md'), skill, contentForSkill); } -/** - * `agent status`: one row per (target × default skill), each classified per - * the {@link SkillArtifactState} contract. Exit contract: returns normally - * (exit 0) when every row is 'ok' or 'absent'; throws CLIError exit 1 when any - * row is stale/modified/unmarked/corrupt, so the command can gate CI. - */ export async function runStatus(opts: StatusOptions, deps: AgentDeps = {}): Promise { const agentFs = deps.fs ?? defaultAgentFs; const out = makeOutput(opts.output, deps); - // An explicit but empty --dir must not silently resolve to cwd - // (path.resolve('') === cwd). if (opts.dir !== undefined && opts.dir.trim() === '') { throw localValidationError('dir', 'must not be empty'); } - const dir = opts.dir !== undefined ? opts.dir.trim() : (deps.cwd ?? process.cwd()); - const root = path.resolve(dir); - - // Canonical own-file bodies, read once per skill (same lazy caching pattern - // as runInstall's bodyForSkill). - const skillBodyCache = new Map(); - const bodyForSkill = (skill: string): string => { - let cachedBody = skillBodyCache.get(skill); - if (cachedBody === undefined) { - cachedBody = loadSkillBodyFor(skill); - skillBodyCache.set(skill, cachedBody); - } - return cachedBody; - }; + const root = path.resolve(opts.dir !== undefined ? opts.dir.trim() : (deps.cwd ?? process.cwd())); + + const contentCache = new Map(); + const contentForSkill = (skill: string): string => + cached(contentCache, skill, () => loadSkillFull(skill)); const results: StatusResult[] = []; - for (const [target, spec] of Object.entries(TARGETS) as [ - AgentTarget, - { mode: string; path: string }, - ][]) { - if (spec.mode === 'managed-section') { - const stateFor = await classifyManagedSectionStates(agentFs, path.resolve(root, spec.path)); - for (const skill of DEFAULT_SKILLS) { - results.push({ target, skill, path: spec.path, state: stateFor(skill) }); - } - continue; - } + for (const target of Object.keys(TARGETS) as AgentTarget[]) { + const spec = TARGETS[target]!; for (const skill of DEFAULT_SKILLS) { - const relPath = pathFor(target, skill); - results.push({ - target, - skill, - path: relPath, - state: await classifyOwnFileState( - agentFs, - path.resolve(root, relPath), - target, - skill, - bodyForSkill, - ), - }); + const state = spec.universal + ? await classifySkillFile( + agentFs, + path.resolve(root, canonicalSkillFile(skill)), + skill, + contentForSkill, + ) + : await classifySymlinked(agentFs, root, target, skill, contentForSkill); + if (state === 'absent') continue; + results.push({ target, skill, path: pathFor(target, skill), state }); } } out.print(results, data => { const items = data as StatusResult[]; - const header = `${'TARGET'.padEnd(14)} ${'SKILL'.padEnd(20)} ${'STATE'.padEnd(10)} PATH`; + if (items.length === 0) return 'No TestSprite skill artifacts installed in this project.'; + const header = `${'TARGET'.padEnd(18)} ${'SKILL'.padEnd(20)} ${'STATE'.padEnd(10)} PATH`; const rows = items.map( - row => `${row.target.padEnd(14)} ${row.skill.padEnd(20)} ${row.state.padEnd(10)} ${row.path}`, + row => `${row.target.padEnd(18)} ${row.skill.padEnd(20)} ${row.state.padEnd(10)} ${row.path}`, ); return [header, ...rows].join('\n'); }); - const needingAttention = results.filter( - result => result.state !== 'ok' && result.state !== 'absent', - ); + const needingAttention = results.filter(r => r.state !== 'ok'); if (needingAttention.length > 0) { throw new CLIError( - `${needingAttention.length} skill artifact(s) need attention (stale/modified/unmarked/corrupt); re-run \`testsprite agent install\` (add --force for own-file targets) to refresh them.`, + `${needingAttention.length} skill artifact(s) need attention (stale/modified/unmarked); re-run \`testsprite agent install\` (add --force for symlink/copy targets) to refresh them.`, 1, ); } @@ -1062,7 +756,7 @@ function collect(v: string, prev: string[]): string[] { export function createAgentCommand(deps: AgentDeps = {}): Command { const agent = new Command('agent').description( - 'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Kiro, Windsurf, Copilot, Codex)', + 'Install TestSprite skills into each coding agent (Claude Code, Codex, Cursor, Cline, Gemini CLI, Copilot, and 60+ more)', ); agent @@ -1072,7 +766,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { ) .option( '--target ', - 'Agent target(s): claude, cursor, cline, antigravity, kiro, windsurf, copilot, codex (comma-separated or repeated)', + 'Agent target(s): claude-code, codex, cursor, gemini-cli, github-copilot, kiro-cli, windsurf, cline, antigravity (comma-separated or repeated)', collect, [], ) @@ -1085,8 +779,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { .option('--dir ', 'Project root to write into (default: cwd)') .option( '--force', - 'For own-file targets: overwrite existing file (a .bak backup is kept). ' + - 'For codex (managed-section): replaces the section unconditionally; user content outside the section is never destroyed.', + 'Overwrite an existing canonical file or landing (a .bak backup is kept for files).', ) .addHelpText('after', GLOBAL_OPTS_HINT) .action( @@ -1109,7 +802,9 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { agent .command('list') - .description('List supported agent targets and skills, their status, and landing paths') + .description( + 'List supported agent targets, their skill folder, and whether they read .agents/skills directly (universal) or via symlink', + ) .addHelpText('after', GLOBAL_OPTS_HINT) .action(async (_o, command: Command) => { await runList(resolveCommonOptions(command), deps); @@ -1118,7 +813,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { agent .command('status') .description( - 'Check installed TestSprite skill files against this CLI version: ok, stale, modified, unmarked, absent, or corrupt (exits 1 when anything needs attention, so it can gate CI)', + 'Check installed TestSprite skills against this CLI version (ok/stale/modified/unmarked). Reports only agents that have an install; exits 1 when any need attention, so it can gate CI', ) .option('--dir ', 'Project root to inspect (default: cwd)') .addHelpText('after', GLOBAL_OPTS_HINT) @@ -1129,10 +824,6 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { return agent; } -// --------------------------------------------------------------------------- -// Per-file helpers (per convention: copy from auth.ts) -// --------------------------------------------------------------------------- - function resolveCommonOptions(command: Command): CommonOptions { const globals = command.optsWithGlobals() as Partial; return { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 40c1ac8..f1b27a0 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -64,7 +64,6 @@ export interface DoctorDeps { /** Runtime version string (e.g. "22.9.0"). Defaults to `process.versions.node`. */ nodeVersion?: string; existsSync?: (p: string) => boolean; - readFileSync?: (p: string) => string; } type CommonOptions = FactoryCommonOptions; @@ -183,7 +182,6 @@ function checkCredentials(hasKey: boolean, profile: string, dryRun: boolean): Do function checkSkill(cwd: string, deps: DoctorDeps): DoctorCheck { const installed = isVerifySkillInstalled(cwd, { existsSync: deps.existsSync, - readFileSync: deps.readFileSync, }); return { name: 'Verify skill', diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 92e2095..6799678 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -14,7 +14,13 @@ import type { MeResponse } from './auth.js'; import type { AgentFs } from './agent.js'; import type { InitDeps } from './init.js'; import { runInit } from './init.js'; -import { TARGETS, DEFAULT_SKILLS, pathFor, type AgentTarget } from '../lib/agent-targets.js'; +import { + DEFAULT_SKILLS, + TARGETS, + canonicalSkillFile, + pathFor, + type AgentTarget, +} from '../lib/agent-targets.js'; // --------------------------------------------------------------------------- // Fixtures @@ -70,11 +76,14 @@ function makeMemFs(): { fs: AgentFs; writeCalls: string[]; mkdirCalls: string[]; + symlinkCalls: { target: string; link: string }[]; } { const store = new Map(); + const symlinks = new Map(); const dirs = new Set(); const writeCalls: string[] = []; const mkdirCalls: string[] = []; + const symlinkCalls: { target: string; link: string }[] = []; const addAncestors = (p: string) => { let cur = path.dirname(p); @@ -87,6 +96,7 @@ function makeMemFs(): { const agentFs: AgentFs = { async lstat(p: string) { + if (symlinks.has(p)) return { isFile: false, isSymbolicLink: true }; if (store.has(p)) return { isFile: true, isSymbolicLink: false }; if (dirs.has(p)) return { isFile: false, isSymbolicLink: false }; return null; @@ -97,7 +107,7 @@ function makeMemFs(): { return v; }, async writeFile(p: string, data: string, opts?: { exclusive?: boolean }) { - if (opts?.exclusive && (store.has(p) || dirs.has(p))) { + if (opts?.exclusive && (store.has(p) || dirs.has(p) || symlinks.has(p))) { throw Object.assign(new Error(`EEXIST: ${p}`), { code: 'EEXIST' }); } writeCalls.push(p); @@ -109,9 +119,34 @@ function makeMemFs(): { dirs.add(p); addAncestors(p); }, + async readlink(p: string) { + return symlinks.get(p) ?? null; + }, + async symlink(target: string, linkPath: string) { + symlinkCalls.push({ target, link: linkPath }); + symlinks.set(linkPath, target); + addAncestors(linkPath); + }, + async unlink(p: string) { + store.delete(p); + symlinks.delete(p); + }, + async rm(p: string) { + store.delete(p); + symlinks.delete(p); + for (const k of [...store.keys()]) { + if (k.startsWith(p + path.sep)) store.delete(k); + } + for (const s of [...symlinks.keys()]) { + if (s.startsWith(p + path.sep)) symlinks.delete(s); + } + for (const d of [...dirs]) { + if (d === p || d.startsWith(p + path.sep)) dirs.delete(d); + } + }, }; - return { store, fs: agentFs, writeCalls, mkdirCalls }; + return { store, fs: agentFs, writeCalls, mkdirCalls, symlinkCalls }; } // --------------------------------------------------------------------------- @@ -147,7 +182,7 @@ function makeBaseOpts(overrides: Partial[0]> = {}) { debug: false, dryRun: false, fromEnv: false, - agent: 'claude' as AgentTarget, + agent: 'claude-code' as AgentTarget, noAgent: false, force: false, yes: false, @@ -386,13 +421,13 @@ describe('runInit — --no-agent', () => { }); // --------------------------------------------------------------------------- -// 3b. Default claude target: installs both DEFAULT_SKILLS (2 own-file writes) +// 3b. Default claude-code target: installs both DEFAULT_SKILLS into .agents/skills // --------------------------------------------------------------------------- -describe('runInit — default claude target installs 2 skill files', () => { - it('writes both testsprite-verify and testsprite-onboard for claude', async () => { +describe('runInit — default claude-code target installs the canonical skill files', () => { + it('writes both testsprite-verify and testsprite-onboard into .agents/skills', async () => { const { deps } = makeCapture(); - const { fs: agentFs, writeCalls } = makeMemFs(); + const { fs: agentFs, writeCalls, symlinkCalls } = makeMemFs(); const fetchMock = makeOkFetch(); await runInit(makeBaseOpts({ apiKey: 'sk-test' }), { @@ -404,13 +439,14 @@ describe('runInit — default claude target installs 2 skill files', () => { fs: agentFs, }); - const verifyPath = path.resolve(CWD, pathFor('claude', 'testsprite-verify')); - const onboardPath = path.resolve(CWD, pathFor('claude', 'testsprite-onboard')); + // canonical source of truth: one SKILL.md per skill under .agents/skills + const verifyPath = path.resolve(CWD, canonicalSkillFile('testsprite-verify')); + const onboardPath = path.resolve(CWD, canonicalSkillFile('testsprite-onboard')); expect(writeCalls).toContain(verifyPath); expect(writeCalls).toContain(onboardPath); - // Exactly 2 skill-file writes (claude is own-file, one file per skill) - const skillWrites = writeCalls.filter(p => p === verifyPath || p === onboardPath); - expect(skillWrites).toHaveLength(2); + // claude-code is a symlinked target: a link back to canonical is created per skill + expect(symlinkCalls.some(s => s.link.endsWith('.claude/skills/testsprite-verify'))).toBe(true); + expect(symlinkCalls.some(s => s.link.endsWith('.claude/skills/testsprite-onboard'))).toBe(true); }); }); @@ -433,14 +469,13 @@ describe('runInit — --agent cursor', () => { fs: agentFs, }); - // cursor is own-file; DEFAULT_SKILLS installs 2 files (testsprite-verify + testsprite-onboard) + // cursor is universal: it reads .agents/skills directly, so the canonical + // SKILL.md is written (one per skill) and NO symlink is created. const cursorVerifyPath = path.resolve(CWD, pathFor('cursor', 'testsprite-verify')); const cursorOnboardPath = path.resolve(CWD, pathFor('cursor', 'testsprite-onboard')); expect(writeCalls).toContain(cursorVerifyPath); expect(writeCalls).toContain(cursorOnboardPath); - // TARGETS[target].path is the verify skill path (back-compat); still written - const cursorAbsPath = path.resolve(CWD, TARGETS.cursor.path); - expect(writeCalls).toContain(cursorAbsPath); + expect(cursorVerifyPath).toBe(path.resolve(CWD, canonicalSkillFile('testsprite-verify'))); const stdout = captured.stdout.join('\n'); expect(stdout).toContain('cursor'); @@ -779,7 +814,7 @@ describe('runInit — summary JSON shape', () => { agent: { target: string; action: string; skills?: string[] } | null; }; expect(parsed.agent).not.toBeNull(); - expect(parsed.agent?.target).toBe('claude'); + expect(parsed.agent?.target).toBe('claude-code'); // aggregateInstallAction maps 'written' → 'installed'; fresh install is 'installed' expect(parsed.agent?.action).toBe('installed'); expect(typeof parsed.agent?.action).toBe('string'); @@ -849,20 +884,13 @@ describe('runInit — all agent targets', () => { fs: agentFs, }); - // TARGETS[target].path is the testsprite-verify path (back-compat); always written - const expectedPath = path.resolve(CWD, TARGETS[target].path); - expect(writeCalls).toContain(expectedPath); - - if (TARGETS[target].mode === 'own-file') { - // own-file targets: DEFAULT_SKILLS installs 2 separate files (one per skill) - const verifyPath = path.resolve(CWD, pathFor(target, 'testsprite-verify')); - const onboardPath = path.resolve(CWD, pathFor(target, 'testsprite-onboard')); - expect(writeCalls).toContain(verifyPath); - expect(writeCalls).toContain(onboardPath); - } else { - // managed-section (codex): ONE write to AGENTS.md aggregating all skills - expect(writeCalls.filter(p => p === expectedPath).length).toBe(1); - } + // Every target writes the canonical SKILL.md under .agents/skills (the + // single source of truth). Universal targets read it directly; symlinked + // targets additionally link to it. + const verifyPath = path.resolve(CWD, canonicalSkillFile('testsprite-verify')); + const onboardPath = path.resolve(CWD, canonicalSkillFile('testsprite-onboard')); + expect(writeCalls).toContain(verifyPath); + expect(writeCalls).toContain(onboardPath); }); } }); @@ -929,8 +957,8 @@ describe('[B-E2E-05] runInit: --no-agent + --agent conflict emits [warn] on stde const warnLine = captured.stderr.find(l => l.includes('[warn]') && l.includes('--no-agent')); expect(warnLine).toBeDefined(); - // --agent cursor wins → cursor file should be written - const cursorPath = path.resolve(CWD, TARGETS.cursor.path); + // --agent cursor wins → cursor (universal) canonical file should be written + const cursorPath = path.resolve(CWD, canonicalSkillFile('testsprite-verify')); expect(writeCalls).toContain(cursorPath); }); @@ -986,6 +1014,18 @@ describe('[B-E2E-06] runInit: install failure → info message on stderr + re-th async mkdir() { throw new Error('ENOENT: no such directory'); }, + async readlink() { + return null; + }, + async symlink() { + throw new Error('ENOENT'); + }, + async unlink() { + throw new Error('ENOENT'); + }, + async rm() { + throw new Error('ENOENT'); + }, }; let caughtErr: unknown; diff --git a/src/commands/init.ts b/src/commands/init.ts index 659945f..e916757 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -23,7 +23,7 @@ import type { AuthDeps, MeResponse } from './auth.js'; import { runConfigure, runWhoami } from './auth.js'; import type { AgentDeps, AgentFs, InstallResult } from './agent.js'; import { runInstall } from './agent.js'; -import { TARGETS, DEFAULT_SKILLS, type AgentTarget } from '../lib/agent-targets.js'; +import { DEFAULT_SKILLS, type AgentTarget } from '../lib/agent-targets.js'; import type { FetchImpl } from '../lib/http.js'; import { readProfile } from '../lib/credentials.js'; @@ -507,11 +507,7 @@ interface SetupCmdOpts { } /** Attach the onboarding flags shared by `setup` and the `init` alias. */ -function addSetupOptions( - cmd: Command, - validTargets: AgentTarget[], - defaultAgent: AgentTarget, -): Command { +function addSetupOptions(cmd: Command, defaultAgent: AgentTarget): Command { return cmd .option('--api-key ', 'API key to configure (skips the interactive prompt)') .option( @@ -521,7 +517,9 @@ function addSetupOptions( ) .option( '--agent ', - `Coding-agent target to install: ${validTargets.join(', ')} (default: ${defaultAgent})`, + 'Coding-agent target to install — any Agent Skills standard (agentskills.io) agent id ' + + '(e.g. claude-code, codex, cursor, cline, gemini-cli, github-copilot, ' + + `kiro-cli, windsurf, antigravity). Default: ${defaultAgent}`, defaultAgent, ) .option('--no-agent', 'Skip the agent skill install (configure credentials only)') @@ -598,10 +596,9 @@ async function runSetupAction( } export function createSetupCommand(deps: InitDeps = {}): Command { - const validTargets = Object.keys(TARGETS) as AgentTarget[]; - const defaultAgent: AgentTarget = 'claude'; + const defaultAgent: AgentTarget = 'claude-code'; - return addSetupOptions(new Command('setup'), validTargets, defaultAgent) + return addSetupOptions(new Command('setup'), defaultAgent) .description(SETUP_DESCRIPTION) .addHelpText('after', GLOBAL_OPTS_HINT) .action(async (cmdOpts: SetupCmdOpts, command: Command) => { @@ -616,10 +613,9 @@ export function createSetupCommand(deps: InitDeps = {}): Command { * deprecation notice. (Setup consolidation.) */ export function createDeprecatedInitCommand(deps: InitDeps = {}): Command { - const validTargets = Object.keys(TARGETS) as AgentTarget[]; - const defaultAgent: AgentTarget = 'claude'; + const defaultAgent: AgentTarget = 'claude-code'; - return addSetupOptions(new Command('init'), validTargets, defaultAgent) + return addSetupOptions(new Command('init'), defaultAgent) .description('(deprecated) alias for `setup`') .action(async (cmdOpts: SetupCmdOpts, command: Command) => { emitDeprecationNotice('init', 'setup', deps.stderr); @@ -638,5 +634,5 @@ export async function runConfigureViaSetup( deps: InitDeps, fromEnv: boolean, ): Promise { - await runSetupAction({ agent: 'claude', fromEnv }, command, deps, 'claude'); + await runSetupAction({ agent: 'claude-code', fromEnv }, command, deps, 'claude-code'); } diff --git a/src/lib/agent-targets.test.ts b/src/lib/agent-targets.test.ts index 73feee3..5a8463c 100644 --- a/src/lib/agent-targets.test.ts +++ b/src/lib/agent-targets.test.ts @@ -1,930 +1,347 @@ import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { VERSION } from '../version.js'; import { + type AgentTarget, + CANONICAL_SKILLS_DIR, DEFAULT_SKILLS, - MANAGED_SECTION_BEGIN, - MANAGED_SECTION_END, - ONBOARD_CODEX_LINE, - SKILL_DESCRIPTION, - SKILL_NAME, SKILLS, TARGETS, + TARGET_ALIASES, + acceptedTargetTokens, bodyHash12, - buildCodexAggregate, buildSkillMarker, - codexContentFor, - loadCodexSkillBody, - loadSkillBody, - loadSkillBodyFor, + canonicalSkillDir, + canonicalSkillFile, + loadSkill, + loadSkillFull, + parseSkillFrontmatter, parseSkillMarker, pathFor, - renderForTarget, - renderOwnFileWithMarker, + renderCanonical, + renderCanonicalWithMarker, + resolveTarget, + targetLandingDir, } from './agent-targets.js'; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Parse the `description:` value out of a YAML frontmatter block. - * The frontmatter is delimited by `---` lines at the top of the file. - * The description value is a single line (no folded/literal block scalars). - */ -function parseFrontmatterDescription(content: string): string | undefined { - // Tolerate CRLF so a Windows checkout (autocrlf) doesn't leave a trailing - // \r on the description and break the byte-identical comparisons. - const lines = content.split(/\r?\n/); - let inFrontmatter = false; - for (const line of lines) { - if (line.trim() === '---') { - if (!inFrontmatter) { - inFrontmatter = true; - continue; - } else { - // End of frontmatter - break; - } - } - if (inFrontmatter && line.startsWith('description: ')) { - return line.slice('description: '.length); - } - } - return undefined; -} - -// Load skill-template.md from repo root (vitest cwd = repo root). -const templateRaw = readFileSync('docs/cli-v1-agent-install/skill-template.md', 'utf8'); -const templateDescription = parseFrontmatterDescription(templateRaw); - -// Load onboard-skill-template.md from repo root. -const onboardTemplateRaw = readFileSync( - 'docs/cli-v1-agent-install/onboard-skill-template.md', - 'utf8', -); -const onboardTemplateDescription = parseFrontmatterDescription(onboardTemplateRaw); - -// Stub body for unit tests that don't need the real file, so tests are fast -// and deterministic regardless of asset path resolution. -const STUB_BODY = `# TestSprite Verification Loop - -The verification-loop autopilot - -testsprite test run --wait --target-url --timeout 600 - -testsprite test artifact get --out ./out/ -`; +// A stub SKILL.md (frontmatter + body) used to keep render/marker tests off disk. +const STUB_SKILL_MD = `---\nname: testsprite-verify\ndescription: stub description\n---\n\n# TestSprite Verification Loop\n\nStub body.\n`; // --------------------------------------------------------------------------- -// TARGETS shape +// Registry shape // --------------------------------------------------------------------------- -describe('TARGETS', () => { - it('has all eight required keys', () => { - const keys = Object.keys(TARGETS).sort(); - expect(keys).toEqual([ +describe('TARGETS registry', () => { + it('canonical directory is .agents/skills', () => { + expect(CANONICAL_SKILLS_DIR).toBe('.agents/skills'); + }); + + // Exact key set — any add/remove/reorder surfaces here for review. + it('exposes the full standard agent set', () => { + expect(Object.keys(TARGETS)).toEqual([ + 'aider-desk', + 'amp', 'antigravity', - 'claude', + 'antigravity-cli', + 'astrbot', + 'autohand-code', + 'augment', + 'bob', + 'claude-code', + 'openclaw', 'cline', + 'codearts-agent', + 'codebuddy', + 'codemaker', + 'codestudio', 'codex', - 'copilot', + 'command-code', + 'continue', + 'cortex', + 'crush', 'cursor', - 'kiro', + 'deepagents', + 'devin', + 'dexto', + 'droid', + 'eve', + 'firebender', + 'forgecode', + 'gemini-cli', + 'github-copilot', + 'goose', + 'hermes-agent', + 'inference-sh', + 'jazz', + 'junie', + 'iflow-cli', + 'kilo', + 'kimi-code-cli', + 'kiro-cli', + 'kode', + 'lingma', + 'loaf', + 'mcpjam', + 'mistral-vibe', + 'moxby', + 'mux', + 'opencode', + 'openhands', + 'ona', + 'pi', + 'qoder', + 'qoder-cn', + 'qwen-code', + 'replit', + 'reasonix', + 'roo', + 'rovodev', + 'tabnine-cli', + 'terramind', + 'tinycloud', + 'trae', + 'trae-cn', + 'warp', 'windsurf', + 'zed', + 'zcode', + 'zencoder', + 'zenflow', + 'neovate', + 'pochi', + 'promptscript', + 'adal', ]); }); - it('claude is GA', () => { - expect(TARGETS.claude.status).toBe('ga'); - }); - - it('cursor, cline, windsurf, copilot, antigravity, kiro, and codex are experimental', () => { - expect(TARGETS.cursor.status).toBe('experimental'); - expect(TARGETS.cline.status).toBe('experimental'); - expect(TARGETS.windsurf.status).toBe('experimental'); - expect(TARGETS.copilot.status).toBe('experimental'); - expect(TARGETS.antigravity.status).toBe('experimental'); - expect(TARGETS.kiro.status).toBe('experimental'); - expect(TARGETS.codex.status).toBe('experimental'); - }); - - it('each target has a non-empty POSIX path', () => { - for (const [, spec] of Object.entries(TARGETS)) { - expect(spec.path.length).toBeGreaterThan(0); - expect(spec.path).not.toContain('\\'); + it('every entry is internally consistent (checked for ALL targets, not spot-checked)', () => { + for (const [id, spec] of Object.entries(TARGETS)) { + expect(spec.displayName.length, `${id} displayName`).toBeGreaterThan(0); + // skillsDir is the canonical shared dir or an agent-local /skills path. + expect( + spec.skillsDir === CANONICAL_SKILLS_DIR || + spec.skillsDir === 'skills' || + spec.skillsDir.endsWith('/skills'), + `${id} skillsDir "${spec.skillsDir}"`, + ).toBe(true); + // universal === "reads the canonical dir directly". + expect(spec.universal, `${id} universal`).toBe(spec.skillsDir === CANONICAL_SKILLS_DIR); } }); - - it('own-file targets have mode own-file', () => { - expect(TARGETS.claude.mode).toBe('own-file'); - expect(TARGETS.antigravity.mode).toBe('own-file'); - expect(TARGETS.cursor.mode).toBe('own-file'); - expect(TARGETS.cline.mode).toBe('own-file'); - expect(TARGETS.kiro.mode).toBe('own-file'); - expect(TARGETS.windsurf.mode).toBe('own-file'); - expect(TARGETS.copilot.mode).toBe('own-file'); - }); - - it('codex target has mode managed-section', () => { - expect(TARGETS.codex.mode).toBe('managed-section'); - }); - - it('codex target path is AGENTS.md', () => { - expect(TARGETS.codex.path).toBe('AGENTS.md'); - }); -}); - -// --------------------------------------------------------------------------- -// SKILL_DESCRIPTION -// --------------------------------------------------------------------------- - -describe('SKILL_DESCRIPTION', () => { - it('is ≤ 1536 characters (claude description cap)', () => { - expect(SKILL_DESCRIPTION.length).toBeLessThanOrEqual(1536); - }); - - it('is byte-identical to skill-template.md frontmatter description', () => { - expect(templateDescription).toBeDefined(); - expect(SKILL_DESCRIPTION).toBe(templateDescription); - }); - - it('begins with TestSprite', () => { - expect(SKILL_DESCRIPTION.startsWith('TestSprite')).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// SKILL_NAME -// --------------------------------------------------------------------------- - -describe('SKILL_NAME', () => { - it('is "testsprite-verify"', () => { - expect(SKILL_NAME).toBe('testsprite-verify'); - }); -}); - -// --------------------------------------------------------------------------- -// loadSkillBody -// --------------------------------------------------------------------------- - -describe('loadSkillBody', () => { - it('returns a non-empty string when using the injectable read stub', () => { - const body = loadSkillBody(() => STUB_BODY); - expect(body).toBe(STUB_BODY); - }); - - it('real loadSkillBody() reads the actual asset and starts with the TestSprite Verification Loop H1', () => { - // This exercises the real URL resolution path — proves the asset is reachable. - const body = loadSkillBody(); - expect(body.length).toBeGreaterThan(0); - expect(body.trimStart().startsWith('# TestSprite Verification Loop')).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// renderForTarget — frontmatter shape per target -// --------------------------------------------------------------------------- - -describe('renderForTarget("claude")', () => { - const result = renderForTarget('claude', 'testsprite-verify', STUB_BODY); - - it('returns the correct path', () => { - expect(result.path).toBe('.claude/skills/testsprite-verify/SKILL.md'); - }); - - it('frontmatter contains name: testsprite-verify', () => { - expect(result.content).toContain('name: testsprite-verify'); - }); - - it('frontmatter contains description:', () => { - expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`); - }); - - it('content ends with a trailing newline', () => { - expect(result.content.endsWith('\n')).toBe(true); - }); -}); - -describe('renderForTarget("antigravity")', () => { - const result = renderForTarget('antigravity', 'testsprite-verify', STUB_BODY); - - it('returns the correct path', () => { - expect(result.path).toBe('.agents/skills/testsprite-verify/SKILL.md'); - }); - - it('frontmatter contains name: testsprite-verify', () => { - expect(result.content).toContain('name: testsprite-verify'); - }); - - it('frontmatter contains description:', () => { - expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`); - }); -}); - -describe('renderForTarget("kiro")', () => { - const result = renderForTarget('kiro', 'testsprite-verify', STUB_BODY); - - it('returns the correct path', () => { - expect(result.path).toBe('.kiro/skills/testsprite-verify/SKILL.md'); - }); - - it('frontmatter contains name: testsprite-verify', () => { - expect(result.content).toContain('name: testsprite-verify'); - }); - - it('frontmatter contains description:', () => { - expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`); - }); -}); - -describe('renderForTarget("claude") vs renderForTarget("antigravity")', () => { - it('produce the same frontmatter lines (name + description)', () => { - const claude = renderForTarget('claude', 'testsprite-verify', STUB_BODY); - const antigravity = renderForTarget('antigravity', 'testsprite-verify', STUB_BODY); - - // Extract the frontmatter block from each - const extractFrontmatter = (content: string): string => { - const match = /^---\n([\s\S]*?)\n---/.exec(content); - return match?.[1] ?? ''; - }; - - expect(extractFrontmatter(claude.content)).toBe(extractFrontmatter(antigravity.content)); - }); - - it('differ only in their landing path', () => { - const claude = renderForTarget('claude', 'testsprite-verify', STUB_BODY); - const antigravity = renderForTarget('antigravity', 'testsprite-verify', STUB_BODY); - - expect(claude.path).not.toBe(antigravity.path); - // Body content should be identical - expect(claude.content).toBe(antigravity.content); - }); -}); - -describe('renderForTarget("cursor")', () => { - const result = renderForTarget('cursor', 'testsprite-verify', STUB_BODY); - - it('returns the correct path', () => { - expect(result.path).toBe('.cursor/rules/testsprite-verify.mdc'); - }); - - it('frontmatter has alwaysApply: false', () => { - expect(result.content).toContain('alwaysApply: false'); - }); - - it('frontmatter has description:', () => { - expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`); - }); - - it('frontmatter does NOT have a globs: line', () => { - // Extract frontmatter block - const match = /^---\n([\s\S]*?)\n---/.exec(result.content); - const fm = match?.[1] ?? ''; - expect(fm).not.toContain('globs:'); - }); - - it('frontmatter does NOT have a name: line', () => { - const match = /^---\n([\s\S]*?)\n---/.exec(result.content); - const fm = match?.[1] ?? ''; - expect(fm).not.toContain('name:'); - }); -}); - -describe('renderForTarget("cline")', () => { - const result = renderForTarget('cline', 'testsprite-verify', STUB_BODY); - - it('returns the correct path', () => { - expect(result.path).toBe('.clinerules/testsprite-verify.md'); - }); - - it('has no --- frontmatter fence', () => { - expect(result.content).not.toContain('---'); - }); - - it('starts with the TestSprite Verification Loop H1 (the body H1)', () => { - expect(result.content.trimStart().startsWith('# TestSprite Verification Loop')).toBe(true); - }); -}); - -describe('renderForTarget("windsurf")', () => { - const result = renderForTarget('windsurf', 'testsprite-verify', STUB_BODY); - - it('returns the .windsurf/rules path', () => { - expect(result.path).toBe('.windsurf/rules/testsprite-verify.md'); - }); - - it('uses the Cascade frontmatter (trigger: model_decision + description)', () => { - expect(result.content.startsWith('---\n')).toBe(true); - expect(result.content).toContain('trigger: model_decision'); - expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`); - }); - - it('does NOT carry the Claude/Cursor frontmatter keys', () => { - const match = /^---\n([\s\S]*?)\n---/.exec(result.content); - const fm = match?.[1] ?? ''; - expect(fm).not.toContain('name:'); // claude key - expect(fm).not.toContain('alwaysApply:'); // cursor .mdc key - }); -}); - -describe('windsurf renders within the rules-file budget', () => { - // Regression: a `.windsurf/rules/*.md` file caps at ~12 K characters and - // Cascade silently truncates beyond that. The full verify body (~22 KB) would - // be cut in half, so windsurf renders the COMPACT body for verify (its trimmed - // codex asset) and the full body for onboard (which already fits). Uses the - // REAL bodies (no stub) so the size reflects what a user receives. - for (const skill of DEFAULT_SKILLS) { - it(`${skill} fits under 12 000 characters`, () => { - const r = renderForTarget('windsurf', skill); - expect(r.content.length).toBeLessThan(12_000); - }); - } - - it('verify uses the compact body (smaller than the full claude render)', () => { - const windsurf = renderForTarget('windsurf', 'testsprite-verify'); - const claude = renderForTarget('claude', 'testsprite-verify'); - expect(windsurf.content.length).toBeLessThan(claude.content.length); - // The full-body-only intro line is absent from the compact body... - expect(claude.content).toContain('The verification loop that flies'); - expect(windsurf.content).not.toContain('The verification loop that flies'); - // ...but the load-bearing command survives. - expect(windsurf.content).toContain('testsprite test run'); - }); -}); - -describe('renderForTarget("copilot")', () => { - const result = renderForTarget('copilot', 'testsprite-verify', STUB_BODY); - - it('returns the .github/instructions path', () => { - expect(result.path).toBe('.github/instructions/testsprite-verify.instructions.md'); - }); - - it('uses the Copilot frontmatter (applyTo + description)', () => { - expect(result.content.startsWith('---\n')).toBe(true); - expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`); - expect(result.content).toContain("applyTo: '**'"); - }); - - it('does NOT carry the Claude/Cursor/Windsurf frontmatter keys', () => { - const match = /^---\n([\s\S]*?)\n---/.exec(result.content); - const fm = match?.[1] ?? ''; - expect(fm).not.toContain('name:'); // claude key - expect(fm).not.toContain('alwaysApply:'); // cursor .mdc key - expect(fm).not.toContain('trigger:'); // windsurf Cascade key - }); - - it('renders the compact verify body (applyTo:** is always-on, so keep it small)', () => { - // Uses the REAL bodies (no stub): copilot always-injects, so like windsurf it - // ships the trimmed verify body while keeping the load-bearing command. - const copilot = renderForTarget('copilot', 'testsprite-verify'); - const claude = renderForTarget('claude', 'testsprite-verify'); - expect(copilot.content.length).toBeLessThan(claude.content.length); - expect(copilot.content).not.toContain('The verification loop that flies'); - expect(copilot.content).toContain('testsprite test run'); - }); }); // --------------------------------------------------------------------------- -// Content integrity — load-bearing command strings must survive any body trim +// Aliases + resolveTarget // --------------------------------------------------------------------------- -describe('content integrity — own-file targets', () => { - // Full-body own-file targets. Compact-body targets (windsurf, copilot) are - // excluded — they render the trimmed verify body; see their dedicated tests. - const ownFileTargets: Array<'claude' | 'cursor' | 'cline' | 'antigravity' | 'kiro'> = [ - 'claude', - 'cursor', - 'cline', - 'antigravity', - 'kiro', - ]; - - // Use the real body for these checks, since we're guarding against trimming. - for (const target of ownFileTargets) { - describe(`target: ${target}`, () => { - const result = renderForTarget(target, 'testsprite-verify'); - - it('contains the TestSprite Verification Loop H1', () => { - // The skill body opens with the renamed H1. - expect(result.content).toContain('# TestSprite Verification Loop'); - expect(result.content).toContain('The verification loop that flies'); - }); - - it('contains testsprite test run', () => { - expect(result.content).toContain('testsprite test run'); - }); - - it('contains --wait flag', () => { - expect(result.content).toContain('--wait'); - }); - - it('contains test artifact get', () => { - expect(result.content).toContain('test artifact get'); - }); - }); - } -}); - -// --------------------------------------------------------------------------- -// loadCodexSkillBody -// --------------------------------------------------------------------------- - -describe('loadCodexSkillBody', () => { - it('returns a non-empty string when using the injectable read stub', () => { - const body = loadCodexSkillBody(() => '# stub codex body'); - expect(body).toBe('# stub codex body'); - }); - - it('real loadCodexSkillBody() reads the actual asset and starts with the TestSprite Verification Loop H1', () => { - const body = loadCodexSkillBody(); - expect(body.length).toBeGreaterThan(0); - expect(body.trimStart().startsWith('# TestSprite Verification Loop')).toBe(true); +describe('TARGET_ALIASES + resolveTarget', () => { + it('every alias maps to a real canonical id', () => { + for (const [alias, id] of Object.entries(TARGET_ALIASES)) { + expect(TARGETS[id], `alias ${alias} → ${id}`).toBeDefined(); + } }); - it('testsprite-verify.codex.md is ≤ 6144 bytes (6 KiB trim budget)', () => { - const body = loadCodexSkillBody(); - expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(6144); + it('no alias equals its canonical id (an identical alias would be pointless)', () => { + for (const [alias, id] of Object.entries(TARGET_ALIASES)) { + expect(alias, `${alias} === ${id}`).not.toBe(id); + } }); -}); - -// --------------------------------------------------------------------------- -// content integrity — current auth command names -// --------------------------------------------------------------------------- -describe('content integrity — testsprite-verify auth commands', () => { - it('uses current auth/setup commands in every canonical verify skill asset', () => { - const assets = [ - ['docs template', templateRaw], - ['own-file skill body', loadSkillBodyFor('testsprite-verify')], - ['codex skill body', codexContentFor('testsprite-verify')], - ] as const; - - for (const [name, content] of assets) { - expect(content, name).toContain('testsprite auth status'); - expect(content, name).toContain('testsprite setup'); - expect(content, name).not.toContain('auth whoami'); - expect(content, name).not.toContain('auth configure'); + it('resolveTarget returns every canonical id as-is', () => { + for (const id of Object.keys(TARGETS)) { + expect(resolveTarget(id), id).toBe(id); } }); -}); -// --------------------------------------------------------------------------- -// MANAGED_SECTION sentinels -// --------------------------------------------------------------------------- - -describe('MANAGED_SECTION sentinels', () => { - it('BEGIN sentinel is an HTML comment', () => { - expect(MANAGED_SECTION_BEGIN.startsWith('')).toBe(true); + it('resolveTarget resolves every alias to its mapped id', () => { + for (const [alias, id] of Object.entries(TARGET_ALIASES)) { + expect(resolveTarget(alias), alias).toBe(id); + } }); - it('END sentinel is an HTML comment', () => { - expect(MANAGED_SECTION_END.startsWith('')).toBe(true); + it('resolveTarget rejects an unknown token', () => { + expect(resolveTarget('definitely-not-an-agent')).toBeNull(); }); - it('sentinels contain testsprite identity marker', () => { - expect(MANAGED_SECTION_BEGIN.toLowerCase()).toContain('testsprite'); - expect(MANAGED_SECTION_END.toLowerCase()).toContain('testsprite'); + it('acceptedTargetTokens is exactly the ids plus the aliases', () => { + expect(new Set(acceptedTargetTokens())).toEqual( + new Set([...Object.keys(TARGETS), ...Object.keys(TARGET_ALIASES)]), + ); }); }); // --------------------------------------------------------------------------- -// content integrity — codex target +// Path helpers // --------------------------------------------------------------------------- -describe('content integrity — codex target (testsprite-verify.codex.md)', () => { - it('contains testsprite test run (load-bearing command)', () => { - const body = loadCodexSkillBody(); - expect(body).toContain('testsprite test run'); - }); - - it('contains --wait flag (load-bearing command string)', () => { - const body = loadCodexSkillBody(); - expect(body).toContain('--wait'); - }); - - it('contains test artifact get (load-bearing command)', () => { - const body = loadCodexSkillBody(); - expect(body).toContain('test artifact get'); - }); - - it('renderForTarget("codex") path is AGENTS.md', () => { - const STUB_CODEX_BODY = - '# TestSprite Verification Loop\ntestsprite test run\n--wait\ntest artifact get\n'; - const result = renderForTarget('codex', 'testsprite-verify', STUB_CODEX_BODY); - expect(result.path).toBe('AGENTS.md'); - }); - - it('renderForTarget("codex") content is the body unwrapped (no frontmatter)', () => { - const STUB_CODEX_BODY = - '# TestSprite Verification Loop\ntestsprite test run\n--wait\ntest artifact get\n'; - const result = renderForTarget('codex', 'testsprite-verify', STUB_CODEX_BODY); - // codex wrap is identity — no frontmatter fences - expect(result.content).toBe(STUB_CODEX_BODY); - expect(result.content).not.toContain('---'); +describe('path helpers', () => { + it('canonicalSkillDir / canonicalSkillFile', () => { + expect(canonicalSkillDir('testsprite-verify')).toBe('.agents/skills/testsprite-verify'); + expect(canonicalSkillFile('testsprite-verify')).toBe( + '.agents/skills/testsprite-verify/SKILL.md', + ); }); - it('renderForTarget("codex") without body arg uses codex asset (not full skill body)', () => { - // The real codex asset is trimmed (no acronym line). - const result = renderForTarget('codex', 'testsprite-verify'); - // Plain Markdown; no frontmatter fences from own-file wraps - expect(result.content).not.toContain('name: testsprite-verify'); - expect(result.content).not.toContain('alwaysApply:'); + it('pathFor / targetLandingDir reach SKILL.md for every target', () => { + for (const id of Object.keys(TARGETS) as AgentTarget[]) { + const spec = TARGETS[id]!; + const landing = targetLandingDir(id, 'testsprite-verify'); + expect(pathFor(id, 'testsprite-verify'), id).toBe(`${landing}/SKILL.md`); + expect(landing, id).toBe( + spec.universal ? '.agents/skills/testsprite-verify' : `${spec.skillsDir}/testsprite-verify`, + ); + } }); }); // --------------------------------------------------------------------------- -// SKILLS registry +// SKILLS registry — metadata lives in the SKILL.md frontmatter // --------------------------------------------------------------------------- describe('SKILLS registry', () => { - it('has testsprite-verify key', () => { - expect(SKILLS['testsprite-verify']).toBeDefined(); - }); - - it('has testsprite-onboard key', () => { - expect(SKILLS['testsprite-onboard']).toBeDefined(); - }); - - it('testsprite-verify description is ≤ 1536 characters', () => { - expect(SKILLS['testsprite-verify']!.description.length).toBeLessThanOrEqual(1536); - }); - - it('testsprite-onboard description is ≤ 1536 characters', () => { - expect(SKILLS['testsprite-onboard']!.description.length).toBeLessThanOrEqual(1536); - }); - - it('testsprite-verify description is byte-identical to skill-template.md frontmatter description', () => { - expect(templateDescription).toBeDefined(); - expect(SKILLS['testsprite-verify']!.description).toBe(templateDescription); - }); - - it('testsprite-onboard description is byte-identical to onboard-skill-template.md frontmatter description', () => { - expect(onboardTemplateDescription).toBeDefined(); - expect(SKILLS['testsprite-onboard']!.description).toBe(onboardTemplateDescription); - }); - - it('testsprite-verify has bodyFile testsprite-verify.skill.md', () => { - expect(SKILLS['testsprite-verify']!.bodyFile).toBe('testsprite-verify.skill.md'); - }); - - it('testsprite-onboard has bodyFile testsprite-onboard.skill.md', () => { - expect(SKILLS['testsprite-onboard']!.bodyFile).toBe('testsprite-onboard.skill.md'); + it('ships exactly verify and onboard, in install order', () => { + expect(Object.keys(SKILLS)).toEqual(['testsprite-verify', 'testsprite-onboard']); + expect([...DEFAULT_SKILLS]).toEqual(['testsprite-verify', 'testsprite-onboard']); }); - it('testsprite-verify codex kind is full', () => { - const codex = SKILLS['testsprite-verify']!.codex; - expect(codex.kind).toBe('full'); - }); - - it('testsprite-onboard codex kind is line', () => { - const codex = SKILLS['testsprite-onboard']!.codex; - expect(codex.kind).toBe('line'); - }); -}); - -// --------------------------------------------------------------------------- -// DEFAULT_SKILLS -// --------------------------------------------------------------------------- - -describe('DEFAULT_SKILLS', () => { - it('equals ["testsprite-verify", "testsprite-onboard"]', () => { - expect(DEFAULT_SKILLS).toEqual(['testsprite-verify', 'testsprite-onboard']); - }); - - it('has exactly two entries', () => { - expect(DEFAULT_SKILLS.length).toBe(2); + it('every entry points at its .skill.md asset', () => { + for (const [id, asset] of Object.entries(SKILLS)) { + expect(asset.file, id).toBe(`${id}.skill.md`); + } }); }); // --------------------------------------------------------------------------- -// pathFor +// Frontmatter parsing + loadSkill // --------------------------------------------------------------------------- -describe('pathFor', () => { - it('claude + testsprite-verify', () => { - expect(pathFor('claude', 'testsprite-verify')).toBe( - '.claude/skills/testsprite-verify/SKILL.md', - ); - }); - - it('antigravity + testsprite-verify', () => { - expect(pathFor('antigravity', 'testsprite-verify')).toBe( - '.agents/skills/testsprite-verify/SKILL.md', - ); - }); - - it('cursor + testsprite-verify', () => { - expect(pathFor('cursor', 'testsprite-verify')).toBe('.cursor/rules/testsprite-verify.mdc'); - }); - - it('cline + testsprite-verify', () => { - expect(pathFor('cline', 'testsprite-verify')).toBe('.clinerules/testsprite-verify.md'); +describe('parseSkillFrontmatter', () => { + it('extracts name + description + body', () => { + const parsed = parseSkillFrontmatter(STUB_SKILL_MD); + expect(parsed.name).toBe('testsprite-verify'); + expect(parsed.description).toBe('stub description'); + expect(parsed.body.trimStart().startsWith('# TestSprite Verification Loop')).toBe(true); }); - it('codex + testsprite-verify', () => { - expect(pathFor('codex', 'testsprite-verify')).toBe('AGENTS.md'); + it('throws when frontmatter is missing', () => { + expect(() => parseSkillFrontmatter('no frontmatter here')).toThrow(/missing frontmatter/); }); - it('claude + testsprite-onboard', () => { - expect(pathFor('claude', 'testsprite-onboard')).toBe( - '.claude/skills/testsprite-onboard/SKILL.md', + it('throws when name/description is missing', () => { + expect(() => parseSkillFrontmatter('---\nname: only-name\n---\nbody')).toThrow( + /missing required name\/description/, ); }); - - it('antigravity + testsprite-onboard', () => { - expect(pathFor('antigravity', 'testsprite-onboard')).toBe( - '.agents/skills/testsprite-onboard/SKILL.md', - ); - }); - - it('cursor + testsprite-onboard', () => { - expect(pathFor('cursor', 'testsprite-onboard')).toBe('.cursor/rules/testsprite-onboard.mdc'); - }); - - it('cline + testsprite-onboard', () => { - expect(pathFor('cline', 'testsprite-onboard')).toBe('.clinerules/testsprite-onboard.md'); - }); - - it('codex + testsprite-onboard is AGENTS.md (shared)', () => { - expect(pathFor('codex', 'testsprite-onboard')).toBe('AGENTS.md'); - }); - - it('TARGETS[t].path === pathFor(t, "testsprite-verify") for every target', () => { - for (const [target] of Object.entries(TARGETS)) { - expect(TARGETS[target as keyof typeof TARGETS].path).toBe( - pathFor(target as Parameters[0], 'testsprite-verify'), - ); - } - }); }); -// --------------------------------------------------------------------------- -// loadSkillBodyFor -// --------------------------------------------------------------------------- - -describe('loadSkillBodyFor', () => { - it('stub read returns the provided stub body for testsprite-verify', () => { - const body = loadSkillBodyFor('testsprite-verify', () => STUB_BODY); - expect(body).toBe(STUB_BODY); - }); - - it('stub read returns the provided stub body for testsprite-onboard', () => { - const ONBOARD_STUB = '# TestSprite: onboard a repo with a seed test suite\nStub.'; - const body = loadSkillBodyFor('testsprite-onboard', () => ONBOARD_STUB); - expect(body).toBe(ONBOARD_STUB); +describe('loadSkill', () => { + it('parses every shipped skill consistently', () => { + for (const id of Object.keys(SKILLS)) { + const skill = loadSkill(id); + expect(skill.name, id).toBe(id); + expect(skill.description.length, `${id} description`).toBeGreaterThan(0); + expect(skill.description.length, `${id} description overlong`).toBeLessThanOrEqual(1536); + expect(skill.full.startsWith(`---\nname: ${id}\n`), id).toBe(true); + expect(skill.body.length, `${id} body`).toBeGreaterThan(0); + expect(skill.body.startsWith('---'), `${id} body leaks frontmatter`).toBe(false); + expect(skill.full.endsWith(skill.body), `${id} full/body split`).toBe(true); + } }); - it('real loadSkillBodyFor("testsprite-verify") starts with the verify H1', () => { - const body = loadSkillBodyFor('testsprite-verify'); - expect(body.trimStart().startsWith('# TestSprite Verification Loop')).toBe(true); + it('verify skill body opens with its title', () => { + expect( + loadSkill('testsprite-verify').body.trimStart().startsWith('# TestSprite Verification Loop'), + ).toBe(true); }); - it('real loadSkillBodyFor("testsprite-onboard") contains the onboard H1', () => { - const body = loadSkillBodyFor('testsprite-onboard'); - expect(body).toContain('# TestSprite: onboard a repo with a seed test suite'); + it('throws on an unknown skill', () => { + expect(() => loadSkill('nope')).toThrow(/unknown skill/); }); - it('unknown skill throws', () => { - expect(() => loadSkillBodyFor('testsprite-unknown')).toThrow('unknown skill'); + it('throws when the registry id does not match the frontmatter name', () => { + const mismatched = STUB_SKILL_MD.replace('name: testsprite-verify', 'name: other-name'); + expect(() => loadSkill('testsprite-verify', () => mismatched)).toThrow(/id mismatch/); }); }); -// --------------------------------------------------------------------------- -// codexContentFor -// --------------------------------------------------------------------------- - -describe('codexContentFor', () => { - it('testsprite-verify (full) contains "testsprite test run"', () => { - const content = codexContentFor('testsprite-verify'); - expect(content).toContain('testsprite test run'); - }); - - it('testsprite-verify (full) contains "--wait"', () => { - const content = codexContentFor('testsprite-verify'); - expect(content).toContain('--wait'); - }); - - it('testsprite-onboard (line) equals ONBOARD_CODEX_LINE', () => { - const content = codexContentFor('testsprite-onboard'); - expect(content).toBe(ONBOARD_CODEX_LINE); - }); - - it('ONBOARD_CODEX_LINE starts with "**First-time setup:**"', () => { - expect(ONBOARD_CODEX_LINE.startsWith('**First-time setup:**')).toBe(true); - }); - - it('unknown skill throws', () => { - expect(() => codexContentFor('testsprite-unknown')).toThrow('unknown skill'); - }); - - it('testsprite-verify with stub read returns stub value', () => { - const content = codexContentFor('testsprite-verify', () => '# stub codex'); - expect(content).toBe('# stub codex'); +describe('loadSkillFull', () => { + it('equals loadSkill(id).full for every skill', () => { + for (const id of Object.keys(SKILLS)) { + expect(loadSkillFull(id), id).toBe(loadSkill(id).full); + } }); }); // --------------------------------------------------------------------------- -// buildCodexAggregate +// renderCanonical // --------------------------------------------------------------------------- -describe('buildCodexAggregate', () => { - it('single verify is byte-identical to loadCodexSkillBody().trimEnd()', () => { - const aggregate = buildCodexAggregate(['testsprite-verify']); - expect(aggregate).toBe(loadCodexSkillBody().trimEnd()); - }); +describe('renderCanonical', () => { + const rendered = renderCanonical('testsprite-verify', () => STUB_SKILL_MD); - it('DEFAULT_SKILLS aggregate contains the verify H1', () => { - const aggregate = buildCodexAggregate(DEFAULT_SKILLS); - expect(aggregate).toContain('# TestSprite Verification Loop'); + it('preserves the asset frontmatter (name + description)', () => { + expect(rendered.startsWith('---\nname: testsprite-verify\n')).toBe(true); + expect(rendered).toContain('description: stub description'); }); - it('DEFAULT_SKILLS aggregate contains the onboard line', () => { - const aggregate = buildCodexAggregate(DEFAULT_SKILLS); - expect(aggregate).toContain('**First-time setup:**'); + it('injects the testsprite-skill marker right after the closing fence', () => { + expect(rendered).toContain(buildSkillMarker('testsprite-verify', STUB_SKILL_MD)); + // marker sits between the closing --- and the body H1 + const afterFence = rendered.slice(rendered.indexOf('\n---\n') + 5); + expect(afterFence.startsWith('`, - ); - expect(marker).toMatch( - /^$/, + ``, ); }); - it('bodyHash12 equals the first 12 hex chars of the body SHA-256', () => { - const fullHex = createHash('sha256').update(STUB_BODY, 'utf8').digest('hex'); - expect(bodyHash12(STUB_BODY)).toBe(fullHex.slice(0, 12)); - }); - - it('parseSkillMarker round-trips a built marker embedded in surrounding content', () => { - const marker = buildSkillMarker('testsprite-verify', STUB_BODY); - const parsed = parseSkillMarker(`# heading\n${marker}\nbody text\n`); + it('parseSkillMarker round-trips a built marker', () => { + const marker = buildSkillMarker('testsprite-onboard', STUB_SKILL_MD); + const parsed = parseSkillMarker(marker); expect(parsed).not.toBeNull(); - expect(parsed?.skill).toBe('testsprite-verify'); - expect(parsed?.version).toBe(VERSION); - expect(parsed?.hash12).toBe(bodyHash12(STUB_BODY)); - expect(parsed?.line).toBe(marker); - }); - - it('parseSkillMarker strips a trailing CR so CRLF checkouts parse identically', () => { - const marker = buildSkillMarker('testsprite-verify', STUB_BODY); - const parsed = parseSkillMarker(`${marker}\r\nrest\r\n`); - expect(parsed?.line).toBe(marker); - }); - - it('parseSkillMarker returns null when no marker line is present', () => { - expect(parseSkillMarker('# Just a heading\n\nProse without any marker.\n')).toBeNull(); - }); - - it('parseSkillMarker ignores the managed-section sentinels (also HTML comments)', () => { - expect(parseSkillMarker(`${MANAGED_SECTION_BEGIN}\nbody\n${MANAGED_SECTION_END}\n`)).toBeNull(); + expect(parsed!.skill).toBe('testsprite-onboard'); + expect(parsed!.version).toBe(VERSION); + expect(parsed!.hash12).toBe(bodyHash12(STUB_SKILL_MD)); + expect(parsed!.line).toBe(marker); }); -}); -describe('render marker placement (own-file targets)', () => { - it('claude render carries the marker on the line right after the closing frontmatter fence', () => { - const { content } = renderForTarget('claude', 'testsprite-verify', STUB_BODY); - const closingFence = '\n---\n'; - const fenceEnd = content.indexOf(closingFence) + closingFence.length; - expect(content.slice(fenceEnd).startsWith(''; - const withForeign = renderOwnFileWithMarker( - 'claude', - 'testsprite-verify', - foreignMarker, - STUB_BODY, - ); - expect(withForeign).toContain(foreignMarker); - // Marker line aside, the bytes match the canonical render exactly. - const canonical = renderForTarget('claude', 'testsprite-verify', STUB_BODY).content; - const currentMarker = buildSkillMarker('testsprite-verify', STUB_BODY); - expect(withForeign.replace(foreignMarker, currentMarker)).toBe(canonical); - }); - - it('renderOwnFileWithMarker rejects the managed-section target', () => { - expect(() => renderOwnFileWithMarker('codex', 'testsprite-verify', 'marker', 'body')).toThrow( - 'own-file', - ); - }); - - it('renderOwnFileWithMarker throws on an unknown skill', () => { - expect(() => renderOwnFileWithMarker('claude', 'testsprite-unknown', 'marker')).toThrow( - 'unknown skill', - ); + it('parseSkillMarker finds the marker mid-file and strips trailing CR', () => { + const marker = buildSkillMarker('testsprite-verify', STUB_SKILL_MD); + const content = `# Title\n\n${marker}\r\nbody`; + expect(parseSkillMarker(content)!.skill).toBe('testsprite-verify'); }); }); diff --git a/src/lib/agent-targets.ts b/src/lib/agent-targets.ts index 7e6f94d..4d78125 100644 --- a/src/lib/agent-targets.ts +++ b/src/lib/agent-targets.ts @@ -2,533 +2,340 @@ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { VERSION } from '../version.js'; -export type AgentTarget = - | 'claude' - | 'cursor' - | 'cline' - | 'antigravity' - | 'codex' - | 'kiro' - | 'windsurf' - | 'copilot'; +/** + * Agent-skill installation, following the [Agent Skills](https://agentskills.io) + * open standard. Each skill has one canonical copy at + * `.agents/skills//SKILL.md`. "Universal" agents read it directly; + * every other agent gets a symlink from its own skills folder back to it. + */ -export interface TargetSpec { - status: 'ga' | 'experimental'; - /** - * Repo-relative landing path for the CANONICAL skill (`testsprite-verify`), - * POSIX separators. Kept for back-compat: `skill-nudge.ts` reads this to detect - * a verify install, and `agent list`/tests reference it. For any skill, derive - * the real path via {@link pathFor} — this field is `pathFor(target, SKILL_NAME)`. - */ - path: string; - /** - * 'own-file': the CLI owns the whole file (claude/cursor/cline/antigravity/windsurf). - * 'managed-section': the CLI writes only a sentinel-delimited section inside - * a potentially user-authored file (codex target, AGENTS.md). - */ - mode: 'own-file' | 'managed-section'; - /** - * When true, render the budget-friendly body (see {@link compactBodyFor}) - * instead of the full own-file skill body. Used for own-file targets whose - * rule files are size-capped — currently `windsurf` (`.windsurf/rules/*.md` - * files cap at ~12 K characters and Cascade silently truncates beyond that, - * which would cut the full ~22 KB verify skill in half). - */ - compactBody?: boolean; - /** - * Wrap a skill body in this target's frontmatter/header. Takes the skill's - * `name`+`description` (own-file targets emit them as frontmatter) and the body. - * No-op for cline (body verbatim) and codex (managed-section authors plain - * Markdown with no frontmatter). - */ - wrap(name: string, description: string, body: string): string; -} +/** Canonical skills directory (POSIX, repo-relative). */ +export const CANONICAL_SKILLS_DIR = '.agents/skills'; // --------------------------------------------------------------------------- -// Skill registry +// Skill registry — name/description live in each SKILL.md's frontmatter. // --------------------------------------------------------------------------- -/** - * How a skill contributes to the codex target's always-on `AGENTS.md` section. - * - * - 'full': inject the skill's trimmed codex body (a `*.codex.md` asset). Used by - * `testsprite-verify` (~6 KiB). - * - 'line': inject a single short line authored inline here. Used by - * `testsprite-onboard` — the full 6-step flow doesn't belong in an always-on, - * 32 KiB-budgeted file, but a one-line signal does. - * - 'none': skill is not represented in AGENTS.md at all (reserved). - */ -export type CodexContribution = - | { kind: 'full'; file: string } - | { kind: 'line'; text: string } - | { kind: 'none' }; - -export interface SkillSpec { - /** Skill name — appears in own-file frontmatter and the landing path. */ - name: string; - /** ≤1536 chars (claude description cap). Byte-identical to its template doc. */ - description: string; - /** Own-file body asset basename under `skills/`, e.g. 'testsprite-verify.skill.md'. */ - bodyFile: string; - /** How this skill contributes to the codex AGENTS.md managed section. */ - codex: CodexContribution; +/** Static index of a shipped skill → its SKILL.md asset. Disk-free on import. */ +interface SkillAsset { + /** Asset basename under `skills/`, e.g. 'testsprite-verify.skill.md'. */ + file: string; } -/** - * `testsprite-onboard` codex contribution — a single always-on line. Kept here - * (not in a `*.codex.md` asset) because it is one line; see {@link CodexContribution}. - */ -export const ONBOARD_CODEX_LINE = - '**First-time setup:** if this repo has no TestSprite tests yet, seed a *broad* first suite across its main user flows — not just one test — each with a concrete, observable assertion, before reporting setup as done.'; - -/** - * The skill registry. Each entry owns its name, description (drift-guarded by a - * byte-identity unit test against a template doc), own-file body asset, and codex - * contribution. `agent install` / `setup` install {@link DEFAULT_SKILLS}; the - * codex target aggregates every installed skill's codex contribution into ONE - * AGENTS.md section. - */ -export const SKILLS: Record = { - 'testsprite-verify': { - name: 'testsprite-verify', - description: - 'TestSprite verification loop — after finishing a feature or fix in a TestSprite-tested repo, use the `testsprite` CLI to run the relevant TestSprite tests against the change and inspect any failure artifacts before reporting the work as done. Use whenever code has changed outside docs/config and is about to be reported complete — by running an existing test that covers the change, or by creating a new TestSprite test (a frontend plan, or a backend Python assertion) and running it to a terminal verdict.', - bodyFile: 'testsprite-verify.skill.md', - codex: { kind: 'full', file: 'testsprite-verify.codex.md' }, - }, - 'testsprite-onboard': { - name: 'testsprite-onboard', - description: - 'Stand up a complete, runnable TestSprite test suite for the current repo at first use — create a project (with a target URL and auth), derive a coherent set of tests from the codebase, batch-create them, and smoke-run a few to a green verdict so the user immediately has something worth running. Use ONLY when a repo has no TestSprite tests yet (a fresh project), right after `testsprite setup`, or when the user asks to "set up / bootstrap / seed tests". This is first-run setup, NOT change verification — once a project already has tests, use the testsprite-verify skill instead.', - bodyFile: 'testsprite-onboard.skill.md', - codex: { kind: 'line', text: ONBOARD_CODEX_LINE }, - }, +/** id → SKILL.md asset. Disk-free on import; metadata is parsed on load. */ +export const SKILLS: Record = { + 'testsprite-verify': { file: 'testsprite-verify.skill.md' }, + 'testsprite-onboard': { file: 'testsprite-onboard.skill.md' }, }; -/** - * Skills installed by `setup` and by `agent install` when no `--skill` subset is - * given. Order is significant for the codex aggregate (verify first, then the - * onboard line as a short addendum). - */ +/** Skills installed by `setup` and `agent install` when `--skill` is omitted. */ export const DEFAULT_SKILLS = ['testsprite-verify', 'testsprite-onboard'] as const; // --------------------------------------------------------------------------- -// Back-compat single-skill exports (= the canonical `testsprite-verify` skill) -// --------------------------------------------------------------------------- - -/** @deprecated The canonical skill name. New code: iterate {@link SKILLS}. */ -export const SKILL_NAME = 'testsprite-verify'; - -/** - * @deprecated The canonical skill's description. New code: - * `SKILLS['testsprite-verify'].description`. Kept so existing importers and the - * byte-identity unit test keep working. - */ -export const SKILL_DESCRIPTION = SKILLS['testsprite-verify']!.description; - -// --------------------------------------------------------------------------- -// Wrappers +// Agent registry // --------------------------------------------------------------------------- -function wrapSkill(name: string, description: string, body: string): string { - return `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`; +export interface TargetSpec { + displayName: string; + /** Project-relative skills directory (POSIX). Universal agents use CANONICAL_SKILLS_DIR. */ + skillsDir: string; + /** True when the agent reads .agents/skills directly (no symlink needed). */ + universal: boolean; } -function wrapMdc(_name: string, description: string, body: string): string { - return `---\ndescription: ${description}\nalwaysApply: false\n---\n\n${body}\n`; -} +/** Standard agent id → its project skills directory and universal flag. */ +export const TARGETS: Record = { + 'aider-desk': { displayName: 'AiderDesk', skillsDir: '.aider-desk/skills', universal: false }, + amp: { displayName: 'Amp', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + antigravity: { displayName: 'Antigravity', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'antigravity-cli': { + displayName: 'Antigravity CLI', + skillsDir: CANONICAL_SKILLS_DIR, + universal: true, + }, + astrbot: { displayName: 'AstrBot', skillsDir: 'data/skills', universal: false }, + 'autohand-code': { + displayName: 'Autohand Code CLI', + skillsDir: '.autohand/skills', + universal: false, + }, + augment: { displayName: 'Augment', skillsDir: '.augment/skills', universal: false }, + bob: { displayName: 'IBM Bob', skillsDir: '.bob/skills', universal: false }, + 'claude-code': { displayName: 'Claude Code', skillsDir: '.claude/skills', universal: false }, + openclaw: { displayName: 'OpenClaw', skillsDir: 'skills', universal: false }, + cline: { displayName: 'Cline', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'codearts-agent': { + displayName: 'CodeArts Agent', + skillsDir: '.codeartsdoer/skills', + universal: false, + }, + codebuddy: { displayName: 'CodeBuddy', skillsDir: '.codebuddy/skills', universal: false }, + codemaker: { displayName: 'Codemaker', skillsDir: '.codemaker/skills', universal: false }, + codestudio: { displayName: 'Code Studio', skillsDir: '.codestudio/skills', universal: false }, + codex: { displayName: 'Codex', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'command-code': { + displayName: 'Command Code', + skillsDir: '.commandcode/skills', + universal: false, + }, + continue: { displayName: 'Continue', skillsDir: '.continue/skills', universal: false }, + cortex: { displayName: 'Cortex Code', skillsDir: '.cortex/skills', universal: false }, + crush: { displayName: 'Crush', skillsDir: '.crush/skills', universal: false }, + cursor: { displayName: 'Cursor', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + deepagents: { displayName: 'Deep Agents', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + devin: { displayName: 'Devin for Terminal', skillsDir: '.devin/skills', universal: false }, + dexto: { displayName: 'Dexto', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + droid: { displayName: 'Droid', skillsDir: '.factory/skills', universal: false }, + eve: { displayName: 'Eve', skillsDir: 'agent/skills', universal: false }, + firebender: { displayName: 'Firebender', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + forgecode: { displayName: 'ForgeCode', skillsDir: '.forge/skills', universal: false }, + 'gemini-cli': { displayName: 'Gemini CLI', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'github-copilot': { + displayName: 'GitHub Copilot', + skillsDir: CANONICAL_SKILLS_DIR, + universal: true, + }, + goose: { displayName: 'Goose', skillsDir: '.goose/skills', universal: false }, + 'hermes-agent': { displayName: 'Hermes Agent', skillsDir: '.hermes/skills', universal: false }, + 'inference-sh': { + displayName: 'inference.sh', + skillsDir: '.inferencesh/skills', + universal: false, + }, + jazz: { displayName: 'Jazz', skillsDir: '.jazz/skills', universal: false }, + junie: { displayName: 'Junie', skillsDir: '.junie/skills', universal: false }, + 'iflow-cli': { displayName: 'iFlow CLI', skillsDir: '.iflow/skills', universal: false }, + kilo: { displayName: 'Kilo Code', skillsDir: '.kilocode/skills', universal: false }, + 'kimi-code-cli': { + displayName: 'Kimi Code CLI', + skillsDir: CANONICAL_SKILLS_DIR, + universal: true, + }, + 'kiro-cli': { displayName: 'Kiro CLI', skillsDir: '.kiro/skills', universal: false }, + kode: { displayName: 'Kode', skillsDir: '.kode/skills', universal: false }, + lingma: { displayName: 'Lingma', skillsDir: '.lingma/skills', universal: false }, + loaf: { displayName: 'Loaf', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + mcpjam: { displayName: 'MCPJam', skillsDir: '.mcpjam/skills', universal: false }, + 'mistral-vibe': { displayName: 'Mistral Vibe', skillsDir: '.vibe/skills', universal: false }, + moxby: { displayName: 'Moxby', skillsDir: '.moxby/skills', universal: false }, + mux: { displayName: 'Mux', skillsDir: '.mux/skills', universal: false }, + opencode: { displayName: 'OpenCode', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + openhands: { displayName: 'OpenHands', skillsDir: '.openhands/skills', universal: false }, + ona: { displayName: 'Ona', skillsDir: '.ona/skills', universal: false }, + pi: { displayName: 'Pi', skillsDir: '.pi/skills', universal: false }, + qoder: { displayName: 'Qoder', skillsDir: '.qoder/skills', universal: false }, + 'qoder-cn': { displayName: 'Qoder CN', skillsDir: '.qoder/skills', universal: false }, + 'qwen-code': { displayName: 'Qwen Code', skillsDir: '.qwen/skills', universal: false }, + replit: { displayName: 'Replit', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + reasonix: { displayName: 'Reasonix', skillsDir: '.reasonix/skills', universal: false }, + roo: { displayName: 'Roo Code', skillsDir: '.roo/skills', universal: false }, + rovodev: { displayName: 'Rovo Dev', skillsDir: '.rovodev/skills', universal: false }, + 'tabnine-cli': { + displayName: 'Tabnine CLI', + skillsDir: '.tabnine/agent/skills', + universal: false, + }, + terramind: { displayName: 'Terramind', skillsDir: '.terramind/skills', universal: false }, + tinycloud: { displayName: 'Tinycloud', skillsDir: '.tinycloud/skills', universal: false }, + trae: { displayName: 'Trae', skillsDir: '.trae/skills', universal: false }, + 'trae-cn': { displayName: 'Trae CN', skillsDir: '.trae/skills', universal: false }, + warp: { displayName: 'Warp', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + windsurf: { displayName: 'Windsurf', skillsDir: '.windsurf/skills', universal: false }, + zed: { displayName: 'Zed', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + zcode: { displayName: 'ZCode', skillsDir: '.zcode/skills', universal: false }, + zencoder: { displayName: 'Zencoder', skillsDir: '.zencoder/skills', universal: false }, + zenflow: { displayName: 'Zenflow', skillsDir: '.zencoder/skills', universal: false }, + neovate: { displayName: 'Neovate', skillsDir: '.neovate/skills', universal: false }, + pochi: { displayName: 'Pochi', skillsDir: '.pochi/skills', universal: false }, + promptscript: { displayName: 'PromptScript', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + adal: { displayName: 'AdaL', skillsDir: '.adal/skills', universal: false }, +}; + +/** Agent id type (one per TARGETS key). */ +export type AgentTarget = keyof typeof TARGETS; /** - * Windsurf (Cascade) reads workspace rules from `.windsurf/rules/*.md` with YAML - * frontmatter. `trigger: model_decision` is the Cascade equivalent of the Cursor - * `.mdc` `alwaysApply: false` mode: only the `description` is surfaced up front, - * and Cascade pulls in the full rule body when the description shows it is - * relevant — exactly the on-demand activation these skills want. (The other - * triggers are `always_on`, `manual`, and `glob`.) + * Legacy short target names → canonical ids. These exist ONLY for backwards + * compatibility with older scripts/docs that used the short names — prefer the + * canonical id. Only mappings where the alias actually differs from the id are + * listed: resolveTarget finds canonical ids directly, so an alias identical to + * its id would be pointless. */ -function wrapWindsurf(_name: string, description: string, body: string): string { - return `---\ntrigger: model_decision\ndescription: ${description}\n---\n\n${body}\n`; +export const TARGET_ALIASES: Record = { + claude: 'claude-code', + kiro: 'kiro-cli', + copilot: 'github-copilot', +}; + +/** Resolve a `--target` token (id or alias) to a canonical id, or null if unknown. */ +export function resolveTarget(raw: string): AgentTarget | null { + if (Object.prototype.hasOwnProperty.call(TARGETS, raw)) return raw as AgentTarget; + return TARGET_ALIASES[raw] ?? null; } -/** - * GitHub Copilot reads path-specific custom instructions from - * `.github/instructions/*.instructions.md` (VS Code / Visual Studio / GitHub - * Copilot Chat). Each file carries YAML frontmatter with `applyTo` — a glob that - * scopes when the instructions attach. `applyTo: '**'` attaches the guidance to - * every request in the repo, which is what a persistent verification skill wants - * (there is no on-demand "model decides" mode for Copilot instruction files, so - * always-apply is the correct idiom). `description` is surfaced in Copilot's UI. - */ -function wrapCopilot(_name: string, description: string, body: string): string { - return `---\ndescription: ${description}\napplyTo: '**'\n---\n\n${body}\n`; +/** Every accepted `--target` token (ids + aliases), for help/error text. */ +export function acceptedTargetTokens(): string[] { + return [...Object.keys(TARGETS), ...Object.keys(TARGET_ALIASES)]; } // --------------------------------------------------------------------------- -// Landing paths +// Paths // --------------------------------------------------------------------------- -/** - * Repo-relative landing path for a given skill on a given target (POSIX - * separators). Own-file targets embed the skill name in the path so multiple - * skills coexist; the codex target always lands at the single shared `AGENTS.md` - * (every skill's codex contribution is merged into one managed section there). - */ -export function pathFor(target: AgentTarget, skill: string): string { - switch (target) { - case 'claude': - return `.claude/skills/${skill}/SKILL.md`; - case 'antigravity': - return `.agents/skills/${skill}/SKILL.md`; - case 'cursor': - return `.cursor/rules/${skill}.mdc`; - case 'cline': - return `.clinerules/${skill}.md`; - case 'kiro': - return `.kiro/skills/${skill}/SKILL.md`; - case 'windsurf': - return `.windsurf/rules/${skill}.md`; - case 'copilot': - return `.github/instructions/${skill}.instructions.md`; - case 'codex': - return 'AGENTS.md'; - } +/** `.agents/skills/` — the real SKILL.md location every agent path resolves to. */ +export function canonicalSkillDir(skill: string): string { + return `${CANONICAL_SKILLS_DIR}/${skill}`; } -export const TARGETS: Record = { - claude: { - status: 'ga', - path: pathFor('claude', SKILL_NAME), - mode: 'own-file', - wrap: wrapSkill, - }, - antigravity: { - status: 'experimental', - path: pathFor('antigravity', SKILL_NAME), - mode: 'own-file', - wrap: wrapSkill, - }, - cursor: { - status: 'experimental', - path: pathFor('cursor', SKILL_NAME), - mode: 'own-file', - wrap: wrapMdc, - }, - cline: { - status: 'experimental', - path: pathFor('cline', SKILL_NAME), - mode: 'own-file', - wrap: (_name, _description, body) => body, - }, - kiro: { - status: 'experimental', - path: pathFor('kiro', SKILL_NAME), - mode: 'own-file', - // kiro reads SKILL.md files with name/description frontmatter, same as - // claude/antigravity, so it shares the wrapSkill wrapper. - wrap: wrapSkill, - }, - windsurf: { - status: 'experimental', - path: pathFor('windsurf', SKILL_NAME), - mode: 'own-file', - // Windsurf rules files are budget-capped (~12 K chars per `.windsurf/rules/*.md`), - // so render the compact body per skill (see compactBodyFor). - compactBody: true, - wrap: wrapWindsurf, - }, - copilot: { - status: 'experimental', - path: pathFor('copilot', SKILL_NAME), - mode: 'own-file', - // GitHub Copilot path-specific instructions: frontmatter carries `applyTo`. - // `applyTo: '**'` means the file is ALWAYS injected into Copilot requests - // (there is no on-demand "model decides" mode like Cursor/Windsurf), so - // render the compact body to keep the always-on context cost small — the - // same reasoning that drives windsurf's compact render. - compactBody: true, - wrap: wrapCopilot, - }, - /** - * codex target — managed-section mode. - * - * Codex auto-loads AGENTS.md from the project root (always-on, 32 KiB budget - * for the whole file). Unlike own-file targets, we must NOT clobber a user's - * existing AGENTS.md: we write only a sentinel-delimited section so other - * project instructions coexist. The sentinel pair is the canonical identity - * marker; the content between them is ours to replace. EVERY installed skill's - * codex contribution is aggregated into this one section (see - * {@link buildCodexAggregate}). - * - * --force with managed-section: replaces the section unconditionally but - * NEVER destroys content outside the sentinels. No whole-file .bak is written - * for a section-only change — only a whole-file backup makes sense if the - * entire file was ours to own (own-file mode). User content is never at risk. - */ - codex: { - status: 'experimental', - path: pathFor('codex', SKILL_NAME), - mode: 'managed-section', - // wrap is a no-op for managed-section — content is authored as plain Markdown - // with no frontmatter (AGENTS.md is plain prose, not a skill schema). - wrap: (_name, _description, body) => body, - }, -}; +/** `.agents/skills//SKILL.md`. */ +export function canonicalSkillFile(skill: string): string { + return `${canonicalSkillDir(skill)}/SKILL.md`; +} + +/** Directory the agent reads the skill from: canonical (universal) or a symlink to it. */ +export function targetLandingDir(target: AgentTarget, skill: string): string { + const spec = TARGETS[target]!; + return spec.universal ? canonicalSkillDir(skill) : `${spec.skillsDir}/${skill}`; +} -/** Sentinel pair that bounds our managed section in AGENTS.md. */ -export const MANAGED_SECTION_BEGIN = - ''; -export const MANAGED_SECTION_END = ''; +/** SKILL.md path a given agent reads (canonical, or via the symlink for non-universal agents). */ +export function pathFor(target: AgentTarget, skill: string): string { + return `${targetLandingDir(target, skill)}/SKILL.md`; +} // --------------------------------------------------------------------------- -// Install marker (stale-skill detection, issue #123) +// Install marker — lets `agent status` detect stale/edited installs. // --------------------------------------------------------------------------- -/** - * Hex characters of the canonical body's SHA-256 kept in the install marker. - * 12 hex chars (48 bits) is ample for drift DETECTION (equality against bodies - * this CLI ships); the marker is provenance metadata, not a security boundary. - */ const MARKER_HASH_HEX_LENGTH = 12; -/** - * When one marker covers several skills (the codex managed section aggregates - * every installed skill), their names are joined with this separator in the - * marker's skill field. Skill names never contain '+' (see {@link SKILLS} keys). - */ -export const MARKER_SKILL_SEPARATOR = '+'; - -/** - * Marker line shape: ``. - * An HTML comment is inert in every target format (SKILL.md, .mdc, .clinerules - * markdown, AGENTS.md). Built via `new RegExp` so the hash length stays bound - * to {@link MARKER_HASH_HEX_LENGTH}. - */ const SKILL_MARKER_LINE_RE = new RegExp( `^$`, ); -/** - * First {@link MARKER_HASH_HEX_LENGTH} hex chars of the SHA-256 of a canonical - * skill body. The hash covers the CANONICAL BODY ONLY (pre-wrap, pre-marker), - * so writing the marker into the rendered artifact never changes the hash the - * marker itself carries. - */ -export function bodyHash12(canonicalBody: string): string { +/** First 12 hex chars of the SHA-256 of canonical SKILL.md content (the drift fingerprint). */ +export function bodyHash12(canonicalContent: string): string { return createHash('sha256') - .update(canonicalBody, 'utf8') + .update(canonicalContent, 'utf8') .digest('hex') .slice(0, MARKER_HASH_HEX_LENGTH); } -/** - * Build the provenance marker line for a skill (or a - * {@link MARKER_SKILL_SEPARATOR}-joined skill set) and its canonical body. - * `agent status` compares this fingerprint against the bodies the running CLI - * ships to detect silently stale installs. - */ -export function buildSkillMarker(skillName: string, canonicalBody: string): string { - return ``; +/** Build the provenance marker line embedded in each written SKILL.md. */ +export function buildSkillMarker(skillName: string, canonicalContent: string): string { + return ``; } /** A marker line parsed back into its fields. */ export interface ParsedSkillMarker { - /** Skill name, or several names joined with {@link MARKER_SKILL_SEPARATOR}. */ skill: string; - /** CLI version that wrote the artifact. */ version: string; - /** First 12 hex chars of the canonical body's SHA-256 at install time. */ hash12: string; - /** The exact marker line (trailing CR/whitespace stripped) as found. */ + /** The exact marker line as found (trailing CR/whitespace stripped). */ line: string; } -/** - * Find the first testsprite-skill marker line in `content`, or null when the - * content carries none (a pre-marker install). Lines are matched whole with - * trailing CR/whitespace stripped, so CRLF checkouts parse identically. - */ +/** Find and parse the first testsprite-skill marker line, or null if none. */ export function parseSkillMarker(content: string): ParsedSkillMarker | null { for (const rawLine of content.split('\n')) { - const line = rawLine.trimEnd(); - const matched = SKILL_MARKER_LINE_RE.exec(line); + const matched = SKILL_MARKER_LINE_RE.exec(rawLine.trimEnd()); if (matched) { - return { skill: matched[1]!, version: matched[2]!, hash12: matched[3]!, line }; + return { + skill: matched[1]!, + version: matched[2]!, + hash12: matched[3]!, + line: rawLine.trimEnd(), + }; } } return null; } +// --------------------------------------------------------------------------- +// Skill loading and rendering +// --------------------------------------------------------------------------- + type ReadFn = (url: URL) => string; const defaultRead: ReadFn = (url: URL) => readFileSync(url, 'utf8'); -// --------------------------------------------------------------------------- -// Asset loaders -// --------------------------------------------------------------------------- - -/** - * Resolve a `skills/` asset. `../../skills/...` resolves to the repo-root - * `skills/` directory in BOTH source (vitest: `src/lib/` → `../../skills`) and - * the built/published package (`dist/lib/` → `../../skills` = package root). The - * directory ships verbatim via package.json `files`, so no build-time copy step - * is needed. Injectable `read` keeps unit tests off disk. - */ +/** Resolve a `skills/` asset (ships verbatim via package.json `files`). */ function readSkillAsset(file: string, read: ReadFn): string { return read(new URL(`../../skills/${file}`, import.meta.url)); } -/** Load a skill's own-file body by skill name (frontmatter is added by `wrap`). */ -export function loadSkillBodyFor(skill: string, read: ReadFn = defaultRead): string { - const spec = SKILLS[skill]; - if (!spec) throw new Error(`unknown skill: ${skill}`); - return readSkillAsset(spec.bodyFile, read); -} - -/** - * Budget-friendly body for an own-file target whose rule files are size-capped - * (e.g. windsurf). For a skill that ships a trimmed codex asset (`codex.kind === - * 'full'`, e.g. `testsprite-verify` — full body ~22 KB, codex ~5 KB) we render - * that compact asset so the wrapped file stays under the cap. For skills whose - * codex contribution is only a one-liner (`'line'`/`'none'`, e.g. - * `testsprite-onboard`), the one-liner is useless as a standalone rule and the - * full own-file body (~6.5 KB) already fits the budget — so the full body is - * used. - */ -export function compactBodyFor(skill: string, read: ReadFn = defaultRead): string { - const spec = SKILLS[skill]; - if (!spec) throw new Error(`unknown skill: ${skill}`); - return spec.codex.kind === 'full' - ? readSkillAsset(spec.codex.file, read) - : loadSkillBodyFor(skill, read); -} - -/** - * Resolve a skill's codex (AGENTS.md) contribution as a Markdown string. - * 'full' → read the `*.codex.md` asset; 'line' → the inline one-liner; 'none' → ''. - */ -export function codexContentFor(skill: string, read: ReadFn = defaultRead): string { - const spec = SKILLS[skill]; - if (!spec) throw new Error(`unknown skill: ${skill}`); - const c = spec.codex; - if (c.kind === 'full') return readSkillAsset(c.file, read); - if (c.kind === 'line') return c.text; - return ''; +/** Parsed SKILL.md: frontmatter metadata plus the body that follows it. */ +export interface ParsedSkill { + name: string; + description: string; + /** Content after the closing `---` fence. */ + body: string; + /** Entire file — the canonical SKILL.md bytes. */ + full: string; } /** - * Compose the codex managed-section BODY (sans sentinels) from several skills: - * each skill's codex contribution, trimmed, joined by a blank line, in the given - * order. A single `['testsprite-verify']` aggregate is byte-identical to the old - * single-skill codex body, so existing AGENTS.md installs round-trip unchanged. + * Parse a SKILL.md's frontmatter (name + description) and body, without a YAML + * dependency. Our descriptions are single-line plain scalars, so a line-oriented + * parse suffices. */ -export function buildCodexAggregate(skills: readonly string[], read: ReadFn = defaultRead): string { - return skills - .map(s => codexContentFor(s, read).trimEnd()) - .filter(Boolean) - .join('\n\n'); +export function parseSkillFrontmatter(raw: string): { + name: string; + description: string; + body: string; +} { + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!m) throw new Error('skill asset missing frontmatter (--- ... ---)'); + const fm = m[1]!; + const name = fm.match(/^name:\s*(.*)$/m)?.[1]?.trim() ?? ''; + const description = fm.match(/^description:\s*(.*)$/m)?.[1]?.trim() ?? ''; + if (!name || !description) { + throw new Error('skill frontmatter missing required name/description'); + } + return { name, description, body: m[2] ?? '' }; } -/** - * Back-compat: the canonical verify skill body (own-file). Kept so existing - * importers and the `loadSkillBody(read)` unit-test signature keep working. - * @deprecated Use {@link loadSkillBodyFor}. - */ -export function loadSkillBody(read: ReadFn = defaultRead): string { - return loadSkillBodyFor(SKILL_NAME, read); +/** Load and parse a skill. The registry id must match the frontmatter `name`. */ +export function loadSkill(skill: string, read: ReadFn = defaultRead): ParsedSkill { + const asset = SKILLS[skill]; + if (!asset) throw new Error(`unknown skill: ${skill}`); + const full = readSkillAsset(asset.file, read); + const { name, description, body } = parseSkillFrontmatter(full); + if (name !== skill) { + throw new Error( + `skill id mismatch: registry "${skill}" vs frontmatter "${name}" in ${asset.file}`, + ); + } + return { name, description, body, full }; } -/** - * Back-compat: the canonical verify skill's trimmed codex body. Kept so existing - * importers and the `loadCodexSkillBody(read)` unit-test signature keep working. - * @deprecated Use {@link codexContentFor}. - */ -export function loadCodexSkillBody(read: ReadFn = defaultRead): string { - return codexContentFor(SKILL_NAME, read); +/** Canonical SKILL.md bytes (frontmatter + body), verbatim. */ +export function loadSkillFull(skill: string, read: ReadFn = defaultRead): string { + return loadSkill(skill, read).full; } -// --------------------------------------------------------------------------- -// renderForTarget -// --------------------------------------------------------------------------- - -/** - * Place the marker line inside a wrapped own-file render. - * - * - Wraps that emit YAML frontmatter (claude/antigravity/cursor): the marker - * lands on the line right after the closing `---` fence, before the body. - * - Wrapless targets (cline, body verbatim): the marker is appended as the - * LAST line instead. Cline surfaces the file's first heading as the rule - * title, so a leading comment would displace the body's H1. - */ -function injectMarkerLine(wrapped: string, markerLine: string): string { - if (wrapped.startsWith('---\n')) { - // The name/description frontmatter values are single-line, so the first - // `\n---\n` after the opening fence is always the closing fence. - const closingFence = '\n---\n'; - const fenceIdx = wrapped.indexOf(closingFence); - if (fenceIdx !== -1) { - const insertAt = fenceIdx + closingFence.length; - return `${wrapped.slice(0, insertAt)}${markerLine}\n${wrapped.slice(insertAt)}`; - } - } - const separator = wrapped.endsWith('\n') ? '' : '\n'; - return `${wrapped}${separator}${markerLine}\n`; +/** Insert the marker line right after the closing `---` fence. */ +function injectMarkerLine(skillMd: string, markerLine: string): string { + const closingFence = '\n---\n'; + const at = skillMd.indexOf(closingFence) + closingFence.length; + return `${skillMd.slice(0, at)}${markerLine}\n${skillMd.slice(at)}`; } -/** - * Exact own-file bytes for a skill on a target, carrying the GIVEN marker line. - * `agent status` uses this to re-render the current canonical body with a - * file's own (possibly older-versioned) marker: when only the marker's version - * string lags but the body is unchanged, the artifact still compares pristine. - */ -export function renderOwnFileWithMarker( - target: AgentTarget, +/** Canonical SKILL.md bytes carrying a specific marker line (used by `agent status`). */ +export function renderCanonicalWithMarker( skill: string, markerLine: string, - body?: string, + read: ReadFn = defaultRead, ): string { - const spec = TARGETS[target]; - if (spec.mode !== 'own-file') { - throw new Error(`renderOwnFileWithMarker: ${target} is not an own-file target`); - } - const skillSpec = SKILLS[skill]; - if (!skillSpec) throw new Error(`unknown skill: ${skill}`); - const resolvedBody = body !== undefined ? body : loadSkillBodyFor(skill); - return injectMarkerLine( - spec.wrap(skillSpec.name, skillSpec.description, resolvedBody), - markerLine, - ); + if (!SKILLS[skill]) throw new Error(`unknown skill: ${skill}`); + return injectMarkerLine(loadSkillFull(skill, read), markerLine); } -/** - * The exact bytes to write for one skill on one target. - * - * - own-file targets: `body` defaults to the skill's own-file asset, wrapped in - * the target's frontmatter/header, and carrying a provenance marker line so - * `agent status` can tell fresh, stale, and hand-edited installs apart. - * - codex (managed-section): returns the skill's codex contribution unwrapped - * and marker-free (plain Markdown, no frontmatter). The real install does NOT - * call this for codex: it aggregates all skills via - * {@link buildCodexAggregate} and writes ONE marker just inside the BEGIN - * sentinel. It is kept single-skill here for tests and parity. Pass an - * explicit `body` to override. - */ -export function renderForTarget( - t: AgentTarget, - skill: string, - body?: string, -): { path: string; content: string } { - const spec = TARGETS[t]; - const skillSpec = SKILLS[skill]; - if (!skillSpec) throw new Error(`unknown skill: ${skill}`); - const path = pathFor(t, skill); - if (spec.mode === 'managed-section') { - const resolvedBody = body !== undefined ? body : codexContentFor(skill); - return { path, content: spec.wrap(skillSpec.name, skillSpec.description, resolvedBody) }; - } - const resolvedBody = - body !== undefined ? body : spec.compactBody ? compactBodyFor(skill) : loadSkillBodyFor(skill); - return { - path, - content: renderOwnFileWithMarker(t, skill, buildSkillMarker(skill, resolvedBody), resolvedBody), - }; +/** Canonical SKILL.md bytes to write: the asset verbatim plus a provenance marker. */ +export function renderCanonical(skill: string, read: ReadFn = defaultRead): string { + return renderCanonicalWithMarker( + skill, + buildSkillMarker(skill, loadSkillFull(skill, read)), + read, + ); } diff --git a/src/lib/skill-nudge.test.ts b/src/lib/skill-nudge.test.ts index 2b26c0e..caef759 100644 --- a/src/lib/skill-nudge.test.ts +++ b/src/lib/skill-nudge.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MANAGED_SECTION_BEGIN, MANAGED_SECTION_END, TARGETS } from './agent-targets.js'; +import { TARGETS, pathFor, type AgentTarget } from './agent-targets.js'; import type { OutputMode } from './output.js'; import { SKILL_NUDGE_COMMANDS, @@ -18,59 +18,23 @@ import { const toPosix = (p: string) => p.replaceAll('\\', '/'); describe('isVerifySkillInstalled', () => { - it('true when the claude own-file SKILL.md exists', () => { - const existsSync = (p: string) => - toPosix(p).endsWith('.claude/skills/testsprite-verify/SKILL.md'); - expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true); - }); - - it('true for the cursor .mdc landing file', () => { - const existsSync = (p: string) => toPosix(p).endsWith('.cursor/rules/testsprite-verify.mdc'); - expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true); - }); - - it('true for the cline landing file', () => { - const existsSync = (p: string) => toPosix(p).endsWith('.clinerules/testsprite-verify.md'); - expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true); - }); - - it('true for the antigravity landing file', () => { - const existsSync = (p: string) => - toPosix(p).endsWith('.agents/skills/testsprite-verify/SKILL.md'); - expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true); - }); - - it('true when AGENTS.md exists AND carries our BEGIN sentinel', () => { - const existsSync = (p: string) => p.endsWith('AGENTS.md'); - const readFileSync = () => - `# project\n${MANAGED_SECTION_BEGIN}\n...skill...\n${MANAGED_SECTION_END}\n`; - expect(isVerifySkillInstalled('/proj', { existsSync, readFileSync })).toBe(true); - }); - - it('false when AGENTS.md has only the BEGIN sentinel without a complete managed section', () => { - const existsSync = (p: string) => p.endsWith('AGENTS.md'); - const readFileSync = () => `# project\n${MANAGED_SECTION_BEGIN}\n...partial skill...\n`; - expect(isVerifySkillInstalled('/proj', { existsSync, readFileSync })).toBe(false); - }); - - it('false when only a bare AGENTS.md (no sentinel) exists', () => { - const existsSync = (p: string) => p.endsWith('AGENTS.md'); - const readFileSync = () => '# my project\nNothing TestSprite here.\n'; - expect(isVerifySkillInstalled('/proj', { existsSync, readFileSync })).toBe(false); - }); - - it('false when an unreadable AGENTS.md is the only candidate', () => { - const existsSync = (p: string) => p.endsWith('AGENTS.md'); - const readFileSync = () => { - throw new Error('EACCES'); - }; - expect(isVerifySkillInstalled('/proj', { existsSync, readFileSync })).toBe(false); + it('true when any single target landing exists (checked for every target)', () => { + for (const id of Object.keys(TARGETS) as AgentTarget[]) { + const landing = toPosix(pathFor(id, 'testsprite-verify')); + const existsSync = (p: string) => toPosix(p).endsWith(landing); + expect(isVerifySkillInstalled('/proj', { existsSync }), id).toBe(true); + } }); it('false when nothing is present', () => { expect(isVerifySkillInstalled('/proj', { existsSync: () => false })).toBe(false); }); + it('false for an unrelated file', () => { + const existsSync = (p: string) => toPosix(p).endsWith('README.md'); + expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(false); + }); + it('checks paths under the supplied dir', () => { const seen: string[] = []; isVerifySkillInstalled('/some/proj', { @@ -79,8 +43,9 @@ describe('isVerifySkillInstalled', () => { return false; }, }); + expect(seen.length).toBeGreaterThan(0); expect(seen.every(p => toPosix(p).startsWith('/some/proj'))).toBe(true); - // One probe per target landing path. + // One probe per supported target landing path. expect(seen).toHaveLength(Object.keys(TARGETS).length); }); }); diff --git a/src/lib/skill-nudge.ts b/src/lib/skill-nudge.ts index 0f67572..d6973ad 100644 --- a/src/lib/skill-nudge.ts +++ b/src/lib/skill-nudge.ts @@ -1,25 +1,10 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { MANAGED_SECTION_BEGIN, MANAGED_SECTION_END, TARGETS } from './agent-targets.js'; +import { TARGETS, pathFor, type AgentTarget } from './agent-targets.js'; import { defaultCredentialsPath, readProfile } from './credentials.js'; import type { OutputMode } from './output.js'; -/** - * Full command paths (group + leaf) that signal the caller is actively driving - * the verification loop — running or authoring tests, or checking auth in - * preflight. The skill nudge fires ONLY for these. - * - * Deliberately excluded: - * - `setup` / `init` / `agent install` — they ARE the fix; nudging is circular. - * - read-only inspection (`test list/get/result/...`, `project list/get`) — - * keeps an agent that is merely browsing from being nagged. - * - `auth configure` / `auth remove` (and the deprecated `auth logout`) — - * credential management, not the loop. - * - * Match the strings emitted by `commandPathOf` in `src/index.ts`. `auth status` - * is the primary identity command; `auth whoami` is its deprecated alias and is - * listed too so the warning fires regardless of which name the caller uses. - */ +/** Command paths that drive the verification loop, where a missing skill matters. */ export const SKILL_NUDGE_COMMANDS: ReadonlySet = new Set([ 'test run', 'test rerun', @@ -29,55 +14,26 @@ export const SKILL_NUDGE_COMMANDS: ReadonlySet = new Set([ 'auth whoami', ]); -/** - * Env var that silences the warning. For CI, or users who deliberately drive - * the CLI by hand without wiring a coding agent. - */ +/** Env var that silences the nudge. */ export const SKILL_NUDGE_OPT_OUT_ENV = 'TESTSPRITE_NO_SKILL_WARNING'; export interface SkillPresenceDeps { existsSync?: (p: string) => boolean; - readFileSync?: (p: string) => string; } -/** - * True if the `testsprite-verify` skill is installed for ANY supported agent in - * `dir`. own-file targets (claude/cursor/cline/antigravity): the landing file - * exists. managed-section target (codex / AGENTS.md): the file exists AND - * carries one complete managed section — a user-authored AGENTS.md without the - * sentinels, or with a truncated section, does NOT count as our skill. - * - * The TARGETS table is the single source of truth for landing paths, so this - * stays in lockstep with `agent install` without re-listing paths. Best-effort: - * a per-target read error is swallowed (that target is treated as absent). - */ +/** True if the `testsprite-verify` skill is reachable from any agent's skills directory in `dir`. */ export function isVerifySkillInstalled(dir: string, deps: SkillPresenceDeps = {}): boolean { const exists = deps.existsSync ?? existsSync; - const read = deps.readFileSync ?? ((p: string) => readFileSync(p, 'utf8')); - for (const spec of Object.values(TARGETS)) { - const full = join(dir, spec.path); - if (!exists(full)) continue; - if (spec.mode === 'managed-section') { - try { - if (hasCompleteManagedSection(read(full))) return true; - } catch { - // unreadable AGENTS.md → treat this target as absent, keep checking - } - continue; + for (const target of Object.keys(TARGETS) as AgentTarget[]) { + try { + if (exists(join(dir, pathFor(target, 'testsprite-verify')))) return true; + } catch { + // unreadable → treat as absent, keep checking } - return true; // own-file landing file present } return false; } -/** True when a managed-section file contains an ordered TestSprite BEGIN/END pair. */ -function hasCompleteManagedSection(content: string): boolean { - const begin = content.indexOf(MANAGED_SECTION_BEGIN); - if (begin === -1) return false; - const end = content.indexOf(MANAGED_SECTION_END, begin + MANAGED_SECTION_BEGIN.length); - return end !== -1; -} - export interface SkillNudgeContext { /** Full command path, e.g. "test run" / "auth whoami". */ commandPath: string; @@ -86,48 +42,29 @@ export interface SkillNudgeContext { profile: string; cwd: string; env: NodeJS.ProcessEnv; - /** Override the credentials file location (tests). */ credentialsPath?: string; - /** Override the profile lookup (tests); defaults to the real `readProfile`. */ readProfileImpl?: (profile: string, opts: { path: string }) => { apiKey?: string } | undefined; - /** Sink for the hint line; defaults to `process.stderr`. */ stderr?: (line: string) => void; existsSync?: (p: string) => boolean; - readFileSync?: (p: string) => string; } /** - * Best-effort onboarding warning. When a configured caller drives a verify-loop - * command in a project that has NO installed skill, print a one-line `[warn]` - * line to stderr pointing at `testsprite setup`. Reaches a coding agent at the - * exact moment it uses the CLI without the skill wired up. - * - * Gates (all must pass to emit): text output (never pollutes `--output json`), - * not `--dry-run`, the command is in {@link SKILL_NUDGE_COMMANDS}, the opt-out - * env is unset, the active profile has an api key (un-configured callers hit an - * auth error that already points at setup), and the skill is not already - * installed. Never throws and never blocks the command — any error is swallowed. + * Best-effort stderr hint: when a configured caller runs a verify-loop command + * in a project with no installed skill, point it at `testsprite setup`. Text + * output only, skipped under --dry-run / opt-out env / unconfigured profile, + * and never throws or blocks the command. */ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void { try { - if (ctx.output !== 'text') return; - if (ctx.dryRun) return; - if (isTruthyEnv(ctx.env[SKILL_NUDGE_OPT_OUT_ENV])) return; + if (ctx.output !== 'text' || ctx.dryRun || isTruthyEnv(ctx.env[SKILL_NUDGE_OPT_OUT_ENV])) + return; if (!SKILL_NUDGE_COMMANDS.has(ctx.commandPath)) return; - const credsPath = ctx.credentialsPath ?? defaultCredentialsPath(); - const lookup = ctx.readProfileImpl ?? readProfile; - const profile = lookup(ctx.profile, { path: credsPath }); + const profile = (ctx.readProfileImpl ?? readProfile)(ctx.profile, { + path: ctx.credentialsPath ?? defaultCredentialsPath(), + }); if (!profile?.apiKey) return; - - if ( - isVerifySkillInstalled(ctx.cwd, { - existsSync: ctx.existsSync, - readFileSync: ctx.readFileSync, - }) - ) { - return; - } + if (isVerifySkillInstalled(ctx.cwd, { existsSync: ctx.existsSync })) return; const write = ctx.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); write( @@ -136,12 +73,10 @@ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void { `\`testsprite agent install\`) to set it up. Silence: ${SKILL_NUDGE_OPT_OUT_ENV}=1`, ); } catch { - // A nudge must never break, delay, or alter the exit status of a real - // command. Swallow everything (missing creds file, fs races, etc.). + // A nudge must never alter the outcome of the real command. } } -/** Interpret common env-var spellings for an enabled opt-out flag. */ function isTruthyEnv(v: string | undefined): boolean { if (v === undefined) return false; const s = v.trim().toLowerCase(); diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 4724064..f933415 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -3,8 +3,8 @@ exports[`--help snapshots > agent 1`] = ` "Usage: testsprite agent [options] [command] -Install TestSprite guidance into coding-agent config (Claude Code, Cursor, -Cline, Antigravity, Kiro, Windsurf, Copilot, Codex) +Install TestSprite skills into each coding agent (Claude Code, Codex, Cursor, +Cline, Gemini CLI, Copilot, and 60+ more) Options: -h, --help display help for command @@ -12,11 +12,13 @@ Options: Commands: install [options] Write the TestSprite agent skills (verification loop + first-run onboarding) into a project for a coding agent - list List supported agent targets and skills, their status, and - landing paths - status [options] Check installed TestSprite skill files against this CLI - version: ok, stale, modified, unmarked, absent, or corrupt - (exits 1 when anything needs attention, so it can gate CI) + list List supported agent targets, their skill folder, and + whether they read .agents/skills directly (universal) or + via symlink + status [options] Check installed TestSprite skills against this CLI version + (ok/stale/modified/unmarked). Reports only agents that + have an install; exits 1 when any need attention, so it + can gate CI help [command] display help for command " `; @@ -28,16 +30,14 @@ Write the TestSprite agent skills (verification loop + first-run onboarding) into a project for a coding agent Options: - --target Agent target(s): claude, cursor, cline, antigravity, kiro, - windsurf, copilot, codex (comma-separated or repeated) - (default: []) + --target Agent target(s): claude-code, codex, cursor, gemini-cli, + github-copilot, kiro-cli, windsurf, cline, antigravity + (comma-separated or repeated) (default: []) --skill Skill(s) to install: testsprite-verify, testsprite-onboard (comma-separated or repeated; default: all) (default: []) --dir Project root to write into (default: cwd) - --force For own-file targets: overwrite existing file (a .bak backup - is kept). For codex (managed-section): replaces the section - unconditionally; user content outside the section is never - destroyed. + --force Overwrite an existing canonical file or landing (a .bak + backup is kept for files). -h, --help display help for command Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug): @@ -48,7 +48,8 @@ Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeou exports[`--help snapshots > agent list 1`] = ` "Usage: testsprite agent list [options] -List supported agent targets and skills, their status, and landing paths +List supported agent targets, their skill folder, and whether they read +.agents/skills directly (universal) or via symlink Options: -h, --help display help for command @@ -115,9 +116,11 @@ Options: --api-key API key to configure (skips the interactive prompt) --from-env Read TESTSPRITE_API_KEY from the environment instead of prompting (default: false) - --agent Coding-agent target to install: claude, antigravity, - cursor, cline, kiro, windsurf, copilot, codex (default: - claude) (default: "claude") + --agent Coding-agent target to install — any Agent Skills + standard (agentskills.io) agent id (e.g. claude-code, + codex, cursor, cline, gemini-cli, github-copilot, + kiro-cli, windsurf, antigravity). Default: claude-code + (default: "claude-code") --no-agent Skip the agent skill install (configure credentials only) --force Overwrite an existing skill file (a .bak backup is @@ -758,9 +761,9 @@ Commands: auth Manage TestSprite credentials project Manage TestSprite projects test Inspect TestSprite tests - agent Install TestSprite guidance into coding-agent - config (Claude Code, Cursor, Cline, Antigravity, - Kiro, Windsurf, Copilot, Codex) + agent Install TestSprite skills into each coding agent + (Claude Code, Codex, Cursor, Cline, Gemini CLI, + Copilot, and 60+ more) usage|credits Show credit balance and plan/entitlement info (proactive pre-flight before a large test run) doctor Diagnose CLI setup: version, Node, profile, diff --git a/test/e2e/agent-install.e2e.test.ts b/test/e2e/agent-install.e2e.test.ts index 4fb4584..2519ed5 100644 --- a/test/e2e/agent-install.e2e.test.ts +++ b/test/e2e/agent-install.e2e.test.ts @@ -5,24 +5,28 @@ * freshly `mkdtemp`-ed project directory. No network, no credentials — fully * CI-runnable. * + * Covers the canonical-source model: every skill is written once to + * `.agents/skills//SKILL.md`; universal agents read it directly and + * symlinked agents reach it through a short link (or a copy when symlinks are + * unavailable, e.g. Windows without Developer Mode). + * * Run via: `npm run test:e2e` (which builds first). * Do NOT run via `npm test` — the main vitest.config.ts excludes `test/e2e/**`. */ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, lstatSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { - MANAGED_SECTION_BEGIN, - MANAGED_SECTION_END, TARGETS, SKILLS, DEFAULT_SKILLS, + canonicalSkillFile, pathFor, - renderForTarget, + renderCanonical, type AgentTarget, } from '../../src/lib/agent-targets.js'; @@ -61,10 +65,6 @@ afterEach(() => { } }); -// --------------------------------------------------------------------------- -// Helper: spawn CLI without throwing on non-zero exit codes -// --------------------------------------------------------------------------- - interface CliResult { status: number; stdout: string; @@ -76,22 +76,27 @@ function runCli(args: string[]): CliResult { encoding: 'utf8', env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, }); - return { - status: result.status ?? -1, - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - }; + return { status: result.status ?? -1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; } // --------------------------------------------------------------------------- -// 1. Fresh install — table-driven over all TARGETS +// 1. Fresh install — table-driven over a representative slice of TARGETS +// (universal + symlinked), plus a full-registry smoke pass. // --------------------------------------------------------------------------- -describe('fresh install (per target)', () => { - const allTargets = Object.keys(TARGETS) as AgentTarget[]; +const representative: AgentTarget[] = [ + 'claude-code', // symlinked + 'codex', // universal + 'cursor', // universal + 'gemini-cli', // universal + 'github-copilot', // universal + 'kiro-cli', // symlinked + 'windsurf', // symlinked +]; - for (const target of allTargets) { - it(`installs ${target} → exit 0, all skill files land, action: written/section-installed`, () => { +describe('fresh install (representative targets)', () => { + for (const target of representative) { + it(`installs ${target} → exit 0, canonical SKILL.md lands, every skill action: written`, () => { const tmpDir = freshTmpDir(); const result = runCli([ 'agent', @@ -104,165 +109,79 @@ describe('fresh install (per target)', () => { ]); expect(result.status, `exit code for ${target}`).toBe(0); - // Parse JSON array output const parsed = JSON.parse(result.stdout) as Array<{ target: string; path: string; action: string; skills: string[]; + mode: string; }>; expect(Array.isArray(parsed), 'output should be a JSON array').toBe(true); - if (TARGETS[target].mode === 'managed-section') { - // codex: ONE result aggregating all skills - const entry = parsed.find(r => r.target === target); - expect(entry, `entry for ${target}`).toBeDefined(); - expect(entry!.action, `action for ${target}`).toBe('section-installed'); - expect(entry!.path).toBe(TARGETS[target].path); - const absPath = join(tmpDir, TARGETS[target].path); - expect(existsSync(absPath), `file at ${absPath}`).toBe(true); - expect(existsSync(dirname(absPath))).toBe(true); - } else { - // own-file: one result per skill, one file per skill - for (const skill of DEFAULT_SKILLS) { - const entry = parsed.find(r => r.target === target && r.path === pathFor(target, skill)); - expect(entry, `entry for ${target}/${skill}`).toBeDefined(); - expect(entry!.action, `action for ${target}/${skill}`).toBe('written'); - - const absPath = join(tmpDir, pathFor(target, skill)); - expect(existsSync(absPath), `file at ${absPath}`).toBe(true); - expect(existsSync(dirname(absPath))).toBe(true); - } + // The canonical source of truth exists for every skill. + for (const skill of DEFAULT_SKILLS) { + const canonicalAbs = join(tmpDir, canonicalSkillFile(skill)); + expect(existsSync(canonicalAbs), `canonical file at ${canonicalAbs}`).toBe(true); + } + + // Every result row for this target reports a fresh-write action. + for (const row of parsed.filter(r => r.target === target)) { + expect(['written', 'copy-fallback']).toContain(row.action); } }); } + + it('universal target (codex) creates NO symlink — only the canonical file', () => { + const tmpDir = freshTmpDir(); + runCli(['agent', 'install', '--target=codex', '--dir', tmpDir, '--output', 'json']); + // canonical present + expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(true); + // no agent-specific skills dir created (codex reads .agents/skills directly) + expect(existsSync(join(tmpDir, '.codex'))).toBe(false); + }); + + it('symlinked target (claude-code) links .claude/skills/ → canonical (or copies)', () => { + const tmpDir = freshTmpDir(); + runCli(['agent', 'install', '--target=claude-code', '--dir', tmpDir, '--output', 'json']); + // The agent reaches the skill through its own folder, via symlink or copy. + const landing = join(tmpDir, '.claude/skills/testsprite-verify/SKILL.md'); + expect(existsSync(landing), `claude-code landing should resolve to a SKILL.md`).toBe(true); + // canonical still present + expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(true); + }); }); // --------------------------------------------------------------------------- -// 2. Content integrity — check every target's written file +// 2. Content integrity — the canonical SKILL.md shape // --------------------------------------------------------------------------- describe('content integrity', () => { - // own-file targets: both skill files land with correct structure - const ownFileTargets = (Object.keys(TARGETS) as AgentTarget[]).filter( - t => TARGETS[t].mode === 'own-file', - ); - - for (const target of ownFileTargets) { - it(`${target} testsprite-verify file has correct structure and load-bearing strings`, () => { - const tmpDir = freshTmpDir(); - runCli(['agent', 'install', `--target=${target}`, '--dir', tmpDir, '--output', 'json']); - - const filePath = join(tmpDir, pathFor(target, 'testsprite-verify')); - const content = readFileSync(filePath, 'utf8'); - - // (a) Frontmatter check - if (target === 'claude' || target === 'antigravity') { - expect(content.startsWith('---'), `${target}: should start with ---`).toBe(true); - expect(content).toContain('name: testsprite-verify'); - } else if (target === 'cursor') { - expect(content.startsWith('---'), `cursor: should start with ---`).toBe(true); - expect(content).toContain('alwaysApply: false'); - } else if (target === 'cline') { - expect(content.startsWith('---'), `cline: must NOT start with ---`).toBe(false); - expect( - content.trimStart().startsWith('#'), - `cline: should start with a markdown heading`, - ).toBe(true); - } else if (target === 'windsurf') { - // Windsurf Cascade frontmatter: trigger + description (no name/alwaysApply) - expect(content.startsWith('---'), `windsurf: should start with ---`).toBe(true); - expect(content).toContain('trigger: model_decision'); - expect(content).toContain('description:'); - } else if (target === 'copilot') { - // GitHub Copilot instructions frontmatter: applyTo glob + description - expect(content.startsWith('---'), `copilot: should start with ---`).toBe(true); - expect(content).toContain("applyTo: '**'"); - expect(content).toContain('description:'); - } - - // (b) branding — the renamed H1 must be present in every body variant - expect(content).toContain('TestSprite Verification Loop'); - // The full-body intro line lives only in the FULL body; compact-body targets - // (e.g. windsurf, budget-capped) ship the trimmed verify body and omit it. - if (!TARGETS[target].compactBody) { - expect(content).toContain('The verification loop that flies'); - } - - // (c) Load-bearing command strings - expect(content, `${target}: missing 'testsprite test run'`).toContain('testsprite test run'); - expect(content, `${target}: missing '--wait'`).toContain('--wait'); - expect(content, `${target}: missing 'test artifact get'`).toContain('test artifact get'); - }); - - it(`${target} testsprite-onboard file lands and has correct structure`, () => { - const tmpDir = freshTmpDir(); - runCli(['agent', 'install', `--target=${target}`, '--dir', tmpDir, '--output', 'json']); - - const filePath = join(tmpDir, pathFor(target, 'testsprite-onboard')); - expect(existsSync(filePath), `onboard file must exist at ${filePath}`).toBe(true); - const content = readFileSync(filePath, 'utf8'); - - // Frontmatter check for onboard skill - if (target === 'claude' || target === 'antigravity') { - expect(content.startsWith('---'), `${target}/onboard: should start with ---`).toBe(true); - expect(content).toContain('name: testsprite-onboard'); - } else if (target === 'cursor') { - expect(content.startsWith('---'), `cursor/onboard: should start with ---`).toBe(true); - expect(content).toContain('alwaysApply: false'); - } else if (target === 'cline') { - expect(content.startsWith('---'), `cline/onboard: must NOT start with ---`).toBe(false); - } else if (target === 'windsurf') { - expect(content.startsWith('---'), `windsurf/onboard: should start with ---`).toBe(true); - expect(content).toContain('trigger: model_decision'); - expect(content).toContain('description:'); - } else if (target === 'copilot') { - expect(content.startsWith('---'), `copilot/onboard: should start with ---`).toBe(true); - expect(content).toContain("applyTo: '**'"); - } - - // Load-bearing onboard string: the skill body must reference setup - expect(content).toContain('testsprite'); - }); - } - - // codex: ONE managed section containing BOTH verify body and onboard one-liner - it('codex AGENTS.md contains ONE managed section with both verify and onboard content', () => { + it('testsprite-verify SKILL.md has frontmatter, marker, branding, and command strings', () => { const tmpDir = freshTmpDir(); - runCli(['agent', 'install', '--target=codex', '--dir', tmpDir, '--output', 'json']); + runCli(['agent', 'install', '--target=claude-code', '--dir', tmpDir, '--output', 'json']); - const filePath = join(tmpDir, TARGETS.codex.path); + // Read through the claude-code landing (exercises the symlink/copy path). + const filePath = join(tmpDir, pathFor('claude-code', 'testsprite-verify')); const content = readFileSync(filePath, 'utf8'); - // (a) Exactly ONE pair of sentinels - const beginCount = ( - content.match( - new RegExp(MANAGED_SECTION_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), - ) ?? [] - ).length; - const endCount = ( - content.match(new RegExp(MANAGED_SECTION_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) ?? - [] - ).length; - expect(beginCount, 'exactly one BEGIN sentinel').toBe(1); - expect(endCount, 'exactly one END sentinel').toBe(1); - - // BEGIN must come before END - expect(content.indexOf(MANAGED_SECTION_BEGIN)).toBeLessThan( - content.indexOf(MANAGED_SECTION_END), - ); - - // (b) Verify content: branding heading + load-bearing command strings + expect(content.startsWith('---')).toBe(true); + expect(content).toContain('name: testsprite-verify'); + expect(content).toContain('description:'); expect(content).toContain('TestSprite Verification Loop'); expect(content).toContain('testsprite test run'); expect(content).toContain('--wait'); expect(content).toContain('test artifact get'); + // provenance marker line + expect(content).toMatch(/'; - writeFileSync(verifyFilePath, editedContent, 'utf8'); + const canonical = join(tmpDir, canonicalSkillFile('testsprite-verify')); + const edited = readFileSync(canonical, 'utf8') + '\n\n'; + writeFileSync(canonical, edited, 'utf8'); - // Re-run without --force const second = runCli([ 'agent', 'install', - '--target=claude', + '--target=claude-code', '--dir', tmpDir, '--output', 'json', ]); expect(second.status).toBe(6); - - // At least one entry must be blocked (the verify file) - const parsed = JSON.parse(second.stdout) as Array<{ path: string; action: string }>; - const blockedEntry = parsed.find(r => r.path === pathFor('claude', 'testsprite-verify')); - expect(blockedEntry, 'verify entry should be blocked').toBeDefined(); - expect(blockedEntry!.action).toBe('blocked'); - - // File must be unchanged (not overwritten) - expect(readFileSync(verifyFilePath, 'utf8')).toBe(editedContent); - - // Stderr must contain --force hint expect(second.stderr).toContain('--force'); + // File unchanged + expect(readFileSync(canonical, 'utf8')).toBe(edited); }); }); @@ -373,22 +258,18 @@ describe('conflict handling', () => { // --------------------------------------------------------------------------- describe('force overwrite with backup', () => { - it('--force exits 0 with action: updated for edited file, .bak holds edited bytes', () => { + it('--force backs up the canonical file and writes canonical content', () => { const tmpDir = freshTmpDir(); + runCli(['agent', 'install', '--target=claude-code', '--dir', tmpDir, '--output', 'json']); - // First install - runCli(['agent', 'install', '--target=claude', '--dir', tmpDir, '--output', 'json']); - - // Hand-edit the verify file - const verifyFilePath = join(tmpDir, pathFor('claude', 'testsprite-verify')); - const editedContent = readFileSync(verifyFilePath, 'utf8') + '\n\n'; - writeFileSync(verifyFilePath, editedContent, 'utf8'); + const canonical = join(tmpDir, canonicalSkillFile('testsprite-verify')); + const edited = readFileSync(canonical, 'utf8') + '\n\n'; + writeFileSync(canonical, edited, 'utf8'); - // Re-run with --force const forced = runCli([ 'agent', 'install', - '--target=claude', + '--target=claude-code', '--dir', tmpDir, '--force', @@ -397,19 +278,13 @@ describe('force overwrite with backup', () => { ]); expect(forced.status).toBe(0); - const parsed = JSON.parse(forced.stdout) as Array<{ path: string; action: string }>; - const verifyEntry = parsed.find(r => r.path === pathFor('claude', 'testsprite-verify')); - expect(verifyEntry, 'verify entry must be present').toBeDefined(); - expect(verifyEntry!.action).toBe('updated'); - - // Verify file must now equal canonical content - const { content: canonicalVerify } = renderForTarget('claude', 'testsprite-verify'); - expect(readFileSync(verifyFilePath, 'utf8')).toBe(canonicalVerify); + const parsed = JSON.parse(forced.stdout) as Array<{ action: string }>; + expect(parsed.some(r => r.action === 'updated')).toBe(true); - // .bak must hold the edited bytes - const bakPath = verifyFilePath + '.bak'; - expect(existsSync(bakPath), '.bak file must exist').toBe(true); - expect(readFileSync(bakPath, 'utf8')).toBe(editedContent); + // canonical content restored + expect(readFileSync(canonical, 'utf8')).toBe(renderCanonical('testsprite-verify')); + // .bak holds the edited bytes + expect(readFileSync(`${canonical}.bak`, 'utf8')).toBe(edited); }); }); @@ -418,46 +293,36 @@ describe('force overwrite with backup', () => { // --------------------------------------------------------------------------- describe('dry-run', () => { - it('--dry-run exits 0, prints both skill paths to stderr, creates no files', () => { + it('--dry-run exits 0, prints would-write lines, creates no files', () => { const tmpDir = freshTmpDir(); - const result = runCli([ '--dry-run', 'agent', 'install', - '--target=claude', + '--target=claude-code', '--dir', tmpDir, '--output', 'json', ]); expect(result.status).toBe(0); - - // Stderr shows both skill paths and "would write" banner expect(result.stderr).toContain('would write'); - expect(result.stderr).toContain(pathFor('claude', 'testsprite-verify')); - expect(result.stderr).toContain(pathFor('claude', 'testsprite-onboard')); - - // No files created on disk for either skill - for (const skill of DEFAULT_SKILLS) { - const filePath = join(tmpDir, pathFor('claude', skill)); - expect(existsSync(filePath), `file must NOT be created in dry-run: ${skill}`).toBe(false); - } + expect(result.stderr).toContain(canonicalSkillFile('testsprite-verify')); + expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(false); }); }); // --------------------------------------------------------------------------- -// 7. Multi-target — all five files land in one invocation +// 7. Multi-target — canonical written once, symlinks per non-universal target // --------------------------------------------------------------------------- describe('multi-target install', () => { - it('--target=claude,cursor,cline,antigravity,kiro,codex writes all targets + skills, exit 0', () => { + it('--target=claude-code,codex,cursor writes canonical once + links per symlinked target', () => { const tmpDir = freshTmpDir(); - const result = runCli([ 'agent', 'install', - '--target=claude,cursor,cline,antigravity,kiro,codex', + '--target=claude-code,codex,cursor', '--dir', tmpDir, '--output', @@ -465,44 +330,25 @@ describe('multi-target install', () => { ]); expect(result.status).toBe(0); - const parsed = JSON.parse(result.stdout) as Array<{ - target: string; - action: string; - path: string; - }>; - const allTargets: AgentTarget[] = ['claude', 'cursor', 'cline', 'antigravity', 'kiro', 'codex']; - - for (const target of allTargets) { - if (TARGETS[target].mode === 'managed-section') { - // codex: one result aggregating all skills - const entry = parsed.find(r => r.target === target); - expect(entry, `entry for ${target}`).toBeDefined(); - expect(entry!.action, `action for ${target}`).toBe('section-installed'); - const absPath = join(tmpDir, TARGETS[target].path); - expect(existsSync(absPath), `file at ${absPath}`).toBe(true); - } else { - // own-file: one result per skill - for (const skill of DEFAULT_SKILLS) { - const skillPath = pathFor(target, skill); - const entry = parsed.find(r => r.target === target && r.path === skillPath); - expect(entry, `entry for ${target}/${skill}`).toBeDefined(); - expect(entry!.action, `action for ${target}/${skill}`).toBe('written'); - const absPath = join(tmpDir, skillPath); - expect(existsSync(absPath), `file at ${absPath}`).toBe(true); - } - } + // canonical source of truth present + for (const skill of DEFAULT_SKILLS) { + expect(existsSync(join(tmpDir, canonicalSkillFile(skill)))).toBe(true); } + // claude-code landing resolves to a SKILL.md + expect(existsSync(join(tmpDir, pathFor('claude-code', 'testsprite-verify')))).toBe(true); + // codex/cursor are universal — no private skills dir + expect(existsSync(join(tmpDir, '.codex'))).toBe(false); + expect(existsSync(join(tmpDir, '.cursor'))).toBe(false); }); }); // --------------------------------------------------------------------------- -// 8. Unknown target → exit 5, supported list on stderr, nothing written +// 8. Unknown target → exit 5 // --------------------------------------------------------------------------- describe('unknown target', () => { - it('--target=bogus exits 5 with supported-target list, nothing written', () => { + it('--target=bogus exits 5, nothing written', () => { const tmpDir = freshTmpDir(); - const result = runCli([ 'agent', 'install', @@ -513,214 +359,22 @@ describe('unknown target', () => { 'json', ]); expect(result.status).toBe(5); - - // stderr lists supported targets - for (const t of Object.keys(TARGETS)) { - expect(result.stderr, `stderr should mention "${t}"`).toContain(t); - } - - // Nothing written to disk - for (const spec of Object.values(TARGETS)) { - const absPath = join(tmpDir, spec.path); - expect(existsSync(absPath), `unexpected file at ${absPath}`).toBe(false); - } + expect(result.stderr).toContain('unknown target'); + expect(existsSync(join(tmpDir, '.agents'))).toBe(false); }); }); // --------------------------------------------------------------------------- -// 9. managed-section (codex) — lifecycle scenarios -// --------------------------------------------------------------------------- - -describe('managed-section (codex target)', () => { - it('create: AGENTS.md absent → creates file with sentinels, action: section-installed', () => { - const tmpDir = freshTmpDir(); - const result = runCli([ - 'agent', - 'install', - '--target=codex', - '--dir', - tmpDir, - '--output', - 'json', - ]); - expect(result.status).toBe(0); - - const parsed = JSON.parse(result.stdout) as Array<{ target: string; action: string }>; - const entry = parsed.find(r => r.target === 'codex'); - expect(entry).toBeDefined(); - expect(entry!.action).toBe('section-installed'); - - const filePath = join(tmpDir, TARGETS.codex.path); - const content = readFileSync(filePath, 'utf8'); - expect(content).toContain(MANAGED_SECTION_BEGIN); - expect(content).toContain(MANAGED_SECTION_END); - }); - - it('append: AGENTS.md exists (no sentinels) → appends section, original preserved, action: section-installed', () => { - const tmpDir = freshTmpDir(); - const agentsPath = join(tmpDir, 'AGENTS.md'); - const existingContent = '# My Project\n\nExisting project instructions here.\n'; - writeFileSync(agentsPath, existingContent, 'utf8'); - - const result = runCli([ - 'agent', - 'install', - '--target=codex', - '--dir', - tmpDir, - '--output', - 'json', - ]); - expect(result.status).toBe(0); - - const parsed = JSON.parse(result.stdout) as Array<{ target: string; action: string }>; - const entry = parsed.find(r => r.target === 'codex'); - expect(entry!.action).toBe('section-installed'); - - const content = readFileSync(agentsPath, 'utf8'); - // original content preserved - expect(content).toContain('My Project'); - expect(content).toContain('Existing project instructions here.'); - // section appended - expect(content).toContain(MANAGED_SECTION_BEGIN); - expect(content).toContain(MANAGED_SECTION_END); - // original comes first (append, not prepend) - expect(content.indexOf('My Project')).toBeLessThan(content.indexOf(MANAGED_SECTION_BEGIN)); - }); - - it('replace: sentinels present → replaces section content, surrounding text preserved, action: section-updated', () => { - const tmpDir = freshTmpDir(); - const agentsPath = join(tmpDir, 'AGENTS.md'); - const before = '# Intro\n\nBefore content.\n'; - const after = '\n\nAfter content.\n'; - const oldSection = `${MANAGED_SECTION_BEGIN}\nOLD CONTENT\n${MANAGED_SECTION_END}`; - writeFileSync(agentsPath, `${before}${oldSection}${after}`, 'utf8'); - - const result = runCli([ - 'agent', - 'install', - '--target=codex', - '--dir', - tmpDir, - '--output', - 'json', - ]); - expect(result.status).toBe(0); - - const parsed = JSON.parse(result.stdout) as Array<{ target: string; action: string }>; - const entry = parsed.find(r => r.target === 'codex'); - expect(entry!.action).toBe('section-updated'); - - const content = readFileSync(agentsPath, 'utf8'); - // surrounding content preserved - expect(content).toContain('Before content.'); - expect(content).toContain('After content.'); - // old section content replaced - expect(content).not.toContain('OLD CONTENT'); - // new section content present - expect(content).toContain(MANAGED_SECTION_BEGIN); - expect(content).toContain(MANAGED_SECTION_END); - expect(content).toContain('testsprite test run'); - }); - - it('unchanged: re-running on identical sentinels → no write, action: section-unchanged', () => { - const tmpDir = freshTmpDir(); - - // First install - const first = runCli([ - 'agent', - 'install', - '--target=codex', - '--dir', - tmpDir, - '--output', - 'json', - ]); - expect(first.status).toBe(0); - - const agentsPath = join(tmpDir, 'AGENTS.md'); - const contentBefore = readFileSync(agentsPath, 'utf8'); - - // Second install (same content) - const second = runCli([ - 'agent', - 'install', - '--target=codex', - '--dir', - tmpDir, - '--output', - 'json', - ]); - expect(second.status).toBe(0); - - const parsed = JSON.parse(second.stdout) as Array<{ target: string; action: string }>; - const entry = parsed.find(r => r.target === 'codex'); - expect(entry!.action).toBe('section-unchanged'); - - // File must be byte-identical - expect(readFileSync(agentsPath, 'utf8')).toBe(contentBefore); - }); - - it('corrupt sentinel (BEGIN without END) → exit 5, error message mentions sentinel/corrupt', () => { - const tmpDir = freshTmpDir(); - const agentsPath = join(tmpDir, 'AGENTS.md'); - // BEGIN present but END is absent — malformed file - writeFileSync(agentsPath, `${MANAGED_SECTION_BEGIN}\nOrphaned section\n`, 'utf8'); - - const result = runCli([ - 'agent', - 'install', - '--target=codex', - '--dir', - tmpDir, - '--output', - 'json', - ]); - expect(result.status).toBe(5); - expect(result.stderr).toMatch(/malformed|corrupt|sentinel/i); - }); - - it('--dry-run: no writes, stderr mentions managed section, action: dry-run', () => { - const tmpDir = freshTmpDir(); - - const result = runCli([ - '--dry-run', - 'agent', - 'install', - '--target=codex', - '--dir', - tmpDir, - '--output', - 'json', - ]); - expect(result.status).toBe(0); - - // JSON action is dry-run - const parsed = JSON.parse(result.stdout) as Array<{ target: string; action: string }>; - const entry = parsed.find(r => r.target === 'codex'); - expect(entry!.action).toBe('dry-run'); - - // stderr should mention managed section and the path - expect(result.stderr).toContain('AGENTS.md'); - - // No file created - const agentsPath = join(tmpDir, 'AGENTS.md'); - expect(existsSync(agentsPath), 'AGENTS.md must NOT be created in dry-run').toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// 10. --skill flag: install a single named skill +// 9. --skill flag // --------------------------------------------------------------------------- describe('--skill flag', () => { - it('--skill testsprite-onboard installs only the onboard file, not verify', () => { + it('--skill testsprite-onboard installs only the onboard canonical file', () => { const tmpDir = freshTmpDir(); - const result = runCli([ 'agent', 'install', - '--target=claude', + '--target=claude-code', '--skill', 'testsprite-onboard', '--dir', @@ -729,29 +383,16 @@ describe('--skill flag', () => { 'json', ]); expect(result.status).toBe(0); - - const parsed = JSON.parse(result.stdout) as Array<{ path: string; action: string }>; - // Only one result — the onboard skill - expect(parsed.length).toBe(1); - expect(parsed[0]!.path).toBe(pathFor('claude', 'testsprite-onboard')); - expect(parsed[0]!.action).toBe('written'); - - // Onboard file must exist - const onboardPath = join(tmpDir, pathFor('claude', 'testsprite-onboard')); - expect(existsSync(onboardPath), 'onboard file must exist').toBe(true); - - // Verify file must NOT exist - const verifyPath = join(tmpDir, pathFor('claude', 'testsprite-verify')); - expect(existsSync(verifyPath), 'verify file must NOT exist').toBe(false); + expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-onboard')))).toBe(true); + expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(false); }); - it('unknown --skill bogus exits 5 with documented error message', () => { + it('unknown --skill bogus exits 5', () => { const tmpDir = freshTmpDir(); - const result = runCli([ 'agent', 'install', - '--target=claude', + '--target=claude-code', '--skill', 'bogus', '--dir', @@ -760,108 +401,117 @@ describe('--skill flag', () => { 'json', ]); expect(result.status).toBe(5); - - // The error message must name the unknown skill and list supported skills expect(result.stderr).toContain('bogus'); - expect(result.stderr).toContain('testsprite-verify'); - expect(result.stderr).toContain('testsprite-onboard'); - - // Nothing written to disk - for (const skill of Object.keys(SKILLS)) { - const absPath = join(tmpDir, pathFor('claude', skill)); - expect(existsSync(absPath), `unexpected file at ${absPath}`).toBe(false); - } }); }); // --------------------------------------------------------------------------- -// 11. agent list — includes SKILL column with both skill names +// 10. agent list // --------------------------------------------------------------------------- describe('agent list', () => { - it('output includes TARGET, SKILL column header and both default skill names', () => { + it('output includes TARGET/MODE columns and the headline agents', () => { const result = runCli(['agent', 'list']); expect(result.status).toBe(0); - - // Header must include TARGET and SKILL columns expect(result.stdout).toContain('TARGET'); - expect(result.stdout).toContain('SKILL'); - - // Both default skills must appear in the output - for (const skill of DEFAULT_SKILLS) { - expect(result.stdout, `${skill} should appear in agent list`).toContain(skill); - } - - // All targets must appear - for (const target of Object.keys(TARGETS)) { - expect(result.stdout, `${target} should appear in agent list`).toContain(target); - } + expect(result.stdout).toContain('MODE'); + expect(result.stdout).toContain('claude-code'); + expect(result.stdout).toContain('codex'); }); - it('--output json returns an array with one entry per (target × skill)', () => { + it('--output json returns one row per target with mode + skillsDir', () => { const result = runCli(['agent', 'list', '--output', 'json']); expect(result.status).toBe(0); - const parsed = JSON.parse(result.stdout) as Array<{ target: string; - skill: string; - status: string; mode: string; - path: string; + skillsDir: string; }>; - expect(Array.isArray(parsed)).toBe(true); - - // Expected: 8 targets × 2 skills = 16 rows - const expectedCount = Object.keys(TARGETS).length * DEFAULT_SKILLS.length; - expect(parsed.length).toBe(expectedCount); - - // Every row must have a non-empty skill field from DEFAULT_SKILLS - for (const row of parsed) { - expect(DEFAULT_SKILLS as readonly string[]).toContain(row.skill); - } - - // Claude verify row must have the verify path - const claudeVerify = parsed.find(r => r.target === 'claude' && r.skill === 'testsprite-verify'); - expect(claudeVerify).toBeDefined(); - expect(claudeVerify!.path).toBe(pathFor('claude', 'testsprite-verify')); - - // Claude onboard row must have the onboard path - const claudeOnboard = parsed.find( - r => r.target === 'claude' && r.skill === 'testsprite-onboard', - ); - expect(claudeOnboard).toBeDefined(); - expect(claudeOnboard!.path).toBe(pathFor('claude', 'testsprite-onboard')); + expect(parsed.length).toBe(Object.keys(TARGETS).length); + const codex = parsed.find(r => r.target === 'codex')!; + expect(codex.mode).toBe('universal'); + expect(codex.skillsDir).toBe('.agents/skills'); + const claude = parsed.find(r => r.target === 'claude-code')!; + expect(claude.mode).toBe('symlink'); }); }); // --------------------------------------------------------------------------- -// 12. Matrix-coverage guard — hardcoded list forces a conscious update when -// a target is added or removed from TARGETS. +// 11. Registry coverage guard — forces a conscious update when agents change // --------------------------------------------------------------------------- -describe('matrix coverage guard', () => { - it('TARGETS matches the documented, e2e-covered set (update this list when adding a target)', () => { - expect(Object.keys(TARGETS)).toEqual([ - 'claude', - 'antigravity', - 'cursor', - 'cline', - 'kiro', - 'windsurf', - 'copilot', - 'codex', - ]); + +describe('registry coverage guard', () => { + it('TARGETS includes the documented headline agents (superset of the legacy 8)', () => { + expect(Object.keys(TARGETS)).toEqual( + expect.arrayContaining([ + 'claude-code', + 'codex', + 'cursor', + 'cline', + 'gemini-cli', + 'github-copilot', + 'kiro-cli', + 'windsurf', + 'antigravity', + ]), + ); }); - it('SKILLS matches the documented, e2e-covered set (update this list when adding a skill)', () => { + it('SKILLS matches the documented set', () => { expect(Object.keys(SKILLS)).toEqual(['testsprite-verify', 'testsprite-onboard']); }); + + it('legacy short aliases still resolve via the real binary', () => { + const tmpDir = freshTmpDir(); + const result = runCli([ + 'agent', + 'install', + '--target=claude', + '--dir', + tmpDir, + '--output', + 'json', + ]); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout) as Array<{ target: string }>; + expect(parsed[0]!.target).toBe('claude-code'); + expect(existsSync(join(tmpDir, '.claude/skills/testsprite-verify'))).toBe(true); + }); }); // --------------------------------------------------------------------------- -// Bootstrap tip (piece-3) — SKIPPED in e2e -// Note: tip coverage lives in src/commands/auth.test.ts. Wiring a /me stub -// for `auth configure` is disproportionate here; the unit tests cover the tip. +// 12. Full-registry smoke — every accepted target installs cleanly // --------------------------------------------------------------------------- -it.skip('bootstrap tip after auth configure — see auth.test.ts for tip coverage', () => { - // No-op: piece-3 unit tests in src/commands/auth.test.ts cover the tip. + +describe('full-registry smoke', () => { + for (const target of Object.keys(TARGETS) as AgentTarget[]) { + it(`target=${target}: installs exit 0, no thrown error`, () => { + const tmpDir = freshTmpDir(); + const result = runCli([ + 'agent', + 'install', + `--target=${target}`, + '--dir', + tmpDir, + '--output', + 'json', + ]); + expect(result.status, `stderr: ${result.stderr}`).toBe(0); + // canonical always present + expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(true); + // symlinked targets: the landing resolves to a SKILL.md (symlink or copy) + const spec = TARGETS[target]!; + if (!spec.universal) { + const landing = join(tmpDir, spec.skillsDir, 'testsprite-verify', 'SKILL.md'); + expect(existsSync(landing), `landing SKILL.md for ${target}`).toBe(true); + // On POSIX the landing is a symlink; on Windows it may be a copy. Either is fine. + try { + const st = lstatSync(join(tmpDir, spec.skillsDir, 'testsprite-verify')); + expect(st.isSymbolicLink() || st.isDirectory()).toBe(true); + } catch { + // copy-fallback produced a directory; the existsSync above already proved reachability + } + } + }); + } }); diff --git a/test/e2e/setup.e2e.test.ts b/test/e2e/setup.e2e.test.ts index 5a07434..8935397 100644 --- a/test/e2e/setup.e2e.test.ts +++ b/test/e2e/setup.e2e.test.ts @@ -15,7 +15,12 @@ import { dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { TARGETS, pathFor } from '../../src/lib/agent-targets.js'; +import { + TARGETS, + canonicalSkillFile, + pathFor, + type AgentTarget, +} from '../../src/lib/agent-targets.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '../..'); @@ -77,11 +82,12 @@ describe('setup --help', () => { expect(result.stdout).toContain('--yes'); }); - it('--help lists all valid agent targets', () => { + it('--help describes the agent-target model and the headline ids', () => { const result = runCli(['setup', '--help']); - for (const t of Object.keys(TARGETS)) { - expect(result.stdout, `target "${t}" should be in --help`).toContain(t); - } + expect(result.stdout).toContain('agentskills.io'); + expect(result.stdout).toContain('claude-code'); + expect(result.stdout).toContain('codex'); + expect(result.stdout).toContain('Default: claude-code'); }); }); @@ -103,8 +109,8 @@ describe('setup --dry-run --no-agent', () => { expect(result.stderr).toContain('[dry-run]'); expect(result.stderr).toContain('no writes or network calls'); - for (const spec of Object.values(TARGETS)) { - const absPath = join(tmpDir, spec.path); + for (const target of Object.keys(TARGETS) as AgentTarget[]) { + const absPath = join(tmpDir, pathFor(target, 'testsprite-verify')); expect(existsSync(absPath), `unexpected file: ${absPath}`).toBe(false); } }); @@ -126,7 +132,7 @@ describe('setup --dry-run (with agent)', () => { '--api-key', 'sk-dry-with-agent', '--agent', - 'claude', + 'claude-code', '--dir', tmpDir, ], @@ -136,9 +142,9 @@ describe('setup --dry-run (with agent)', () => { expect(result.status).toBe(0); expect(result.stderr).toContain('[dry-run]'); - // Both skill paths must appear in the dry-run preview - const verifyPath = pathFor('claude', 'testsprite-verify'); - const onboardPath = pathFor('claude', 'testsprite-onboard'); + // The canonical SKILL.md path must appear in the dry-run preview + const verifyPath = canonicalSkillFile('testsprite-verify'); + const onboardPath = canonicalSkillFile('testsprite-onboard'); expect(result.stderr, 'verify path in dry-run preview').toContain(verifyPath); expect(result.stderr, 'onboard path in dry-run preview').toContain(onboardPath); @@ -221,17 +227,20 @@ describe('deprecated `init` alias', () => { // --------------------------------------------------------------------------- describe('matrix coverage guard', () => { - it('TARGETS matches the documented set (update this list when adding a target)', () => { - expect(Object.keys(TARGETS)).toEqual([ - 'claude', - 'antigravity', - 'cursor', - 'cline', - 'kiro', - 'windsurf', - 'copilot', - 'codex', - ]); + it('TARGETS includes the documented headline agents (the standard registry is broad)', () => { + expect(Object.keys(TARGETS)).toEqual( + expect.arrayContaining([ + 'claude-code', + 'codex', + 'cursor', + 'cline', + 'gemini-cli', + 'github-copilot', + 'kiro-cli', + 'windsurf', + 'antigravity', + ]), + ); }); }); diff --git a/test/e2e/skill-nudge.e2e.test.ts b/test/e2e/skill-nudge.e2e.test.ts index d0930bd..e9591df 100644 --- a/test/e2e/skill-nudge.e2e.test.ts +++ b/test/e2e/skill-nudge.e2e.test.ts @@ -20,7 +20,7 @@ import { dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { TARGETS } from '../../src/lib/agent-targets.js'; +import { canonicalSkillFile } from '../../src/lib/agent-targets.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '../..'); @@ -124,8 +124,8 @@ describe('skill nudge — suppression gates', () => { () => { const proj = freshDir('ts-nudge-proj-'); const home = homeWithCreds(); - // Drop a claude own-file skill so detection finds it. - const skillPath = join(proj, TARGETS.claude.path); + // Drop a canonical skill so detection finds it. + const skillPath = join(proj, canonicalSkillFile('testsprite-verify')); mkdirSync(dirname(skillPath), { recursive: true }); writeFileSync(skillPath, '---\nname: testsprite-verify\n---\nbody\n', 'utf8'); From aed1e5fa207204acd504a65d045c5be51ed7c876 Mon Sep 17 00:00:00 2001 From: Pow Date: Fri, 24 Jul 2026 14:07:51 +0700 Subject: [PATCH 2/2] docs(agent): update supported agents list and clarify agent install behavior - Revise CONTRIBUTING.md to describe universal vs symlinked agent targets and requirements for adding new targets - Update DOCUMENTATION.md to clarify agent install command usage and differentiate universal and symlinked agents - Expand README.md with a detailed supported agents table, including canonical ids, aliases, and skills folder locations - Update src/lib/agent-targets.ts with added agents, corrections to skillsDir and universal flags, and alias mappings - Modify unit tests and e2e tests to reflect renamed and updated agent targets, replacing deprecated aliases like gemini-cli with antigravity-cli and windsurf with devin-desktop - Ensure single source of truth for skills at `.agents/skills`, symlink strategy for non-universal agents, and consistency across docs and code --- CONTRIBUTING.md | 21 ++-- DOCUMENTATION.md | 24 +++-- README.md | 163 +++++++++++++++-------------- src/lib/agent-targets.test.ts | 42 ++++---- src/lib/agent-targets.ts | 100 +++++++++--------- test/e2e/agent-install.e2e.test.ts | 8 +- test/e2e/setup.e2e.test.ts | 4 +- 7 files changed, 185 insertions(+), 177 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1479099..d44c1a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,12 +56,21 @@ Pre-decided policies, so proposals don't have to relitigate them: runtime dependency needs explicit maintainer sign-off **in the issue, before the PR**. Utility modules land only together with the consumer that uses them — standalone libraries are declined. -- **`agent install` targets.** Shipped: `claude`, `antigravity`, `cursor`, - `cline`, `codex`, `kiro`, `windsurf`, `copilot`. Accepted and in progress: - `gemini`. - A proposal for a new target needs (1) the editor's official rules/skill - file mechanism, documented, and (2) the proposer prepared to maintain the - target going forward. +- **`agent install` targets.** The CLI supports a registry of agent ids (see + the [supported-agents tables](./README.md#supported-agents) in the README for + the current set), each classified as **universal** (reads the shared + `.agents/skills/` folder directly — Cursor, Codex, Copilot, Amp, …; one + install serves all of them) or **symlinked** (keeps skills in its own folder, + e.g. `.claude/skills`, with a per-skill symlink back to `.agents/skills/` so + every agent reads the same single source of truth). A proposal adding a + target must, in the same PR: (1) cite the agent's **current** skills docs + proving its skills folder and classification (universal = reads + `.agents/skills/` natively; symlinked = installs into its own ``); (2) + add it to the registry (`src/lib/agent-targets.ts` — id, `skillsDir`, + `universal` flag, and any alias); (3) update the unit-test target list + (`src/lib/agent-targets.test.ts`); and (4) add a row to the matching + supported-agents table in the README, with the agent's name and a link to its + skills docs. - **Outbound network calls.** The CLI talks only to the configured TestSprite API endpoint. The one approved exception is an opt-out-able npm registry version check (at most once per 24h, carrying nothing but diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c822de2..d03ebf7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -109,20 +109,22 @@ Every artifact in the bundle shares one `snapshotId`; the CLI will not mix a fai `testsprite agent install` writes the TestSprite skills into your project so your coding agent knows the commands, the exit codes, and the failure-bundle layout — no prompt engineering required. It's a pure-local command: no network, no credentials. ```bash -testsprite agent install claude-code # .agents/skills//SKILL.md + .claude/skills/ symlink -testsprite agent install codex # writes the canonical copy (Codex reads .agents/skills directly) -testsprite agent install cursor # universal — reads the canonical copy -testsprite agent install gemini-cli # universal -testsprite agent install github-copilot # universal -testsprite agent install kiro-cli # .kiro/skills/ symlink → canonical -testsprite agent install windsurf # .windsurf/skills/ symlink → canonical -testsprite agent list # every supported agent, its skills folder, and universal vs symlink -testsprite agent status # check installed skills against this CLI version +testsprite agent install claude-code +testsprite agent install codex +testsprite agent install cursor +testsprite agent install kiro-cli +testsprite agent list +testsprite agent status ``` -`--target` accepts any agent id from the standard registry (70+), plus the legacy short aliases (`claude`, `codex`, `cursor`, `cline`, `antigravity`, `kiro`, `windsurf`, `copilot`). Omitting `--target` in a non-interactive shell defaults to `claude-code`; in a terminal the CLI prompts. +`--target` accepts any agent id from the registry — see [Supported agents](./README.md#supported-agents) in the README for the full list. Omitting `--target` in a non-interactive shell defaults to `claude-code`; in a terminal the CLI prompts. -Because every universal agent reads the same `.agents/skills/` folder, **installing for one universal agent makes the skill available to all of them** — `agent install --target codex` also serves Cursor, Cline, Gemini CLI, Copilot, etc. +There are two kinds of agent: + +- **Universal agents** (Skills folder `.agents/skills`) read the skill directly from the shared folder. Installing for **one** of them makes the skill available to **all** of them — `agent install --target codex` also serves Cursor, Copilot, Amp, and every other universal agent. +- **Symlinked agents** (every other folder) get a per-skill symlink from their own skills folder back to `.agents/skills/`, so they read the same bytes as the universal ones. + +`.agents/skills/` is the **single source of truth**: it is written on every install, even when you target a symlinked agent — `agent install --target claude-code` lands the skill in `.agents/skills/` (covering every universal agent) **and** links it into `.claude/skills/`. Because each symlink points _into_ `.agents/skills/`, you only ever edit a skill there and every symlinked agent reflects the change automatically (on systems where symlinks are unavailable — e.g. Windows without Developer Mode — a plain copy is written instead, which won't auto-update). `agent status` checks the canonical skill file (and each symlinked landing) against the current CLI version and reports `ok`, `stale`, `modified`, or `unmarked` for every agent that has an install (absent agents are omitted to keep output focused). It exits `1` when anything needs attention, so `testsprite agent status && …` can gate a CI step; `--dir ` inspects a different project root. diff --git a/README.md b/README.md index 9c64750..8e58a98 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,90 @@ On [**CoderCup**](https://codercup.ai) — an open leaderboard where frontier co That's the point of all of this: you no longer need the biggest, most expensive model to ship software you can trust — top-tier quality, without paying top-tier prices, within reach of every team. +## Supported agents + +`agent install --target ` wires the verification skill into any of the agents below. Names in parentheses are **aliases** — legacy short names or product variants that resolve to the canonical id; always prefer the canonical id. + +### Universal agents + +`.agents/skills` → **Universal agent**: reads the skill directly from the shared folder, meaning that installing for **one** universal agent makes it available to **all** of them (e.g., `agent install --target codex` also serves Cursor, Copilot, Amp, etc.). + +| `--target` id | Agent | Skills folder | +| -------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------- | +| `amp` | [Amp](https://ampcode.com/manual#agent-skills) | `.agents/skills` | +| `antigravity` | [Antigravity](https://antigravity.google/docs/skills) | `.agents/skills` | +| `antigravity-cli` (`gemini-cli`) | [Antigravity CLI](https://antigravity.google/docs/cli/plugins) | `.agents/skills` | +| `augment` | [Augment](https://docs.augmentcode.com/cli/skills) | `.agents/skills` | +| `codestudio` | [Code Studio](https://help.syncfusion.com/code-studio/reference/configure-properties/skills) | `.agents/skills` | +| `codex` | [Codex](https://developers.openai.com/codex/skills/) | `.agents/skills` | +| `command-code` | [Command Code](https://commandcode.ai/docs/skills) | `.agents/skills` | +| `crush` | [Crush](https://github.com/charmbracelet/crush#agent-skills) | `.agents/skills` | +| `cursor` | [Cursor](https://cursor.com/docs/context/skills) | `.agents/skills` | +| `devin-cloud` | [Devin Cloud](https://docs.devin.ai/product-guides/skills) | `.agents/skills` | +| `firebender` | [Firebender](https://docs.firebender.com/multi-agent/skills) | `.agents/skills` | +| `github-copilot` (`copilot`) | [GitHub Copilot](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills) | `.agents/skills` | +| `goose` | [Goose](https://goose-docs.ai/docs/guides/context-engineering/using-skills/) | `.agents/skills` | +| `kilo` | [Kilo Code](https://kilo.ai/docs/customize/skills) | `.agents/skills` | +| `kimi-code-cli` | [Kimi Code CLI](https://www.kimi.com/code/docs/en/kimi-code-cli/customization/skills.html) | `.agents/skills` | +| `mcpjam` | [MCPJam](https://docs.mcpjam.com/inspector/skills) | `.agents/skills` | +| `mistral-vibe` | [Mistral Vibe](https://docs.mistral.ai/vibe/code/cli/skills) | `.agents/skills` | +| `mux` | [Mux](https://mux.coder.com/agents/agent-skills) | `.agents/skills` | +| `ona` | [Ona](https://ona.com/docs/ona/agents/skills) | `.agents/skills` | +| `openclaw` | [OpenClaw](https://docs.openclaw.ai/tools/skills) | `.agents/skills` | +| `opencode` | [OpenCode](https://opencode.ai/docs/skills/) | `.agents/skills` | +| `openhands` | [OpenHands](https://docs.openhands.dev/overview/skills) | `.agents/skills` | +| `pi` | [Pi](https://pi.dev/docs/latest/skills) | `.agents/skills` | +| `pochi` | [Pochi](https://docs.getpochi.com/skills/) | `.agents/skills` | +| `promptscript` | [PromptScript](https://getpromptscript.dev) | `.agents/skills` | +| `replit` | [Replit](https://docs.replit.com/features/agent/skills) | `.agents/skills` | +| `rovodev` | [Rovo Dev](https://support.atlassian.com/rovo/docs/extend-rovo-dev-cli-with-agent-skills/) | `.agents/skills` | +| `tabnine-cli` | [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/agent-skills) | `.agents/skills` | +| `vtcode` | [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/skills/SKILLS_GUIDE.md) | `.agents/skills` | +| `warp` | [Warp](https://docs.warp.dev/agent-platform/capabilities/skills/) | `.agents/skills` | +| `zed` | [Zed](https://zed.dev/docs/ai/skills) | `.agents/skills` | +| `zencoder` (`zenflow`) | [Zencoder](https://docs.zencoder.ai/features/skills) | `.agents/skills` | + +### Symlinked agents + +Any other folder → **Symlinked agent**: installing a skill for a symlinked agent first installs it into `.agents/skills/`, then creates a symlink in that agent's own folder (for example, `agent install --target claude-code` installs to `.agents/skills/` and symlinks into `.claude/skills/`). Because `.agents/skills/` is the shared source for all symlinked agents, installing a skill for any one of them also makes it available to every universal agent above. On systems without symlink support (such as Windows without Developer Mode), a plain copy is used instead and will not auto-update. + +| `--target` id | Agent | Skills folder | +| ---------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------- | +| `adal` | [AdaL](https://docs.sylph.ai/features/plugins-and-skills) | `.adal/skills` | +| `aider-desk` | [AiderDesk](https://aiderdesk.hotovo.com/docs/features/skills) | `.aider-desk/skills` | +| `astrbot` | [AstrBot](https://docs.astrbot.app/en/use/skills.html) | `data/skills` | +| `autohand-code` | [Autohand Code CLI](https://docs.autohand.ai/working-with-autohand-code/skills) | `.autohand/skills` | +| `bob` | [IBM Bob](https://bob.ibm.com/docs/ide/features/skills) | `.bob/skills` | +| `claude-code` (`claude`) | [Claude Code](https://code.claude.com/docs/en/skills) | `.claude/skills` | +| `cline` | [Cline](https://docs.cline.bot/customization/skills) | `.cline/skills` | +| `codearts-agent` | [CodeArts Agent](https://support.huaweicloud.com/usermanual-cli/codeartsagent_cli_0019.html) | `.codeartsdoer/skills` | +| `codebuddy` | [CodeBuddy](https://www.codebuddy.ai/docs/ide/Features/Skills) | `.codebuddy/skills` | +| `codebuddy-cli` | [CodeBuddy CLI](https://www.codebuddy.ai/docs/cli/skills) | `.codebuddy/skills` | +| `codebuddy-cn` | [CodeBuddy CN](https://www.codebuddy.cn/docs/ide/Features/Skills) | `.codebuddy/skills` | +| `codebuddy-cn-cli` | [CodeBuddy CN CLI](https://www.codebuddy.cn/docs/cli/skills) | `.codebuddy/skills` | +| `continue` | [Continue](https://github.com/continuedev/continue/pull/9696) | `.continue/skills` | +| `cortex` | [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code-desktop/skills) | `.cortex/skills` | +| `devin` | [Devin for Terminal](https://docs.devin.ai/cli/extensibility/skills/overview) | `.devin/skills` | +| `devin-desktop` (`windsurf`) | [Devin Desktop](https://docs.devin.ai/desktop/cascade/skills) | `.windsurf/skills` | +| `droid` | [Droid](https://docs.factory.ai/cli/configuration/skills) | `.factory/skills` | +| `eve` | [Eve](https://eve.dev/docs/skills) | `agent/skills` | +| `forgecode` | [ForgeCode](https://forgecode.dev/docs/skills/) | `.forge/skills` | +| `hermes-agent` | [Hermes Agent](https://github.com/nicepkg/hermes) | `.hermes/skills` | +| `junie` | [Junie](https://junie.jetbrains.com/docs/agent-skills.html) | `.junie/skills` | +| `kiro` | [Kiro IDE](https://kiro.dev/docs/skills/) | `.kiro/skills` | +| `kiro-cli` | [Kiro CLI](https://kiro.dev/docs/cli/skills/) | `.kiro/skills` | +| `kode` | [Kode](https://github.com/shareAI-lab/kode/blob/main/docs/skills.md) | `.kode/skills` | +| `neovate` | [Neovate](https://github.com/neovateai/neovateai.dev/blob/master/content/en/docs/skills.mdx) | `.neovate/skills` | +| `qoder` | [Qoder](https://docs.qoder.com/extensions/skills) | `.qoder/skills` | +| `qoder-cli` (`iflow-cli`) | [Qoder CLI](https://docs.qoder.com/en/cli/Skills) | `.qoder/skills` | +| `qoder-cn` (`lingma`) | [Qoder CN](https://docs.qoder.cn/user-guide/skills) | `.lingma/skills` | +| `qoder-cn-cli` | [Qoder CN CLI](https://docs.qoder.cn/cli/skills) | `.qoder/skills` | +| `qwen-code` | [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/) | `.qwen/skills` | +| `reasonix` | [Reasonix](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md) | `.reasonix/skills` | +| `trae` | [Trae](https://docs.trae.ai/ide/skills) | `.trae/skills` | +| `trae-cn` | [Trae CN](https://docs.trae.cn/ide_skills) | `.trae/skills` | +| `trae-cn-cli` | [Trae CN CLI](https://docs.trae.cn/cli_skills) | `.traecli/skills` | + ## Getting help - 📚 **CLI reference** — [DOCUMENTATION.md](./DOCUMENTATION.md) @@ -200,82 +284,3 @@ Pull requests target the `dev` branch. The full guide — build from source, tes ## License [Apache-2.0](./LICENSE) © TestSprite - -## Supported agents - -`agent install --target ` (and `setup --agent `) wires the verification skill into any of the agents below. Those marked _(shared)_ read the single `.agents/skills/` directory directly; every other agent gets a symlink from its own skills folder back to it — one source of truth, no drift between agents. A few agents also accept a **legacy short alias** (shown in parentheses) for backwards compatibility with older scripts — prefer the canonical id. - -| `--target` id | Agent | Skills folder | -| ---------------------------- | ------------------ | --------------------------- | -| `aider-desk` | AiderDesk | `.aider-desk/skills` | -| `amp` | Amp | `.agents/skills` _(shared)_ | -| `antigravity` | Antigravity | `.agents/skills` _(shared)_ | -| `antigravity-cli` | Antigravity CLI | `.agents/skills` _(shared)_ | -| `astrbot` | AstrBot | `data/skills` | -| `autohand-code` | Autohand Code CLI | `.autohand/skills` | -| `augment` | Augment | `.augment/skills` | -| `bob` | IBM Bob | `.bob/skills` | -| `claude-code` (`claude`) | Claude Code | `.claude/skills` | -| `openclaw` | OpenClaw | `skills` | -| `cline` | Cline | `.agents/skills` _(shared)_ | -| `codearts-agent` | CodeArts Agent | `.codeartsdoer/skills` | -| `codebuddy` | CodeBuddy | `.codebuddy/skills` | -| `codemaker` | Codemaker | `.codemaker/skills` | -| `codestudio` | Code Studio | `.codestudio/skills` | -| `codex` | Codex | `.agents/skills` _(shared)_ | -| `command-code` | Command Code | `.commandcode/skills` | -| `continue` | Continue | `.continue/skills` | -| `cortex` | Cortex Code | `.cortex/skills` | -| `crush` | Crush | `.crush/skills` | -| `cursor` | Cursor | `.agents/skills` _(shared)_ | -| `deepagents` | Deep Agents | `.agents/skills` _(shared)_ | -| `devin` | Devin for Terminal | `.devin/skills` | -| `dexto` | Dexto | `.agents/skills` _(shared)_ | -| `droid` | Droid | `.factory/skills` | -| `eve` | Eve | `agent/skills` | -| `firebender` | Firebender | `.agents/skills` _(shared)_ | -| `forgecode` | ForgeCode | `.forge/skills` | -| `gemini-cli` | Gemini CLI | `.agents/skills` _(shared)_ | -| `github-copilot` (`copilot`) | GitHub Copilot | `.agents/skills` _(shared)_ | -| `goose` | Goose | `.goose/skills` | -| `hermes-agent` | Hermes Agent | `.hermes/skills` | -| `inference-sh` | inference.sh | `.inferencesh/skills` | -| `jazz` | Jazz | `.jazz/skills` | -| `junie` | Junie | `.junie/skills` | -| `iflow-cli` | iFlow CLI | `.iflow/skills` | -| `kilo` | Kilo Code | `.kilocode/skills` | -| `kimi-code-cli` | Kimi Code CLI | `.agents/skills` _(shared)_ | -| `kiro-cli` (`kiro`) | Kiro CLI | `.kiro/skills` | -| `kode` | Kode | `.kode/skills` | -| `lingma` | Lingma | `.lingma/skills` | -| `loaf` | Loaf | `.agents/skills` _(shared)_ | -| `mcpjam` | MCPJam | `.mcpjam/skills` | -| `mistral-vibe` | Mistral Vibe | `.vibe/skills` | -| `moxby` | Moxby | `.moxby/skills` | -| `mux` | Mux | `.mux/skills` | -| `opencode` | OpenCode | `.agents/skills` _(shared)_ | -| `openhands` | OpenHands | `.openhands/skills` | -| `ona` | Ona | `.ona/skills` | -| `pi` | Pi | `.pi/skills` | -| `qoder` | Qoder | `.qoder/skills` | -| `qoder-cn` | Qoder CN | `.qoder/skills` | -| `qwen-code` | Qwen Code | `.qwen/skills` | -| `replit` | Replit | `.agents/skills` _(shared)_ | -| `reasonix` | Reasonix | `.reasonix/skills` | -| `roo` | Roo Code | `.roo/skills` | -| `rovodev` | Rovo Dev | `.rovodev/skills` | -| `tabnine-cli` | Tabnine CLI | `.tabnine/agent/skills` | -| `terramind` | Terramind | `.terramind/skills` | -| `tinycloud` | Tinycloud | `.tinycloud/skills` | -| `trae` | Trae | `.trae/skills` | -| `trae-cn` | Trae CN | `.trae/skills` | -| `warp` | Warp | `.agents/skills` _(shared)_ | -| `windsurf` | Windsurf | `.windsurf/skills` | -| `zed` | Zed | `.agents/skills` _(shared)_ | -| `zcode` | ZCode | `.zcode/skills` | -| `zencoder` | Zencoder | `.zencoder/skills` | -| `zenflow` | Zenflow | `.zencoder/skills` | -| `neovate` | Neovate | `.neovate/skills` | -| `pochi` | Pochi | `.pochi/skills` | -| `promptscript` | PromptScript | `.agents/skills` _(shared)_ | -| `adal` | AdaL | `.adal/skills` | diff --git a/src/lib/agent-targets.test.ts b/src/lib/agent-targets.test.ts index 5a8463c..fe6f672 100644 --- a/src/lib/agent-targets.test.ts +++ b/src/lib/agent-targets.test.ts @@ -39,20 +39,22 @@ describe('TARGETS registry', () => { // Exact key set — any add/remove/reorder surfaces here for review. it('exposes the full standard agent set', () => { expect(Object.keys(TARGETS)).toEqual([ + 'adal', 'aider-desk', 'amp', 'antigravity', 'antigravity-cli', 'astrbot', - 'autohand-code', 'augment', + 'autohand-code', 'bob', 'claude-code', - 'openclaw', 'cline', 'codearts-agent', 'codebuddy', - 'codemaker', + 'codebuddy-cli', + 'codebuddy-cn', + 'codebuddy-cn-cli', 'codestudio', 'codex', 'command-code', @@ -60,57 +62,49 @@ describe('TARGETS registry', () => { 'cortex', 'crush', 'cursor', - 'deepagents', 'devin', - 'dexto', + 'devin-cloud', + 'devin-desktop', 'droid', 'eve', 'firebender', 'forgecode', - 'gemini-cli', 'github-copilot', 'goose', 'hermes-agent', - 'inference-sh', - 'jazz', 'junie', - 'iflow-cli', 'kilo', 'kimi-code-cli', + 'kiro', 'kiro-cli', 'kode', - 'lingma', - 'loaf', 'mcpjam', 'mistral-vibe', - 'moxby', 'mux', + 'neovate', + 'ona', 'opencode', + 'openclaw', 'openhands', - 'ona', 'pi', + 'pochi', + 'promptscript', 'qoder', + 'qoder-cli', 'qoder-cn', + 'qoder-cn-cli', 'qwen-code', - 'replit', 'reasonix', - 'roo', + 'replit', 'rovodev', 'tabnine-cli', - 'terramind', - 'tinycloud', 'trae', 'trae-cn', + 'trae-cn-cli', + 'vtcode', 'warp', - 'windsurf', 'zed', - 'zcode', 'zencoder', - 'zenflow', - 'neovate', - 'pochi', - 'promptscript', - 'adal', ]); }); diff --git a/src/lib/agent-targets.ts b/src/lib/agent-targets.ts index 4d78125..3c68f29 100644 --- a/src/lib/agent-targets.ts +++ b/src/lib/agent-targets.ts @@ -45,6 +45,7 @@ export interface TargetSpec { /** Standard agent id → its project skills directory and universal flag. */ export const TARGETS: Record = { + adal: { displayName: 'AdaL', skillsDir: '.adal/skills', universal: false }, 'aider-desk': { displayName: 'AiderDesk', skillsDir: '.aider-desk/skills', universal: false }, amp: { displayName: 'Amp', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, antigravity: { displayName: 'Antigravity', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, @@ -54,101 +55,94 @@ export const TARGETS: Record = { universal: true, }, astrbot: { displayName: 'AstrBot', skillsDir: 'data/skills', universal: false }, + augment: { displayName: 'Augment', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, 'autohand-code': { displayName: 'Autohand Code CLI', skillsDir: '.autohand/skills', universal: false, }, - augment: { displayName: 'Augment', skillsDir: '.augment/skills', universal: false }, bob: { displayName: 'IBM Bob', skillsDir: '.bob/skills', universal: false }, 'claude-code': { displayName: 'Claude Code', skillsDir: '.claude/skills', universal: false }, - openclaw: { displayName: 'OpenClaw', skillsDir: 'skills', universal: false }, - cline: { displayName: 'Cline', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + cline: { displayName: 'Cline', skillsDir: '.cline/skills', universal: false }, 'codearts-agent': { displayName: 'CodeArts Agent', skillsDir: '.codeartsdoer/skills', universal: false, }, codebuddy: { displayName: 'CodeBuddy', skillsDir: '.codebuddy/skills', universal: false }, - codemaker: { displayName: 'Codemaker', skillsDir: '.codemaker/skills', universal: false }, - codestudio: { displayName: 'Code Studio', skillsDir: '.codestudio/skills', universal: false }, - codex: { displayName: 'Codex', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, - 'command-code': { - displayName: 'Command Code', - skillsDir: '.commandcode/skills', + 'codebuddy-cli': { + displayName: 'CodeBuddy CLI', + skillsDir: '.codebuddy/skills', universal: false, }, + 'codebuddy-cn': { displayName: 'CodeBuddy CN', skillsDir: '.codebuddy/skills', universal: false }, + 'codebuddy-cn-cli': { + displayName: 'CodeBuddy CN CLI', + skillsDir: '.codebuddy/skills', + universal: false, + }, + codestudio: { displayName: 'Code Studio', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + codex: { displayName: 'Codex', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'command-code': { displayName: 'Command Code', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, continue: { displayName: 'Continue', skillsDir: '.continue/skills', universal: false }, cortex: { displayName: 'Cortex Code', skillsDir: '.cortex/skills', universal: false }, - crush: { displayName: 'Crush', skillsDir: '.crush/skills', universal: false }, + crush: { displayName: 'Crush', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, cursor: { displayName: 'Cursor', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, - deepagents: { displayName: 'Deep Agents', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, devin: { displayName: 'Devin for Terminal', skillsDir: '.devin/skills', universal: false }, - dexto: { displayName: 'Dexto', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'devin-cloud': { displayName: 'Devin Cloud', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'devin-desktop': { + displayName: 'Devin Desktop', + skillsDir: '.windsurf/skills', + universal: false, + }, droid: { displayName: 'Droid', skillsDir: '.factory/skills', universal: false }, eve: { displayName: 'Eve', skillsDir: 'agent/skills', universal: false }, firebender: { displayName: 'Firebender', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, forgecode: { displayName: 'ForgeCode', skillsDir: '.forge/skills', universal: false }, - 'gemini-cli': { displayName: 'Gemini CLI', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, 'github-copilot': { displayName: 'GitHub Copilot', skillsDir: CANONICAL_SKILLS_DIR, universal: true, }, - goose: { displayName: 'Goose', skillsDir: '.goose/skills', universal: false }, + goose: { displayName: 'Goose', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, 'hermes-agent': { displayName: 'Hermes Agent', skillsDir: '.hermes/skills', universal: false }, - 'inference-sh': { - displayName: 'inference.sh', - skillsDir: '.inferencesh/skills', - universal: false, - }, - jazz: { displayName: 'Jazz', skillsDir: '.jazz/skills', universal: false }, junie: { displayName: 'Junie', skillsDir: '.junie/skills', universal: false }, - 'iflow-cli': { displayName: 'iFlow CLI', skillsDir: '.iflow/skills', universal: false }, - kilo: { displayName: 'Kilo Code', skillsDir: '.kilocode/skills', universal: false }, + kilo: { displayName: 'Kilo Code', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, 'kimi-code-cli': { displayName: 'Kimi Code CLI', skillsDir: CANONICAL_SKILLS_DIR, universal: true, }, + kiro: { displayName: 'Kiro IDE', skillsDir: '.kiro/skills', universal: false }, 'kiro-cli': { displayName: 'Kiro CLI', skillsDir: '.kiro/skills', universal: false }, kode: { displayName: 'Kode', skillsDir: '.kode/skills', universal: false }, - lingma: { displayName: 'Lingma', skillsDir: '.lingma/skills', universal: false }, - loaf: { displayName: 'Loaf', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, - mcpjam: { displayName: 'MCPJam', skillsDir: '.mcpjam/skills', universal: false }, - 'mistral-vibe': { displayName: 'Mistral Vibe', skillsDir: '.vibe/skills', universal: false }, - moxby: { displayName: 'Moxby', skillsDir: '.moxby/skills', universal: false }, - mux: { displayName: 'Mux', skillsDir: '.mux/skills', universal: false }, + mcpjam: { displayName: 'MCPJam', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'mistral-vibe': { displayName: 'Mistral Vibe', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + mux: { displayName: 'Mux', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + neovate: { displayName: 'Neovate', skillsDir: '.neovate/skills', universal: false }, + ona: { displayName: 'Ona', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, opencode: { displayName: 'OpenCode', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, - openhands: { displayName: 'OpenHands', skillsDir: '.openhands/skills', universal: false }, - ona: { displayName: 'Ona', skillsDir: '.ona/skills', universal: false }, - pi: { displayName: 'Pi', skillsDir: '.pi/skills', universal: false }, + openclaw: { displayName: 'OpenClaw', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + openhands: { displayName: 'OpenHands', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + pi: { displayName: 'Pi', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + pochi: { displayName: 'Pochi', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + promptscript: { displayName: 'PromptScript', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, qoder: { displayName: 'Qoder', skillsDir: '.qoder/skills', universal: false }, - 'qoder-cn': { displayName: 'Qoder CN', skillsDir: '.qoder/skills', universal: false }, + 'qoder-cli': { displayName: 'Qoder CLI', skillsDir: '.qoder/skills', universal: false }, + 'qoder-cn': { displayName: 'Qoder CN', skillsDir: '.lingma/skills', universal: false }, + 'qoder-cn-cli': { displayName: 'Qoder CN CLI', skillsDir: '.qoder/skills', universal: false }, 'qwen-code': { displayName: 'Qwen Code', skillsDir: '.qwen/skills', universal: false }, - replit: { displayName: 'Replit', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, reasonix: { displayName: 'Reasonix', skillsDir: '.reasonix/skills', universal: false }, - roo: { displayName: 'Roo Code', skillsDir: '.roo/skills', universal: false }, - rovodev: { displayName: 'Rovo Dev', skillsDir: '.rovodev/skills', universal: false }, - 'tabnine-cli': { - displayName: 'Tabnine CLI', - skillsDir: '.tabnine/agent/skills', - universal: false, - }, - terramind: { displayName: 'Terramind', skillsDir: '.terramind/skills', universal: false }, - tinycloud: { displayName: 'Tinycloud', skillsDir: '.tinycloud/skills', universal: false }, + replit: { displayName: 'Replit', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + rovodev: { displayName: 'Rovo Dev', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, + 'tabnine-cli': { displayName: 'Tabnine CLI', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, trae: { displayName: 'Trae', skillsDir: '.trae/skills', universal: false }, 'trae-cn': { displayName: 'Trae CN', skillsDir: '.trae/skills', universal: false }, + 'trae-cn-cli': { displayName: 'Trae CN CLI', skillsDir: '.traecli/skills', universal: false }, + vtcode: { displayName: 'VT Code', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, warp: { displayName: 'Warp', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, - windsurf: { displayName: 'Windsurf', skillsDir: '.windsurf/skills', universal: false }, zed: { displayName: 'Zed', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, - zcode: { displayName: 'ZCode', skillsDir: '.zcode/skills', universal: false }, - zencoder: { displayName: 'Zencoder', skillsDir: '.zencoder/skills', universal: false }, - zenflow: { displayName: 'Zenflow', skillsDir: '.zencoder/skills', universal: false }, - neovate: { displayName: 'Neovate', skillsDir: '.neovate/skills', universal: false }, - pochi: { displayName: 'Pochi', skillsDir: '.pochi/skills', universal: false }, - promptscript: { displayName: 'PromptScript', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, - adal: { displayName: 'AdaL', skillsDir: '.adal/skills', universal: false }, + zencoder: { displayName: 'Zencoder', skillsDir: CANONICAL_SKILLS_DIR, universal: true }, }; /** Agent id type (one per TARGETS key). */ @@ -163,8 +157,12 @@ export type AgentTarget = keyof typeof TARGETS; */ export const TARGET_ALIASES: Record = { claude: 'claude-code', - kiro: 'kiro-cli', copilot: 'github-copilot', + windsurf: 'devin-desktop', + 'gemini-cli': 'antigravity-cli', + 'iflow-cli': 'qoder-cli', + lingma: 'qoder-cn', + zenflow: 'zencoder', }; /** Resolve a `--target` token (id or alias) to a canonical id, or null if unknown. */ diff --git a/test/e2e/agent-install.e2e.test.ts b/test/e2e/agent-install.e2e.test.ts index 2519ed5..b362a86 100644 --- a/test/e2e/agent-install.e2e.test.ts +++ b/test/e2e/agent-install.e2e.test.ts @@ -88,10 +88,10 @@ const representative: AgentTarget[] = [ 'claude-code', // symlinked 'codex', // universal 'cursor', // universal - 'gemini-cli', // universal + 'antigravity-cli', // universal 'github-copilot', // universal 'kiro-cli', // symlinked - 'windsurf', // symlinked + 'devin-desktop', // symlinked ]; describe('fresh install (representative targets)', () => { @@ -448,10 +448,10 @@ describe('registry coverage guard', () => { 'codex', 'cursor', 'cline', - 'gemini-cli', + 'antigravity-cli', 'github-copilot', 'kiro-cli', - 'windsurf', + 'devin-desktop', 'antigravity', ]), ); diff --git a/test/e2e/setup.e2e.test.ts b/test/e2e/setup.e2e.test.ts index 8935397..e821438 100644 --- a/test/e2e/setup.e2e.test.ts +++ b/test/e2e/setup.e2e.test.ts @@ -234,10 +234,10 @@ describe('matrix coverage guard', () => { 'codex', 'cursor', 'cline', - 'gemini-cli', + 'antigravity-cli', 'github-copilot', 'kiro-cli', - 'windsurf', + 'devin-desktop', 'antigravity', ]), );