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 8704182..e12f38a 100644
--- a/DOCUMENTATION.md
+++ b/DOCUMENTATION.md
@@ -86,7 +86,7 @@ This is the loop a coding agent runs on its own once you've onboarded it with `t
```bash
# (one-time, per project) teach your agent the CLI
-testsprite agent install claude
+testsprite agent install --target claude
# 1 — describe the behavior you want to guarantee, run it, wait
testsprite test create --project proj_8f0f6 --type frontend \
@@ -106,30 +106,29 @@ 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 --target claude-code
+testsprite agent install --target codex
+testsprite agent install --target cursor
+testsprite agent install --target kiro-cli
+testsprite agent list
+testsprite agent status
```
-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 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.
-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`).
+There are two kinds of agent:
-`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.
+- **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.
-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.
+`.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).
-Re-running with `--force` on **own-file targets** (claude, cursor, cline, antigravity, kiro, windsurf, copilot) backs up the existing file to `.bak` first.
+`agent status` checks each installed skill against this CLI version and reports `ok`, `stale`, `modified`, or `unmarked`. Symlinked agents appear only when their own landing exists; universal agents share one canonical skill file (`.agents/skills/`), so installing for any one of them serves every universal agent and they all report together. Agents with nothing installed are omitted. It exits `1` when anything needs attention, so `testsprite agent status && …` can gate a CI step; `--dir ` inspects a different project root.
+
+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..18dd181 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).
@@ -131,7 +131,7 @@ Prefer to configure each step by hand (or learn the surface offline with `--dry-
- 🤖 **Agent-shaped output.** `test failure get` returns **one bundle** — the failing step, its neighbors, screenshots, DOM snapshots, the test source, a root-cause hypothesis, and a recommended fix target — all sharing a single `snapshotId`. The CLI _refuses_ to stitch data from two different runs, so an agent never reasons over a frankenstein context.
- ♻️ **A loop, not a one-shot.** `create → run → failure get → fix → rerun` — every pass is banked, not thrown away.
- 📐 **Scriptable & deterministic.** Stable `--output json` contract, predictable [exit codes](./DOCUMENTATION.md#exit-codes), and a `--dry-run` that exercises the full code path offline with canned data.
-- 🔌 **One command to onboard your agent.** `testsprite agent install claude` drops a ready-made skill file into your repo so your coding agent knows how to drive the loop on its own.
+- 🔌 **One command to onboard your agent.** `testsprite agent install --target claude` drops a ready-made skill file into your repo so your coding agent knows how to drive the loop on its own.
## How it works
@@ -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)
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..a031006 100644
--- a/src/commands/agent.test.ts
+++ b/src/commands/agent.test.ts
@@ -1,2581 +1,1174 @@
-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,
+ LEGACY_MANAGED_SECTION_BEGIN,
+ LEGACY_MANAGED_SECTION_END,
+ LEGACY_OWN_FILE_TARGETS,
SKILLS,
- buildSkillMarker,
- pathFor,
- renderForTarget,
- renderOwnFileWithMarker,
TARGETS,
type AgentTarget,
+ type LegacyOwnFileSpec,
+ buildSkillMarker,
+ canonicalSkillDir,
+ canonicalSkillFile,
+ findManagedSectionBounds,
+ legacyOwnFilePath,
+ 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);
+ }
+ },
+ async readdir(p) {
+ const names = new Set();
+ const prefix = p + path.sep;
+ const direct = (k: string) => {
+ if (!k.startsWith(prefix)) return;
+ const rest = k.slice(prefix.length);
+ if (!rest.includes(path.sep)) names.add(rest);
+ };
+ for (const k of files.keys()) direct(k);
+ for (const d of dirs) direct(d);
+ return [...names];
+ },
+ };
+
+ 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();
+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);
+ });
+
+ 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());
+ });
+});
+
+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');
+
+ // 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('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: [t],
- 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(t, 'testsprite-verify');
- const abs = path.resolve(CWD, relPath);
+ 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');
+ });
- // File was written to store
- expect(store.get(abs)).toBe(content);
+ 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']);
+ });
+});
- // mkdir was called for the parent directory
- expect(mkdirCalls.some(d => d === path.dirname(abs))).toBe(true);
+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);
+ });
- // stdout contains 'written'
- expect(capture.stdout.join('\n')).toContain('written');
- expect(capture.stdout.join('\n')).toContain(relPath);
+ 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('written content equals renderForTarget exactly', 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: ['claude'],
- skills: ['testsprite-verify'],
- force: false,
+ ...deps(fs, { isTTY: true }),
+ stdout: (l: string) => stdout.push(l),
+ prompt: async () => 'codex',
},
- { cwd: CWD, fs: agentFs, ...deps },
);
-
- const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify');
- const abs = path.resolve(CWD, relPath);
- expect(store.get(abs)).toBe(content);
+ expect((JSON.parse(stdout.join('')) as InstallResult[])[0]!.target).toBe('codex');
});
- it('writes to the correct matrix paths (claude and antigravity)', 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: ['claude', 'antigravity'],
- skills: ['testsprite-verify'],
- force: false,
+ ...deps(fs, { isTTY: true }),
+ stdout: (l: string) => stdout.push(l),
+ prompt: async () => ' ',
},
- { cwd: CWD, fs: agentFs, ...deps },
);
+ expect((JSON.parse(stdout.join('')) as InstallResult[])[0]!.target).toBe('claude-code');
+ });
+});
- 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);
+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);
});
+});
- it('cline landing path .clinerules/testsprite-verify.md', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { deps } = makeCapture();
+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,
+ });
+ });
- await runInstall(
- {
- profile: 'default',
- output: 'text',
- debug: false,
- dryRun: false,
- target: ['cline'],
- skills: ['testsprite-verify'],
- force: false,
- },
- { cwd: CWD, fs: agentFs, ...deps },
- );
+ 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,
+ });
+ });
- const abs = path.resolve(CWD, '.clinerules/testsprite-verify.md');
- expect(store.has(abs)).toBe(true);
+ 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,
+ });
});
+});
- it('cursor landing path .cursor/rules/testsprite-verify.mdc', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { deps } = makeCapture();
+describe('runInstall — --force', () => {
+ 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: ['cursor'],
- skills: ['testsprite-verify'],
+ ...COMMON,
+ target: ['claude-code'],
force: false,
+ dryRun: true,
},
- { cwd: CWD, fs: agentFs, ...deps },
+ { ...deps(fs), stderr: (l: string) => stderrLines.push(l) },
);
-
- const abs = path.resolve(CWD, '.cursor/rules/testsprite-verify.mdc');
- expect(store.has(abs)).toBe(true);
+ expect(fs.writeCalls.length).toBe(0);
+ expect(fs.symlinkCalls.length).toBe(0);
+ expect(stderrLines.some(l => l.includes('[dry-run]'))).toBe(true);
});
-});
-
-// ---------------------------------------------------------------------------
-// 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;
+ 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: false,
- },
- { cwd: CWD, fs: agentFs, ...deps },
+ { ...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);
+ });
- // No new write happened
- expect(writeCalls.length).toBe(writeCountBefore);
- expect(capture.stdout.join('\n')).toContain('skipped');
- // Content unchanged
- expect(store.get(abs)).toBe(content);
+ 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(
+ { ...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('(dry-run)')).toBe(true);
+ expect(note!.includes('(skipped)')).toBe(false);
});
});
-// ---------------------------------------------------------------------------
-// runInstall — conflict without --force
-// ---------------------------------------------------------------------------
+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,
+ });
+ });
-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;
- }
+ 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,
+ });
+ });
- expect(thrown).toBeInstanceOf(CLIError);
- expect((thrown as CLIError).exitCode).toBe(6);
+ 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,
+ });
+ });
- // File not written
- expect(writeCalls.length).toBe(writeCountBefore);
- expect(store.get(abs)).toBe('DIFFERENT CONTENT');
+ 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,
+ });
+ });
- // stderr has a --force hint
- expect(capture.stderr.join('\n')).toContain('--force');
- // stdout has action:blocked
- expect(capture.stdout.join('\n')).toContain('blocked');
+ 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,
+ });
});
});
-// ---------------------------------------------------------------------------
-// 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);
-
- 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'],
- force: true,
+ ...COMMON,
+ target: ['claude-code'],
+ force: false,
+ },
+ {
+ cwd: ROOT,
+ fs: noLinkFs,
+ isTTY: false,
+ stderr: l => stderrLines.push(l),
+ stdout: (l: string) => stdout.push(l),
},
- { cwd: CWD, fs: agentFs, ...deps },
);
+ 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);
+ });
+});
- // .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);
+// 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);
+ };
- // Action reported as updated
- expect(capture.stdout.join('\n')).toContain('updated');
+ 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');
});
- 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);
+ 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,
+ });
+ });
- // First --force
- await runInstall(
- {
- profile: 'default',
- output: 'text',
- debug: false,
- dryRun: false,
- target: ['claude'],
- skills: ['testsprite-verify'],
- force: true,
- },
- { cwd: CWD, fs: agentFs, ...deps1 },
+ 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'),
);
+ });
- // .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
- await runInstall(
- {
- profile: 'default',
- output: 'text',
- debug: false,
- dryRun: false,
- target: ['claude'],
- skills: ['testsprite-verify'],
- force: true,
- },
- { cwd: CWD, fs: agentFs, ...deps2 },
+ 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'),
);
+ });
- // 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);
+ 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 });
});
});
-// ---------------------------------------------------------------------------
-// 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();
+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: true,
- target: ['claude'],
- 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) },
);
-
- // 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');
+ 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');
});
});
-// ---------------------------------------------------------------------------
-// runInstall — --dir override
-// ---------------------------------------------------------------------------
-
describe('runInstall — --dir override', () => {
it('writes under --dir instead of cwd', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { deps } = makeCapture();
-
- const customDir = '/custom-dir';
-
+ const fs = makeMemFs();
+ const other = path.resolve('/other-dir');
await runInstall(
- {
- profile: 'default',
- output: 'text',
- debug: false,
- dryRun: false,
- target: ['claude'],
- skills: ['testsprite-verify'],
- force: false,
- dir: customDir,
- },
- { cwd: CWD, fs: agentFs, ...deps },
+ { ...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,
);
+ });
+});
- 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);
+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'));
+ }
+ }
});
});
// ---------------------------------------------------------------------------
-// runInstall — unknown target
+// runList
// ---------------------------------------------------------------------------
-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;
+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'));
}
-
- 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);
});
});
// ---------------------------------------------------------------------------
-// runInstall — multi-target
+// runStatus
// ---------------------------------------------------------------------------
-describe('runInstall — multi-target', () => {
- it('writes both claude and cursor when both specified', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { capture, deps } = makeCapture();
+function seedCanonical(fs: ReturnType, skill: string, content: string) {
+ fs.seedFile(canonicalPath(skill), content);
+}
- await runInstall(
- {
- profile: 'default',
- output: 'text',
- debug: false,
- dryRun: false,
- target: ['claude', 'cursor'],
- skills: ['testsprite-verify'],
- force: false,
- },
- { cwd: CWD, fs: agentFs, ...deps },
+describe('runStatus', () => {
+ it('empty project: no rows, exit 0', async () => {
+ const fs = makeMemFs();
+ await expect(runStatus({ ...COMMON }, deps(fs))).resolves.toBeUndefined();
+ });
+
+ 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');
+ });
+
+ 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),
);
-
- 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');
+ 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('comma-separated in single --target value works', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { deps } = makeCapture();
+ 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 });
+ });
- await runInstall(
- {
- profile: 'default',
- output: 'text',
- debug: false,
- dryRun: false,
- target: ['claude,cursor'],
- skills: ['testsprite-verify'],
- force: false,
- },
- { cwd: CWD, fs: agentFs, ...deps },
+ 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');
+ });
- expect(store.has(path.resolve(CWD, TARGETS.claude.path))).toBe(true);
- expect(store.has(path.resolve(CWD, TARGETS.cursor.path))).toBe(true);
- });
-
- 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;
- }
-
- // 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('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');
});
- it('de-duplicates repeated targets', async () => {
- const { fs: agentFs, writeCalls } = makeMemFs();
- const { deps } = makeCapture();
+ 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: ['claude', 'claude'],
- skills: ['testsprite-verify'],
- force: false,
- },
- { cwd: CWD, fs: agentFs, ...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) },
);
-
- // Only one write
- const claudeAbs = path.resolve(CWD, TARGETS.claude.path);
- expect(writeCalls.filter(p => p === claudeAbs).length).toBe(1);
+ const text = stdout.join('');
+ expect(text).toContain('TARGET');
+ expect(text).toContain('STATE');
+ expect(text).toContain('codex');
+ expect(text).toContain('ok');
+ });
+
+ 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');
+ });
+
+ 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');
});
});
// ---------------------------------------------------------------------------
-// runInstall — empty target (TTY / non-TTY)
+// Command wiring
// ---------------------------------------------------------------------------
-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 },
- );
+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 });
+ });
- const claudeAbs = path.resolve(CWD, TARGETS.claude.path);
- expect(store.has(claudeAbs)).toBe(true);
- expect(capture.stderr.join('\n')).toContain('defaulting to claude');
+ 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('non-TTY default writes the canonical claude content', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { deps } = makeCapture();
+ 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');
+ });
+});
- await runInstall(
- { profile: 'default', output: 'text', debug: false, dryRun: false, target: [], force: false },
- { cwd: CWD, fs: agentFs, isTTY: false, ...deps },
- );
+// ---------------------------------------------------------------------------
+// Sanity: SKILLS still ships the expected bodies (compile-time drift guard)
+// ---------------------------------------------------------------------------
- const { path: relPath, content } = renderForTarget('claude', 'testsprite-verify');
- const abs = path.resolve(CWD, relPath);
- expect(store.get(abs)).toBe(content);
+describe('shipped skills', () => {
+ it('SKILLS has testsprite-verify and testsprite-onboard', () => {
+ expect(Object.keys(SKILLS).sort()).toEqual(['testsprite-onboard', 'testsprite-verify']);
});
+});
- it('TTY with injected prompt returning "claude" installs claude', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { deps } = makeCapture();
+// ---------------------------------------------------------------------------
+// runInstall --force — legacy migration (issue #270 gate #2)
+// ---------------------------------------------------------------------------
- const promptFn = vi.fn().mockResolvedValue('claude');
+const legacyMarker = (skill: string) => buildSkillMarker(skill, loadSkillFull(skill));
+const claudeSpec = LEGACY_OWN_FILE_TARGETS.find(s => s.legacyTarget === 'claude')!;
+const cursorSpec = LEGACY_OWN_FILE_TARGETS.find(s => s.legacyTarget === 'cursor')!;
+
+function seedLegacyOwnFile(
+ fs: ReturnType,
+ spec: LegacyOwnFileSpec,
+ skill: string,
+): string {
+ // Mimic the old wrap: frontmatter + provenance marker + body. Only the marker
+ // matters for detection; the body/shape is irrelevant to migration.
+ const content = `---\nname: ${skill}\ndescription: legacy\n---\n${legacyMarker(skill)}\n# ${skill}\nlegacy body\n`;
+ const rel = legacyOwnFilePath(spec, skill);
+ fs.seedFile(path.resolve(ROOT, rel), content);
+ return rel;
+}
- 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 },
- );
+function seedLegacyCodexSection(
+ fs: ReturnType,
+ opts: { before?: string; body?: string; after?: string } = {},
+): string {
+ const before = opts.before ?? '# My project\n\nWelcome.\n';
+ const after = opts.after ?? '\n## Notes\n\nHand-written notes.\n';
+ const body =
+ opts.body ?? `${legacyMarker('testsprite-verify')}\nRun testsprite tests after changes.`;
+ const content = `${before}${LEGACY_MANAGED_SECTION_BEGIN}\n${body}\n${LEGACY_MANAGED_SECTION_END}\n${after}`;
+ fs.seedFile(path.resolve(ROOT, 'AGENTS.md'), content);
+ return content;
+}
+
+/** Run `agent install --force` (default target: codex) capturing stdout/stderr. */
+async function runInstallForceCapture(
+ fs: ReturnType,
+ opts: { dryRun?: boolean; target?: string[]; output?: 'json' | 'text' } = {},
+) {
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+ await runInstall(
+ {
+ ...COMMON,
+ output: opts.output ?? 'json',
+ target: opts.target ?? ['codex'],
+ force: true,
+ dryRun: opts.dryRun ?? false,
+ },
+ { ...deps(fs), stdout: (l: string) => stdout.push(l), stderr: (l: string) => stderr.push(l) },
+ );
+ return { stdout: stdout.join(''), stderr: stderr.join('\n') };
+}
- expect(promptFn).toHaveBeenCalledOnce();
- const claudeAbs = path.resolve(CWD, TARGETS.claude.path);
- expect(store.has(claudeAbs)).toBe(true);
+describe('runInstall --force — migrates legacy own-file artifacts (scoped to --target)', () => {
+ it('claude: backs up the legacy folder to a sibling .bak/ and REPLACES it with a symlink', async () => {
+ const fs = makeMemFs();
+ const rel = seedLegacyOwnFile(fs, claudeSpec, 'testsprite-verify');
+
+ const { stderr } = await runInstallForceCapture(fs, { target: ['claude-code'] });
+
+ // The legacy folder is gone; a sibling .bak/ preserves the old SKILL.md.
+ expect(fs.files.has(path.resolve(ROOT, rel))).toBe(false);
+ expect(
+ fs.files.get(path.resolve(ROOT, '.claude/skills/testsprite-verify.bak/SKILL.md'))?.content,
+ ).toContain('legacy body');
+ // The agent's read path is now a SYMLINK → canonical (created by the install phase).
+ const link = fs.files.get(landingPath('claude-code', 'testsprite-verify'))!;
+ expect(link?.kind).toBe('symlink');
+ expect(link?.target).toBe(
+ path.relative(
+ path.dirname(landingPath('claude-code', 'testsprite-verify')),
+ path.resolve(ROOT, canonicalSkillDir('testsprite-verify')),
+ ),
+ );
+ // The SKILL.md reachable through the link is canonical, not the old body.
+ expect(fs.files.get(canonicalPath('testsprite-verify'))?.content).not.toContain('legacy body');
+ expect(stderr).toMatch(
+ /converted claude skill at .claude\/skills\/testsprite-verify\/SKILL.md/,
+ );
+ expect(stderr).toMatch(/find . -name "\*\.bak" -prune -exec rm -rf/);
+ });
+
+ it('cursor (universal): backs up the obsolete file and removes it; no symlink (reads canonical)', async () => {
+ const fs = makeMemFs();
+ const rel = seedLegacyOwnFile(fs, cursorSpec, 'testsprite-verify');
+ await runInstallForceCapture(fs, { target: ['cursor'] });
+ expect(fs.files.has(path.resolve(ROOT, rel))).toBe(false);
+ expect(fs.files.get(path.resolve(ROOT, `${rel}.bak`))?.content).toContain('legacy body');
+ // cursor is universal — canonical is the destination, no symlink landing.
+ expect(fs.files.has(canonicalPath('testsprite-verify'))).toBe(true);
+ });
+
+ it('does not touch a user-authored file at a legacy path (no provenance marker)', async () => {
+ const fs = makeMemFs();
+ const rel = '.cursor/rules/testsprite-verify.mdc';
+ fs.seedFile(path.resolve(ROOT, rel), '---\ndescription: mine\n---\n# my own rule\n');
+ await runInstallForceCapture(fs, { target: ['cursor'] });
+ expect(fs.files.has(path.resolve(ROOT, rel))).toBe(true);
+ });
+
+ it('SCOPED: --target codex does NOT migrate a legacy claude folder', async () => {
+ const fs = makeMemFs();
+ seedLegacyOwnFile(fs, claudeSpec, 'testsprite-verify');
+ const { stderr } = await runInstallForceCapture(fs, { target: ['codex'] });
+ // claude's legacy folder is untouched (codex wasn't asked to migrate it).
+ expect(fs.files.has(path.resolve(ROOT, '.claude/skills/testsprite-verify/SKILL.md'))).toBe(
+ true,
+ );
+ expect(fs.dirs.has(path.resolve(ROOT, '.claude/skills/testsprite-verify.bak'))).toBe(false);
+ expect(stderr).not.toMatch(/migrated claude/);
+ });
+
+ it('does not migrate a new-format symlink landing (claude-code)', async () => {
+ const fs = makeMemFs();
+ await runInstallJson(fs, { target: ['claude-code'] });
+ await runInstallForceCapture(fs, { target: ['claude-code'] });
+ // The symlink is intact; no .bak folder was created next to it.
+ expect(fs.files.get(landingPath('claude-code', 'testsprite-verify'))?.kind).toBe('symlink');
+ expect(fs.dirs.has(path.resolve(ROOT, '.claude/skills/testsprite-verify.bak'))).toBe(false);
+ });
+
+ it('refuses (exit 5) a legacy folder with unknown nested content', async () => {
+ const fs = makeMemFs();
+ seedLegacyOwnFile(fs, claudeSpec, 'testsprite-verify');
+ // Plant a nested subdirectory the old install never wrote → refuse rather than destroy.
+ fs.seedFile(path.resolve(ROOT, '.claude/skills/testsprite-verify/notes/x.md'), 'user data');
+ await expect(runInstallForceCapture(fs, { target: ['claude-code'] })).rejects.toMatchObject({
+ exitCode: 5,
+ });
+ expect(
+ fs.files.get(path.resolve(ROOT, '.claude/skills/testsprite-verify/notes/x.md'))?.content,
+ ).toBe('user data');
});
+});
- it('TTY with empty prompt answer defaults to claude', async () => {
- const { store, fs: agentFs } = makeMemFs();
- const { deps } = makeCapture();
+describe('runInstall --force — codex AGENTS.md managed section', () => {
+ it('backs up AGENTS.md, removes only the section, preserves surrounding content', async () => {
+ const fs = makeMemFs();
+ const original = seedLegacyCodexSection(fs, {
+ before: '# Project Alpha\n\nIntro paragraph.\n',
+ after: '\n## Footer\n\nKeep me.\n',
+ });
- const promptFn = vi.fn().mockResolvedValue(''); // empty => default to claude
+ await runInstallForceCapture(fs);
- 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 },
- );
+ // Backup holds the entire original file.
+ expect(fs.files.get(path.resolve(ROOT, 'AGENTS.md.bak'))?.content).toBe(original);
+ // The sentinel block is gone; user content survives.
+ const next = fs.files.get(path.resolve(ROOT, 'AGENTS.md'))?.content ?? '';
+ expect(next).not.toContain(LEGACY_MANAGED_SECTION_BEGIN);
+ expect(next).not.toContain(LEGACY_MANAGED_SECTION_END);
+ expect(next).toContain('# Project Alpha');
+ expect(next).toContain('Intro paragraph.');
+ expect(next).toContain('## Footer');
+ expect(next).toContain('Keep me.');
+ });
- const claudeAbs = path.resolve(CWD, TARGETS.claude.path);
- expect(store.has(claudeAbs)).toBe(true);
+ it('refuses (exit 5) a corrupt sentinel block without writing anything', async () => {
+ const fs = makeMemFs();
+ const malformed = `# head\n\n${LEGACY_MANAGED_SECTION_BEGIN}\nbody with no end sentinel\n`;
+ fs.seedFile(path.resolve(ROOT, 'AGENTS.md'), malformed);
+ await expect(runInstallForceCapture(fs)).rejects.toMatchObject({ exitCode: 5 });
+ expect(fs.files.get(path.resolve(ROOT, 'AGENTS.md'))?.content).toBe(malformed);
+ expect(fs.files.has(path.resolve(ROOT, 'AGENTS.md.bak'))).toBe(false);
});
});
-// ---------------------------------------------------------------------------
-// runList
-// ---------------------------------------------------------------------------
+describe('runInstall --force — idempotency, dry-run, and the cleanup tip', () => {
+ it('is idempotent: a second --force install reports no migration', async () => {
+ const fs = makeMemFs();
+ seedLegacyCodexSection(fs);
+ await runInstallForceCapture(fs);
+ const second = await runInstallForceCapture(fs);
+ expect(second.stderr).not.toMatch(/migrated /);
+ });
-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);
- });
-
- 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('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('--dry-run --force plans the migration but writes/removes nothing', async () => {
+ const fs = makeMemFs();
+ const rel = seedLegacyOwnFile(fs, cursorSpec, 'testsprite-verify');
+ const { stderr } = await runInstallForceCapture(fs, { target: ['cursor'], dryRun: true });
+ expect(stderr).toMatch(/migrated cursor skill/);
+ expect(fs.files.has(path.resolve(ROOT, rel))).toBe(true);
+ expect(fs.files.has(path.resolve(ROOT, `${rel}.bak`))).toBe(false);
+ expect(fs.files.has(canonicalPath('testsprite-verify'))).toBe(false);
});
-});
-
-// ---------------------------------------------------------------------------
-// JSON vs text output shapes for install
-// ---------------------------------------------------------------------------
-
-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');
+ it('a clean repo: --force install does no migration and prints no tip', async () => {
+ const fs = makeMemFs();
+ const { stderr } = await runInstallForceCapture(fs);
+ expect(stderr).not.toMatch(/migrated /);
+ expect(stderr).not.toMatch(/prune -exec rm -rf/);
});
});
-// ---------------------------------------------------------------------------
-// [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`);
-
+describe('runInstall (plain, no --force) — refuses a legacy dir collision', () => {
+ it('exit 6 with a migration hint when a legacy claude folder blocks the symlink', async () => {
+ const fs = makeMemFs();
+ seedLegacyOwnFile(fs, claudeSpec, 'testsprite-verify');
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 },
+ runInstall({ ...COMMON, target: ['claude-code'], force: false }, deps(fs)),
+ ).rejects.toMatchObject({ exitCode: 6 });
+ // Plain install never migrates: the legacy folder is untouched.
+ expect(fs.files.has(path.resolve(ROOT, '.claude/skills/testsprite-verify/SKILL.md'))).toBe(
+ true,
);
- 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);
+ expect(fs.dirs.has(path.resolve(ROOT, '.claude/skills/testsprite-verify.bak'))).toBe(false);
});
});
-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 },
+describe('runStatus — legacy-artifact nudge', () => {
+ it('names the scoped targets and the exact --force --target command to migrate', async () => {
+ const fs = makeMemFs();
+ seedLegacyOwnFile(fs, claudeSpec, 'testsprite-verify');
+ seedLegacyCodexSection(fs);
+ const stderr: string[] = [];
+ await expect(
+ runStatus(
+ { ...COMMON, output: 'json' },
+ { ...deps(fs), stderr: (l: string) => stderr.push(l) },
+ ),
+ ).rejects.toMatchObject({ exitCode: 1 });
+ const out = stderr.join('\n');
+ expect(out).toMatch(/legacy installs for:.*claude-code/);
+ expect(out).toMatch(/codex/);
+ expect(out).toMatch(/testsprite agent install --force --target \S+/);
+ });
+
+ it('does not emit the nudge on a clean project', async () => {
+ const fs = makeMemFs();
+ const stderr: string[] = [];
+ await runStatus(
+ { ...COMMON, output: 'json' },
+ { ...deps(fs), stderr: (l: string) => stderr.push(l) },
);
-
- 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']);
+ expect(stderr.join('\n')).not.toMatch(/legacy installs for:/);
});
});
-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 },
+// Pure bounds-finder (data-only; no filesystem).
+describe('findManagedSectionBounds', () => {
+ it('absent when no sentinels', () => {
+ expect(findManagedSectionBounds('no sentinels here\n')).toEqual({ state: 'absent' });
+ });
+ it('present with a balanced pair', () => {
+ const c = `a\n${LEGACY_MANAGED_SECTION_BEGIN}\nx\n${LEGACY_MANAGED_SECTION_END}\nb\n`;
+ const b = findManagedSectionBounds(c);
+ expect(b.state).toBe('present');
+ if (b.state === 'present') {
+ expect(c.slice(b.start, b.end)).toBe(
+ `${LEGACY_MANAGED_SECTION_BEGIN}\nx\n${LEGACY_MANAGED_SECTION_END}\n`,
);
- } 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');
+ // Removing the range leaves the surrounding content intact.
+ expect(c.slice(0, b.start) + c.slice(b.end)).toBe('a\nb\n');
}
});
-
- 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('corrupt when BEGIN has no END', () => {
+ const b = findManagedSectionBounds(`a\n${LEGACY_MANAGED_SECTION_BEGIN}\nx\n`);
+ expect(b.state).toBe('corrupt');
});
-
- 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 });
+ it('corrupt when END appears before BEGIN', () => {
+ const c = `${LEGACY_MANAGED_SECTION_END}\nx\n${LEGACY_MANAGED_SECTION_BEGIN}\n`;
+ expect(findManagedSectionBounds(c).state).toBe('corrupt');
});
});
diff --git a/src/commands/agent.ts b/src/commands/agent.ts
index 7d7e7c8..883689c 100644
--- a/src/commands/agent.ts
+++ b/src/commands/agent.ts
@@ -8,72 +8,94 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js';
import { promptText } from '../lib/prompt.js';
import {
type AgentTarget,
- TARGETS,
- SKILLS,
+ type LegacyOwnFileSpec,
DEFAULT_SKILLS,
- MARKER_SKILL_SEPARATOR,
- pathFor,
- loadSkillBodyFor,
+ LEGACY_OWN_FILE_TARGETS,
+ SKILLS,
+ TARGETS,
+ acceptedTargetTokens,
bodyHash12,
- compactBodyFor,
- buildCodexAggregate,
buildSkillMarker,
+ canonicalSkillDir,
+ canonicalSkillFile,
+ findManagedSectionBounds,
+ legacyOwnFilePath,
+ 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;
+ /** List the direct children of a directory (entry names only). Throws on ENOENT. */
+ readdir(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 });
+ },
+ async readdir(p) {
+ return fs.readdir(p);
+ },
};
// ---------------------------------------------------------------------------
@@ -81,19 +103,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 +119,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 +175,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 +186,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 +198,208 @@ export interface AgentDeps {
prompt?: (question: string) => Promise;
}
-// ---------------------------------------------------------------------------
-// Result types
-// ---------------------------------------------------------------------------
-
export type InstallAction =
| 'written'
| 'skipped'
| 'blocked'
| 'updated'
+ | 'migrated'
| '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';
+ // A real folder with a provenance marker is a legacy skill.
+ // Under --force migration already handled it; refuse without --force.
+ if (parseSkillMarker(existing) !== null && !force) {
+ throw new CLIError(
+ `${landingRel} is a legacy TestSprite skill folder that must be ` +
+ `replaced by a symlink to ${canonicalSkillDir(skill)}. Re-run with --force to back it up ` +
+ `to ${landingRel}.bak and link to the canonical skill.`,
+ 6,
+ );
+ }
+ 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 +409,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 +462,171 @@ 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;
- });
+ const skills = rawSkills.length > 0 ? [...new Set(rawSkills)] : [...DEFAULT_SKILLS];
- // 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[] = [];
+ const root = path.resolve(opts.dir ?? deps.cwd ?? process.cwd());
- // 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,
- );
- }
+ // 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))),
+ );
- // 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.`,
- );
+ // Under --force, retire legacy artifacts FIRST so the canonical/symlink
+ // phases land on a clean tree.
+ const migrated = new Set();
+ if (opts.force) {
+ const migration = await migrateLegacyArtifacts(
+ agentFs,
+ root,
+ dedupedTargets,
+ skills,
+ Boolean(opts.dryRun),
+ );
+ if (migration.length > 0) {
+ printMigrationSummary(migration, stderrFn);
+ // Report each migrated (target, skill) as 'migrated' rather than 'skipped'.
+ for (const r of migration) {
+ if (r.skill !== null) {
+ migrated.add(`${r.targetId}\0${r.skill}`);
+ } else {
+ // codex managed section covers every skill.
+ for (const s of skills) migrated.add(`${r.targetId}\0${s}`);
}
- dryRunLines.push({ abs, bytes, note: 'managed section' });
- results.push({ target: t, path: relPath, action: 'dry-run', skills: [...skills] });
- continue;
}
+ }
+ }
- // Inspect the target path via lstat walk (symlink-safe, same as own-file).
- 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,
- );
- }
+ 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) {
+ // canonical already correct → 'skipped'; migration upgrades it.
+ let action: InstallAction = cAction;
+ if (action === 'skipped' && migrated.has(`${t}\0${skill}`)) action = 'migrated';
+ results.push({
+ target: t,
+ path: pathFor(t, skill),
+ action,
+ 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';
+ if (action === 'skipped' && migrated.has(`${t}\0${skill}`)) action = 'migrated';
+ 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 +635,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,193 +704,335 @@ 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 stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
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 });
}
}
+ // Advisory: surface a scoped --force hint for any legacy artifacts (stderr
+ // only, never changes exit code).
+ const legacyTargets = await detectLegacyTargets(agentFs, root);
+ if (legacyTargets.length > 0) {
+ const list = legacyTargets.join(',');
+ stderrFn(
+ `[info] found legacy installs for: ${legacyTargets.join(', ')}. ` +
+ `Run \`testsprite agent install --force --target ${list}\` to back them up and migrate them (*.bak kept).`,
+ );
+ }
+
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,
);
}
}
+// ---------------------------------------------------------------------------
+// Legacy migration
+// ---------------------------------------------------------------------------
+
+/** One legacy artifact retired by `agent install --force` (stderr summary only). */
+interface MigrationRow {
+ /** 'own-file' = a legacy per-target skill file/folder; 'managed-section' = the codex AGENTS.md block. */
+ kind: 'own-file' | 'managed-section';
+ targetId: AgentTarget;
+ /** Legacy target id (own-file) or 'codex' (managed-section) — messaging only. */
+ legacyTarget: string;
+ /** null for the codex managed section (it aggregated skills). */
+ skill: string | null;
+ from: string;
+ backup: string;
+ to: string;
+}
+
+/**
+ * Read a legacy own-file artifact, or null when none is present. Requires a
+ * file at the legacy path AND a provenance marker inside it (so user-authored
+ * files are never touched). A 'dir'-kind symlink landing is the new format, not
+ * a legacy artifact, and is skipped.
+ */
+async function readLegacyOwnFile(
+ agentFs: AgentFs,
+ root: string,
+ spec: LegacyOwnFileSpec,
+ skill: string,
+): Promise<{ path: string; content: string } | null> {
+ const rel = legacyOwnFilePath(spec, skill);
+ if (spec.kind === 'dir') {
+ const skillDirRel = rel.slice(0, rel.length - '/SKILL.md'.length);
+ const skillDirStat = await agentFs.lstat(path.resolve(root, skillDirRel));
+ if (skillDirStat === null) return null;
+ if (skillDirStat.isSymbolicLink) return null;
+ const fileStat = await agentFs.lstat(path.resolve(root, rel));
+ if (fileStat === null || !fileStat.isFile) return null;
+ const content = await agentFs.readFile(path.resolve(root, rel));
+ return parseSkillMarker(content) === null ? null : { path: rel, content };
+ }
+ const abs = path.resolve(root, rel);
+ const st = await agentFs.lstat(abs);
+ if (st === null || !st.isFile) return null;
+ const content = await agentFs.readFile(abs);
+ return parseSkillMarker(content) === null ? null : { path: rel, content };
+}
+
+/** Targets that have a legacy artifact under `root` (for the status nudge). */
+async function detectLegacyTargets(agentFs: AgentFs, root: string): Promise {
+ const found = new Set();
+ for (const spec of LEGACY_OWN_FILE_TARGETS) {
+ for (const skill of Object.keys(SKILLS)) {
+ if ((await readLegacyOwnFile(agentFs, root, spec, skill)) !== null) {
+ found.add(spec.newTarget);
+ break; // one artifact is enough to know this target has legacy
+ }
+ }
+ }
+ const agentsStat = await agentFs.lstat(path.resolve(root, 'AGENTS.md'));
+ if (agentsStat !== null && agentsStat.isFile) {
+ const existing = await agentFs.readFile(path.resolve(root, 'AGENTS.md'));
+ if (findManagedSectionBounds(existing).state === 'present') found.add('codex');
+ }
+ return [...found];
+}
+
+/**
+ * Retire legacy artifacts for the REQUESTED targets (scoped). Idempotent.
+ *
+ * 'file'-kind → backed up to `.bak` and unlinked.
+ * 'dir'-kind → folder backed up to `.bak/` and removed (install phase
+ * plants the symlink in its place).
+ * codex → the AGENTS.md managed section is removed in place.
+ *
+ * Malformed sentinels or non-file entries in a 'dir'-kind folder throw (exit 5).
+ */
+async function migrateLegacyArtifacts(
+ agentFs: AgentFs,
+ root: string,
+ targets: readonly AgentTarget[],
+ skills: readonly string[],
+ dryRun: boolean,
+): Promise {
+ const rows: MigrationRow[] = [];
+
+ // codex: the AGENTS.md managed section (one section, aggregates skills) — retire once.
+ if (targets.includes('codex')) {
+ const agentsMdRel = 'AGENTS.md';
+ const agentsMdAbs = path.resolve(root, agentsMdRel);
+ const agentsStat = await agentFs.lstat(agentsMdAbs);
+ if (agentsStat !== null && agentsStat.isFile) {
+ const existing = await agentFs.readFile(agentsMdAbs);
+ const bounds = findManagedSectionBounds(existing);
+ if (bounds.state === 'corrupt') {
+ throw new CLIError(
+ `${agentsMdRel} contains a malformed TestSprite sentinel block (${bounds.reason}). ` +
+ `Manually remove the partial sentinel lines and re-run.`,
+ 5,
+ );
+ }
+ if (bounds.state === 'present') {
+ let backupRel: string;
+ if (dryRun) {
+ backupRel = path.relative(root, `${agentsMdAbs}.bak`);
+ } else {
+ const backupAbs = await writeBackup(agentFs, agentsMdAbs, existing);
+ backupRel = path.relative(root, backupAbs);
+ const next = existing.slice(0, bounds.start) + existing.slice(bounds.end);
+ await agentFs.writeFile(agentsMdAbs, next);
+ }
+ rows.push({
+ kind: 'managed-section',
+ targetId: 'codex',
+ legacyTarget: 'codex',
+ skill: null,
+ from: agentsMdRel,
+ backup: backupRel,
+ to: canonicalSkillFile('testsprite-verify'),
+ });
+ }
+ }
+ }
+
+ for (const target of targets) {
+ const spec = LEGACY_OWN_FILE_TARGETS.find(s => s.newTarget === target);
+ if (spec === undefined) continue; // target has no legacy format (e.g. amp, antigravity)
+ for (const skill of skills) {
+ const hit = await readLegacyOwnFile(agentFs, root, spec, skill);
+ if (hit === null) continue;
+ const legacyAbs = path.resolve(root, hit.path);
+ let backupRel: string;
+ if (spec.kind === 'dir') {
+ const dirRel = hit.path.slice(0, -'/SKILL.md'.length);
+ backupRel = await backupLegacyDir(agentFs, root, dirRel, dryRun);
+ if (!dryRun) await agentFs.rm(path.resolve(root, dirRel));
+ } else {
+ if (dryRun) {
+ backupRel = path.relative(root, `${legacyAbs}.bak`);
+ } else {
+ const backupAbs = await writeBackup(agentFs, legacyAbs, hit.content);
+ backupRel = path.relative(root, backupAbs);
+ await agentFs.unlink(legacyAbs);
+ }
+ }
+ rows.push({
+ kind: 'own-file',
+ targetId: spec.newTarget,
+ legacyTarget: spec.legacyTarget,
+ skill,
+ from: hit.path,
+ backup: backupRel,
+ to: pathFor(spec.newTarget, skill),
+ });
+ }
+ }
+
+ return rows;
+}
+
+/**
+ * Back up a legacy 'dir'-kind folder to a non-clobbering sibling `.bak[.N]/`.
+ * Refuses (exit 5) on nested dirs/symlinks — only `SKILL.md` is expected.
+ */
+async function backupLegacyDir(
+ agentFs: AgentFs,
+ root: string,
+ dirRel: string,
+ dryRun: boolean,
+): Promise {
+ if (dryRun) return `${dirRel}.bak`;
+ const dirAbs = path.resolve(root, dirRel);
+ const baseRel = `${dirRel}.bak`;
+ const baseAbs = path.resolve(root, baseRel);
+ let backupAbs = baseAbs;
+ let n = 0;
+ while ((await agentFs.lstat(backupAbs)) !== null) {
+ n += 1;
+ backupAbs = `${baseAbs}.${n}`;
+ }
+ const entries = await agentFs.readdir(dirAbs);
+ await agentFs.mkdir(backupAbs);
+ for (const name of entries) {
+ const childAbs = path.join(dirAbs, name);
+ const cst = await agentFs.lstat(childAbs);
+ if (cst === null) continue;
+ if (!cst.isFile) {
+ throw new CLIError(
+ `${dirRel} contains a non-file entry "${name}" (old installs only wrote SKILL.md). ` +
+ `Remove it manually and re-run.`,
+ 5,
+ );
+ }
+ await agentFs.writeFile(path.join(backupAbs, name), await agentFs.readFile(childAbs), {
+ exclusive: true,
+ });
+ }
+ return n === 0 ? baseRel : `${baseRel}.${n}`;
+}
+
+/**
+ * Emit the per-row summary and the *.bak cleanup tip (stderr only).
+ * 'dir'-kind (claude/kiro): "converted" (folder → symlink, from === to).
+ * 'file'-kind: "migrated" (obsolete file → canonical/symlink path).
+ * codex managed section: "migrated" (removed from AGENTS.md).
+ */
+function printMigrationSummary(rows: MigrationRow[], stderr: (line: string) => void): void {
+ for (const r of rows) {
+ if (r.kind === 'managed-section') {
+ stderr(`migrated codex managed section in ${r.from} → ${r.to} (backup: ${r.backup})`);
+ } else if (r.from === r.to) {
+ // In-place: the folder at the agent's own skills path became a symlink.
+ stderr(
+ `converted ${r.legacyTarget} skill at ${r.from} (folder → symlink; backup: ${r.backup})`,
+ );
+ } else {
+ stderr(`migrated ${r.legacyTarget} skill at ${r.from} → ${r.to} (backup: ${r.backup})`);
+ }
+ }
+ stderr(
+ 'tip: a backup of each converted legacy artifact was kept as *.bak. Once you have confirmed ' +
+ 'everything looks correct, you can delete them — for example: ' +
+ 'find . -name "*.bak" -prune -exec rm -rf {} +.',
+ );
+}
+
// ---------------------------------------------------------------------------
// Command factory
// ---------------------------------------------------------------------------
@@ -1062,7 +1043,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 +1053,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 +1066,8 @@ 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, and migrate any legacy ' +
+ 'artifacts found in the repo (originals kept as *.bak).',
)
.addHelpText('after', GLOBAL_OPTS_HINT)
.action(
@@ -1109,7 +1090,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 +1101,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). Universal agents share one canonical skill file (installing for any one serves all); symlinked agents appear only when their own landing exists. 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 +1112,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..d2d5ee1 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,47 @@ 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);
+ }
+ },
+ async readdir(p: string) {
+ const names = new Set();
+ const prefix = p + path.sep;
+ const direct = (k: string) => {
+ if (!k.startsWith(prefix)) return;
+ const rest = k.slice(prefix.length);
+ if (!rest.includes(path.sep)) names.add(rest);
+ };
+ for (const k of store.keys()) direct(k);
+ for (const s of symlinks.keys()) direct(s);
+ for (const d of dirs) direct(d);
+ return [...names];
+ },
};
- return { store, fs: agentFs, writeCalls, mkdirCalls };
+ return { store, fs: agentFs, writeCalls, mkdirCalls, symlinkCalls };
}
// ---------------------------------------------------------------------------
@@ -147,7 +195,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 +434,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 +452,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 +482,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 +827,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 +897,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 +970,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 +1027,21 @@ 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');
+ },
+ async readdir() {
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
+ },
};
let caughtErr: unknown;
diff --git a/src/commands/init.ts b/src/commands/init.ts
index 659945f..2d369cc 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';
@@ -121,7 +121,8 @@ export interface InitSummary {
* which outranks a no-op. `blocked` never reaches here — runInstall throws first.
*/
function aggregateInstallAction(actions: string[]): string {
- if (actions.some(a => a === 'updated' || a === 'section-updated')) return 'updated';
+ if (actions.some(a => a === 'updated' || a === 'migrated' || a === 'section-updated'))
+ return 'updated';
if (actions.some(a => a === 'written' || a === 'section-installed')) return 'installed';
if (actions.some(a => a === 'dry-run')) return 'dry-run';
return 'skipped'; // all skipped / section-unchanged
@@ -507,11 +508,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 +518,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 +597,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 +614,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 +635,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..bcc63ad 100644
--- a/src/lib/agent-targets.test.ts
+++ b/src/lib/agent-targets.test.ts
@@ -1,930 +1,348 @@
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([
+ 'adal',
+ 'aider-desk',
+ 'amp',
'antigravity',
- 'claude',
+ 'antigravity-cli',
+ 'astrbot',
+ 'augment',
+ 'autohand-code',
+ 'bob',
+ 'claude-code',
'cline',
+ 'codearts-agent',
+ 'codebuddy',
+ 'codebuddy-cli',
+ 'codebuddy-cn',
+ 'codebuddy-cn-cli',
+ 'codestudio',
'codex',
- 'copilot',
+ 'command-code',
+ 'continue',
+ 'cortex',
+ 'crush',
'cursor',
+ 'devin',
+ 'devin-cloud',
+ 'devin-desktop',
+ 'droid',
+ 'eve',
+ 'firebender',
+ 'forgecode',
+ 'github-copilot',
+ 'goose',
+ 'hermes-agent',
+ 'junie',
+ 'kilo',
+ 'kimi-code-cli',
'kiro',
- 'windsurf',
+ 'kiro-cli',
+ 'kode',
+ 'mcpjam',
+ 'mistral-vibe',
+ 'mux',
+ 'neovate',
+ 'ona',
+ 'opencode',
+ 'openclaw',
+ 'openhands',
+ 'pi',
+ 'pochi',
+ 'promptscript',
+ 'qoder',
+ 'qoder-cli',
+ 'qoder-cn',
+ 'qoder-cn-cli',
+ 'qwen-code',
+ 'reasonix',
+ 'replit',
+ 'rovodev',
+ 'tabnine-cli',
+ 'trae',
+ 'trae-cn',
+ 'trae-cn-cli',
+ 'vtcode',
+ 'warp',
+ 'zed',
+ 'zencoder',
]);
});
- 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');
+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('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);
+ 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);
+ }
});
- it('testsprite-verify.codex.md is ≤ 6144 bytes (6 KiB trim budget)', () => {
- const body = loadCodexSkillBody();
- expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(6144);
+ it('resolveTarget returns every canonical id as-is', () => {
+ for (const id of Object.keys(TARGETS)) {
+ expect(resolveTarget(id), id).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 resolves every alias to its mapped id', () => {
+ for (const [alias, id] of Object.entries(TARGET_ALIASES)) {
+ expect(resolveTarget(alias), alias).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 rejects an unknown token', () => {
+ expect(resolveTarget('definitely-not-an-agent')).toBeNull();
});
- it('END sentinel is an HTML comment', () => {
- expect(MANAGED_SECTION_END.startsWith('')).toBe(true);
+ it('resolveTarget rejects inherited prototype-chain keys as unknown', () => {
+ // Inherited keys (constructor, __proto__, ...) are truthy on a plain object.
+ for (const token of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
+ expect(resolveTarget(token), token).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('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('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('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');
+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('cline + testsprite-verify', () => {
- expect(pathFor('cline', 'testsprite-verify')).toBe('.clinerules/testsprite-verify.md');
+ it('throws when frontmatter is missing', () => {
+ expect(() => parseSkillFrontmatter('no frontmatter here')).toThrow(/missing frontmatter/);
});
- it('codex + testsprite-verify', () => {
- expect(pathFor('codex', 'testsprite-verify')).toBe('AGENTS.md');
- });
-
- it('claude + testsprite-onboard', () => {
- expect(pathFor('claude', 'testsprite-onboard')).toBe(
- '.claude/skills/testsprite-onboard/SKILL.md',
- );
- });
-
- it('antigravity + testsprite-onboard', () => {
- expect(pathFor('antigravity', 'testsprite-onboard')).toBe(
- '.agents/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('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();
+ expect(parsed!.skill).toBe('testsprite-onboard');
+ expect(parsed!.version).toBe(VERSION);
+ expect(parsed!.hash12).toBe(bodyHash12(STUB_SKILL_MD));
+ expect(parsed!.line).toBe(marker);
});
- it('parseSkillMarker ignores the managed-section sentinels (also HTML comments)', () => {
- expect(parseSkillMarker(`${MANAGED_SECTION_BEGIN}\nbody\n${MANAGED_SECTION_END}\n`)).toBeNull();
+ it('parseSkillMarker returns null when no marker is present', () => {
+ expect(parseSkillMarker('just prose\nno marker here')).toBeNull();
});
-});
-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..8df9b2a 100644
--- a/src/lib/agent-targets.ts
+++ b/src/lib/agent-targets.ts
@@ -2,533 +2,465 @@ 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 = {
+ 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 },
+ 'antigravity-cli': {
+ displayName: 'Antigravity CLI',
+ skillsDir: CANONICAL_SKILLS_DIR,
+ 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,
+ },
+ bob: { displayName: 'IBM Bob', skillsDir: '.bob/skills', universal: false },
+ 'claude-code': { displayName: 'Claude Code', skillsDir: '.claude/skills', universal: false },
+ 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 },
+ '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: CANONICAL_SKILLS_DIR, universal: true },
+ cursor: { displayName: 'Cursor', skillsDir: CANONICAL_SKILLS_DIR, universal: true },
+ devin: { displayName: 'Devin for Terminal', skillsDir: '.devin/skills', universal: false },
+ '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 },
+ 'github-copilot': {
+ displayName: 'GitHub Copilot',
+ skillsDir: CANONICAL_SKILLS_DIR,
+ universal: true,
+ },
+ goose: { displayName: 'Goose', skillsDir: CANONICAL_SKILLS_DIR, universal: true },
+ 'hermes-agent': { displayName: 'Hermes Agent', skillsDir: '.hermes/skills', universal: false },
+ junie: { displayName: 'Junie', skillsDir: '.junie/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 },
+ 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 },
+ 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-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 },
+ reasonix: { displayName: 'Reasonix', skillsDir: '.reasonix/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 },
+ zed: { displayName: 'Zed', skillsDir: CANONICAL_SKILLS_DIR, universal: true },
+ zencoder: { displayName: 'Zencoder', skillsDir: CANONICAL_SKILLS_DIR, universal: true },
+};
+
+/** 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',
+ 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. */
+export function resolveTarget(raw: string): AgentTarget | null {
+ if (Object.prototype.hasOwnProperty.call(TARGETS, raw)) return raw as AgentTarget;
+ // hasOwnProperty avoids inherited keys (constructor, __proto__).
+ return Object.prototype.hasOwnProperty.call(TARGET_ALIASES, raw) ? 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`;
+}
-/** Sentinel pair that bounds our managed section in AGENTS.md. */
-export const MANAGED_SECTION_BEGIN =
- '';
-export const MANAGED_SECTION_END = '';
+/** 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}`;
+}
+
+/** 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);
+/** 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;
}
/**
- * 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.
+ * 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 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);
+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] ?? '' };
}
-/**
- * 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 '';
+/** 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 };
}
-/**
- * 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.
- */
-export function buildCodexAggregate(skills: readonly string[], read: ReadFn = defaultRead): string {
- return skills
- .map(s => codexContentFor(s, read).trimEnd())
- .filter(Boolean)
- .join('\n\n');
+/** Canonical SKILL.md bytes (frontmatter + body), verbatim. */
+export function loadSkillFull(skill: string, read: ReadFn = defaultRead): string {
+ return loadSkill(skill, read).full;
}
-/**
- * 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);
+/** 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)}`;
}
-/**
- * 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 carrying a specific marker line (used by `agent status`). */
+export function renderCanonicalWithMarker(
+ skill: string,
+ markerLine: string,
+ read: ReadFn = defaultRead,
+): string {
+ if (!SKILLS[skill]) throw new Error(`unknown skill: ${skill}`);
+ return injectMarkerLine(loadSkillFull(skill, read), markerLine);
+}
+
+/** 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,
+ );
}
// ---------------------------------------------------------------------------
-// renderForTarget
+// Legacy format detection
// ---------------------------------------------------------------------------
/**
- * 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.
+ * Legacy `AGENTS.md` sentinel pair for the codex target. The BEGIN line matches
+ * the exact string the old CLI emitted.
*/
-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`;
-}
+export const LEGACY_MANAGED_SECTION_BEGIN =
+ '';
+export const LEGACY_MANAGED_SECTION_END = '';
/**
- * 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.
+ * Legacy own-file format for one old target (e.g. `.cursor/rules/*.mdc`,
+ * `.claude/skills//SKILL.md`). `antigravity` is omitted — it already
+ * lands at `.agents/skills/`.
*/
-export function renderOwnFileWithMarker(
- target: AgentTarget,
- skill: string,
- markerLine: string,
- body?: string,
-): 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,
- );
+export interface LegacyOwnFileSpec {
+ /** Old target id — for messaging only (not resolved against the new registry). */
+ legacyTarget: string;
+ /** POSIX path template with a `{skill}` placeholder. */
+ pathTemplate: string;
+ /** 'dir' = the artifact is `//SKILL.md` (skill lives in a folder);
+ * 'file' = the artifact is a bare file (e.g. `/.mdc`). */
+ kind: 'dir' | 'file';
+ /** New-format target id (e.g. 'claude-code', 'github-copilot'). */
+ newTarget: AgentTarget;
}
+export const LEGACY_OWN_FILE_TARGETS: readonly LegacyOwnFileSpec[] = [
+ {
+ legacyTarget: 'claude',
+ pathTemplate: '.claude/skills/{skill}/SKILL.md',
+ kind: 'dir',
+ newTarget: 'claude-code',
+ },
+ {
+ legacyTarget: 'cursor',
+ pathTemplate: '.cursor/rules/{skill}.mdc',
+ kind: 'file',
+ newTarget: 'cursor',
+ },
+ {
+ legacyTarget: 'cline',
+ pathTemplate: '.clinerules/{skill}.md',
+ kind: 'file',
+ newTarget: 'cline',
+ },
+ {
+ legacyTarget: 'kiro',
+ pathTemplate: '.kiro/skills/{skill}/SKILL.md',
+ kind: 'dir',
+ newTarget: 'kiro',
+ },
+ {
+ legacyTarget: 'windsurf',
+ pathTemplate: '.windsurf/rules/{skill}.md',
+ kind: 'file',
+ newTarget: 'devin-desktop',
+ },
+ {
+ legacyTarget: 'copilot',
+ pathTemplate: '.github/instructions/{skill}.instructions.md',
+ kind: 'file',
+ newTarget: 'github-copilot',
+ },
+];
+
+/** Render a legacy path template for a concrete skill (POSIX, repo-relative). */
+export function legacyOwnFilePath(spec: LegacyOwnFileSpec, skill: string): string {
+ return spec.pathTemplate.replace('{skill}', skill);
+}
+
+/** Outcome of locating the legacy managed section inside an `AGENTS.md`. */
+export type ManagedSectionBounds =
+ | { state: 'absent' }
+ | { state: 'present'; start: number; end: number }
+ | { state: 'corrupt'; reason: string };
+
/**
- * The exact bytes to write for one skill on one target.
+ * Find the byte range `[start, end)` of the legacy managed section in
+ * `AGENTS.md`. Only standalone BEGIN/END sentinel lines count (exact match
+ * after trim).
*
- * - 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.
+ * Returns `absent` (no sentinels), `present` (one balanced BEGIN…END pair),
+ * or `corrupt` (unbalanced, duplicate, or misordered).
*/
-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) };
+export function findManagedSectionBounds(content: string): ManagedSectionBounds {
+ const lines = content.split('\n');
+ // lineStart[i] = byte offset where lines[i] begins (lines[0] = 0).
+ const lineStart: number[] = [0];
+ for (let i = 0; i < lines.length; i++) {
+ lineStart.push(lineStart[i]! + lines[i]!.length + 1); // +1 for the '\n'
+ }
+
+ const beginLines: number[] = [];
+ const endLines: number[] = [];
+ for (let i = 0; i < lines.length; i++) {
+ const stripped = lines[i]!.trim();
+ if (stripped === LEGACY_MANAGED_SECTION_BEGIN) beginLines.push(i);
+ else if (stripped === LEGACY_MANAGED_SECTION_END) endLines.push(i);
+ }
+
+ if (beginLines.length === 0 && endLines.length === 0) return { state: 'absent' };
+ if (beginLines.length > 1 || endLines.length > 1) {
+ return { state: 'corrupt', reason: 'found multiple standalone TestSprite sentinel lines' };
+ }
+ if (beginLines.length === 1 && endLines.length === 0) {
+ return { state: 'corrupt', reason: 'BEGIN sentinel without a matching END' };
}
- const resolvedBody =
- body !== undefined ? body : spec.compactBody ? compactBodyFor(skill) : loadSkillBodyFor(skill);
- return {
- path,
- content: renderOwnFileWithMarker(t, skill, buildSkillMarker(skill, resolvedBody), resolvedBody),
- };
+ if (endLines.length === 1 && beginLines.length === 0) {
+ return { state: 'corrupt', reason: 'END sentinel without a matching BEGIN' };
+ }
+
+ const beginLine = beginLines[0]!;
+ const endLine = endLines[0]!;
+ if (endLine < beginLine) {
+ return { state: 'corrupt', reason: 'END sentinel appears before BEGIN' };
+ }
+
+ const start = lineStart[beginLine]!;
+ let end = lineStart[endLine]! + lines[endLine]!.length;
+ if (end < content.length && content[end] === '\n') end += 1;
+ return { state: 'present', start, end };
}
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 bdee849..6600f5f 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,14 @@ 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). Universal agents share one
+ canonical skill file (installing for any one serves all);
+ symlinked agents appear only when their own landing
+ exists. Exits 1 when any need attention, so it can gate CI
help [command] display help for command
"
`;
@@ -28,16 +31,15 @@ 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, and migrate
+ any legacy artifacts found in the repo (originals kept as
+ *.bak).
-h, --help display help for command
Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug):
@@ -48,7 +50,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 +118,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
@@ -769,9 +774,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..b640d56 100644
--- a/test/e2e/agent-install.e2e.test.ts
+++ b/test/e2e/agent-install.e2e.test.ts
@@ -5,24 +5,38 @@
* 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,
+ mkdirSync,
+ 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,
+ LEGACY_MANAGED_SECTION_BEGIN,
+ LEGACY_MANAGED_SECTION_END,
+ canonicalSkillFile,
pathFor,
- renderForTarget,
+ renderCanonical,
type AgentTarget,
} from '../../src/lib/agent-targets.js';
@@ -61,10 +75,6 @@ afterEach(() => {
}
});
-// ---------------------------------------------------------------------------
-// Helper: spawn CLI without throwing on non-zero exit codes
-// ---------------------------------------------------------------------------
-
interface CliResult {
status: number;
stdout: string;
@@ -76,22 +86,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
+ 'antigravity-cli', // universal
+ 'github-copilot', // universal
+ 'kiro-cli', // symlinked
+ 'devin-desktop', // 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 +119,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 +268,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']);
+ const canonical = join(tmpDir, canonicalSkillFile('testsprite-verify'));
+ const edited = readFileSync(canonical, 'utf8') + '\n\n';
+ writeFileSync(canonical, edited, 'utf8');
- // Hand-edit the verify file
- const verifyFilePath = join(tmpDir, pathFor('claude', 'testsprite-verify'));
- const editedContent = readFileSync(verifyFilePath, 'utf8') + '\n\n';
- writeFileSync(verifyFilePath, editedContent, 'utf8');
-
- // Re-run with --force
const forced = runCli([
'agent',
'install',
- '--target=claude',
+ '--target=claude-code',
'--dir',
tmpDir,
'--force',
@@ -397,19 +288,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');
+ const parsed = JSON.parse(forced.stdout) as Array<{ action: string }>;
+ expect(parsed.some(r => r.action === 'updated')).toBe(true);
- // Verify file must now equal canonical content
- const { content: canonicalVerify } = renderForTarget('claude', 'testsprite-verify');
- expect(readFileSync(verifyFilePath, 'utf8')).toBe(canonicalVerify);
-
- // .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 +303,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 +340,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,355 +369,353 @@ 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
+// 9. --skill flag
// ---------------------------------------------------------------------------
-describe('managed-section (codex target)', () => {
- it('create: AGENTS.md absent → creates file with sentinels, action: section-installed', () => {
+describe('--skill flag', () => {
+ it('--skill testsprite-onboard installs only the onboard canonical file', () => {
const tmpDir = freshTmpDir();
const result = runCli([
'agent',
'install',
- '--target=codex',
+ '--target=claude-code',
+ '--skill',
+ 'testsprite-onboard',
'--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);
+ expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-onboard')))).toBe(true);
+ expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(false);
});
- it('append: AGENTS.md exists (no sentinels) → appends section, original preserved, action: section-installed', () => {
+ it('unknown --skill bogus exits 5', () => {
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',
+ '--target=claude-code',
+ '--skill',
+ 'bogus',
'--dir',
tmpDir,
'--output',
'json',
]);
+ expect(result.status).toBe(5);
+ expect(result.stderr).toContain('bogus');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 10. agent list
+// ---------------------------------------------------------------------------
+
+describe('agent list', () => {
+ it('output includes TARGET/MODE columns and the headline agents', () => {
+ const result = runCli(['agent', 'list']);
expect(result.status).toBe(0);
+ expect(result.stdout).toContain('TARGET');
+ expect(result.stdout).toContain('MODE');
+ expect(result.stdout).toContain('claude-code');
+ expect(result.stdout).toContain('codex');
+ });
- 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('--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;
+ mode: string;
+ skillsDir: string;
+ }>;
+ 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');
});
+});
- 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');
+// ---------------------------------------------------------------------------
+// 11. Registry coverage guard — forces a conscious update when agents change
+// ---------------------------------------------------------------------------
+
+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',
+ 'antigravity-cli',
+ 'github-copilot',
+ 'kiro-cli',
+ 'devin-desktop',
+ 'antigravity',
+ ]),
+ );
+ });
+
+ 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=codex',
+ '--target=claude',
'--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');
+ 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);
});
+});
+
+// ---------------------------------------------------------------------------
+// 12. Full-registry smoke — every accepted target installs cleanly
+// ---------------------------------------------------------------------------
+
+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
+ }
+ }
+ });
+ }
+});
- it('unchanged: re-running on identical sentinels → no write, action: section-unchanged', () => {
+// ---------------------------------------------------------------------------
+// 13. `agent install --force` — legacy migration (issue #270 gate #2).
+// Pre-standard artifacts are detected, backed up to *.bak, and retired as
+// part of a forced install; the codex AGENTS.md section is removed in place.
+// A plain (no --force) install refuses a dir collision rather than half-migrate.
+// ---------------------------------------------------------------------------
+
+/** A minimal old-format own-file artifact carrying a valid provenance marker. */
+const MARKER_LINE = '';
+
+function seedLegacyClaude(tmpDir: string): string {
+ // Pre-standard: `.claude/skills//SKILL.md` as a real file (not a symlink).
+ const rel = join(tmpDir, '.claude/skills/testsprite-verify/SKILL.md');
+ mkdirSync(join(tmpDir, '.claude/skills/testsprite-verify'), { recursive: true });
+ writeFileSync(rel, `---\nname: testsprite-verify\n---\n${MARKER_LINE}\n# legacy\nold body\n`);
+ return rel;
+}
+
+function seedLegacyCodexSection(
+ tmpDir: string,
+ opts: { before?: string; after?: string } = {},
+): string {
+ const before = opts.before ?? '# Project\n\nIntro paragraph.\n';
+ const after = opts.after ?? '\n## Footer\n\nKeep me.\n';
+ const content = `${before}${LEGACY_MANAGED_SECTION_BEGIN}\n${MARKER_LINE}\nRun tests.\n${LEGACY_MANAGED_SECTION_END}\n${after}`;
+ writeFileSync(join(tmpDir, 'AGENTS.md'), content);
+ return content;
+}
+
+describe('agent install --force (legacy migration)', () => {
+ it('claude: --target=claude-code backs up the legacy folder to a sibling .bak/ and REPLACES it with a symlink', () => {
const tmpDir = freshTmpDir();
+ seedLegacyClaude(tmpDir);
- // First install
- const first = runCli([
+ const result = runCli([
'agent',
'install',
- '--target=codex',
+ '--target=claude-code',
+ '--force',
'--dir',
tmpDir,
'--output',
'json',
]);
- expect(first.status).toBe(0);
+ expect(result.status, `stderr: ${result.stderr}`).toBe(0);
+
+ // The legacy real folder is gone, REPLACED by a symlink at the same path.
+ expect(lstatSync(join(tmpDir, '.claude/skills/testsprite-verify')).isSymbolicLink()).toBe(true);
+ // The old bytes survive only in the sibling .bak/.
+ expect(
+ readFileSync(join(tmpDir, '.claude/skills/testsprite-verify.bak/SKILL.md'), 'utf8'),
+ ).toContain('old body');
+ // The SKILL.md reachable through the symlink is the CANONICAL content (not the old body).
+ expect(
+ readFileSync(join(tmpDir, '.claude/skills/testsprite-verify/SKILL.md'), 'utf8'),
+ ).not.toContain('old body');
+ // Canonical destination now exists.
+ expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(true);
+ expect(result.stderr).toMatch(
+ /converted claude skill at .*\.claude\/skills\/testsprite-verify/,
+ );
+ expect(result.stderr).toMatch(/find . -name "\*\.bak" -prune -exec rm -rf/);
+ });
- const agentsPath = join(tmpDir, 'AGENTS.md');
- const contentBefore = readFileSync(agentsPath, 'utf8');
+ it('SCOPED: --target=codex does NOT migrate a legacy claude folder', () => {
+ const tmpDir = freshTmpDir();
+ seedLegacyClaude(tmpDir);
- // Second install (same content)
- const second = runCli([
+ const result = runCli([
'agent',
'install',
'--target=codex',
+ '--force',
'--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);
+ expect(result.status, `stderr: ${result.stderr}`).toBe(0);
+ // claude's legacy folder is untouched (codex wasn't asked to migrate it).
+ expect(existsSync(join(tmpDir, '.claude/skills/testsprite-verify/SKILL.md'))).toBe(true);
+ expect(existsSync(join(tmpDir, '.claude/skills/testsprite-verify.bak'))).toBe(false);
+ expect(result.stderr).not.toMatch(/migrated claude/);
});
- it('corrupt sentinel (BEGIN without END) → exit 5, error message mentions sentinel/corrupt', () => {
+ it('removes only the codex managed section, preserving surrounding AGENTS.md content', () => {
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 original = seedLegacyCodexSection(tmpDir);
const result = runCli([
'agent',
'install',
'--target=codex',
+ '--force',
'--dir',
tmpDir,
'--output',
'json',
]);
- expect(result.status).toBe(5);
- expect(result.stderr).toMatch(/malformed|corrupt|sentinel/i);
+ expect(result.status, `stderr: ${result.stderr}`).toBe(0);
+
+ // Whole-file backup preserves the original.
+ expect(readFileSync(join(tmpDir, 'AGENTS.md.bak'), 'utf8')).toBe(original);
+ // The new AGENTS.md keeps user content, drops the sentinel block.
+ const next = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf8');
+ expect(next).not.toContain(LEGACY_MANAGED_SECTION_BEGIN);
+ expect(next).not.toContain(LEGACY_MANAGED_SECTION_END);
+ expect(next).toContain('Intro paragraph.');
+ expect(next).toContain('Keep me.');
+ // Canonical is written so universal agents keep working.
+ expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(true);
});
- it('--dry-run: no writes, stderr mentions managed section, action: dry-run', () => {
+ it('is idempotent: a second --force install reports no migration', () => {
const tmpDir = freshTmpDir();
+ seedLegacyCodexSection(tmpDir);
+ runCli(['agent', 'install', '--target=codex', '--force', '--dir', tmpDir, '--output', 'json']);
- const result = runCli([
- '--dry-run',
+ const second = runCli([
'agent',
'install',
'--target=codex',
+ '--force',
'--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);
+ expect(second.status, `stderr: ${second.stderr}`).toBe(0);
+ expect(second.stderr).not.toMatch(/migrated /);
});
-});
-
-// ---------------------------------------------------------------------------
-// 10. --skill flag: install a single named skill
-// ---------------------------------------------------------------------------
-describe('--skill flag', () => {
- it('--skill testsprite-onboard installs only the onboard file, not verify', () => {
+ it('refuses a corrupt sentinel block with exit 5 and writes nothing', () => {
const tmpDir = freshTmpDir();
-
+ const malformed = `# head\n\n${LEGACY_MANAGED_SECTION_BEGIN}\nbody with no end sentinel\n`;
+ writeFileSync(join(tmpDir, 'AGENTS.md'), malformed);
const result = runCli([
'agent',
'install',
- '--target=claude',
- '--skill',
- 'testsprite-onboard',
+ '--target=codex',
+ '--force',
'--dir',
tmpDir,
'--output',
'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(result.status).toBe(5);
+ // File untouched, no backup created.
+ expect(readFileSync(join(tmpDir, 'AGENTS.md'), 'utf8')).toBe(malformed);
+ expect(existsSync(join(tmpDir, 'AGENTS.md.bak'))).toBe(false);
});
- it('unknown --skill bogus exits 5 with documented error message', () => {
+ it('--dry-run plans the migration but changes nothing on disk', () => {
const tmpDir = freshTmpDir();
-
+ seedLegacyClaude(tmpDir);
const result = runCli([
'agent',
'install',
- '--target=claude',
- '--skill',
- 'bogus',
+ '--target=claude-code',
+ '--force',
+ '--dry-run',
'--dir',
tmpDir,
'--output',
'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);
- }
+ expect(result.status, `stderr: ${result.stderr}`).toBe(0);
+ expect(result.stderr).toMatch(/converted claude skill/);
+ // Nothing written or removed.
+ expect(existsSync(join(tmpDir, '.claude/skills/testsprite-verify/SKILL.md'))).toBe(true);
+ expect(existsSync(join(tmpDir, '.claude/skills/testsprite-verify.bak'))).toBe(false);
+ expect(existsSync(join(tmpDir, canonicalSkillFile('testsprite-verify')))).toBe(false);
});
-});
-
-// ---------------------------------------------------------------------------
-// 11. agent list — includes SKILL column with both skill names
-// ---------------------------------------------------------------------------
-
-describe('agent list', () => {
- it('output includes TARGET, SKILL column header and both default skill names', () => {
- 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);
- }
- });
-
- it('--output json returns an array with one entry per (target × skill)', () => {
- 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;
- }>;
- 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'));
- });
-});
-
-// ---------------------------------------------------------------------------
-// 12. Matrix-coverage guard — hardcoded list forces a conscious update when
-// a target is added or removed from TARGETS.
-// ---------------------------------------------------------------------------
-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',
+ it('plain install (no --force) refuses a legacy dir collision with exit 6', () => {
+ const tmpDir = freshTmpDir();
+ seedLegacyClaude(tmpDir);
+ const result = runCli([
+ 'agent',
+ 'install',
+ '--target=claude-code',
+ '--dir',
+ tmpDir,
+ '--output',
+ 'json',
]);
+ expect(result.status).toBe(6);
+ expect(result.stderr).toMatch(/legacy installs|--force/);
+ // Plain install never migrates: the legacy folder is untouched, no .bak.
+ expect(existsSync(join(tmpDir, '.claude/skills/testsprite-verify/SKILL.md'))).toBe(true);
+ expect(existsSync(join(tmpDir, '.claude/skills/testsprite-verify.bak'))).toBe(false);
});
-
- it('SKILLS matches the documented, e2e-covered set (update this list when adding a skill)', () => {
- expect(Object.keys(SKILLS)).toEqual(['testsprite-verify', 'testsprite-onboard']);
- });
-});
-
-// ---------------------------------------------------------------------------
-// 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.
-// ---------------------------------------------------------------------------
-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.
});
diff --git a/test/e2e/setup.e2e.test.ts b/test/e2e/setup.e2e.test.ts
index 5a07434..e821438 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',
+ 'antigravity-cli',
+ 'github-copilot',
+ 'kiro-cli',
+ 'devin-desktop',
+ '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');