Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,12 @@ 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 list # list all 5 targets with status + mode + path
testsprite agent list # list all 6 targets with status + mode + path
```

Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `antigravity` (experimental).
Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `windsurf` (experimental), `antigravity` (experimental).

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.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ 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`, `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`, `cursor`, `cline`, `windsurf`, `antigravity`, `codex`, etc.) — 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
Expand Down Expand Up @@ -110,7 +110,7 @@ Prefer to configure each step by hand (or learn the surface offline with `--dry-
| | `test rerun` | Cheap replay of one/many tests (FE verbatim; BE with deps); `--all --project <id>` reruns all tests |
| | `test wait` | Block on a `runId` until terminal |
| | `test artifact get` | Download the failure bundle for a specific `runId` |
| **Agent** | `agent install` / `agent list` | Add or list coding-agent targets (pure-local): `claude`, `codex`, `cursor`, `cline`, `antigravity` |
| **Agent** | `agent install` / `agent list` | Add or list coding-agent targets (pure-local): `claude`, `codex`, `cursor`, `cline`, `windsurf`, `antigravity` |

> 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).

Expand Down
11 changes: 6 additions & 5 deletions src/commands/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,11 +723,12 @@ describe('runList', () => {

const json = JSON.parse(capture.stdout.join('\n')) as ListResult[];
expect(Array.isArray(json)).toBe(true);
expect(json).toHaveLength(5);
expect(json).toHaveLength(6);
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('antigravity');
expect(targets).toContain('codex');
const claudeEntry = json.find(r => r.target === 'claude');
Expand Down Expand Up @@ -857,11 +858,11 @@ describe('createAgentCommand wiring', () => {
});

// ---------------------------------------------------------------------------
// All four own-file targets installed at once
// All own-file targets installed at once
// ---------------------------------------------------------------------------

describe('runInstall — all four own-file targets', () => {
it('installs all four own-file targets in one invocation', async () => {
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();

Expand All @@ -871,7 +872,7 @@ describe('runInstall — all four own-file targets', () => {
output: 'text',
debug: false,
dryRun: false,
target: ['claude', 'cursor', 'cline', 'antigravity'],
target: [...OWN_FILE_TARGETS],
force: false,
},
{ cwd: CWD, fs: agentFs, ...deps },
Expand Down
20 changes: 16 additions & 4 deletions src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,8 +566,20 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
// -----------------------------------------------------------------------
// own-file mode (all other targets)
// -----------------------------------------------------------------------
if (ownFileBody === undefined) ownFileBody = loadSkillBody();
const content = renderForTarget(t, ownFileBody).content;
// Budget-capped targets (e.g. windsurf) render the compact body so the
// file is not truncated by the agent; everything else uses the full skill.
// Bodies are loaded lazily and cached so a multi-target install reads each
// source file at most once. Must match renderForTarget's default selection
// so the written bytes equal what the unit tests assert.
let body: string;
if (spec.compactBody === true) {
if (codexBody === undefined) codexBody = loadCodexSkillBody();
body = codexBody;
} else {
if (ownFileBody === undefined) ownFileBody = loadSkillBody();
body = ownFileBody;
}
const content = renderForTarget(t, body).content;

if (opts.dryRun) {
const bytes = Buffer.byteLength(content, 'utf8');
Expand Down Expand Up @@ -703,7 +715,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, Codex)',
'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Windsurf, Antigravity, Codex)',
);

agent
Expand All @@ -713,7 +725,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command {
)
.option(
'--target <t>',
'Agent target(s): claude, cursor, cline, antigravity, codex (comma-separated or repeated)',
'Agent target(s): claude, cursor, cline, windsurf, antigravity, codex (comma-separated or repeated)',
collect,
[],
)
Expand Down
63 changes: 60 additions & 3 deletions src/lib/agent-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,19 @@ testsprite test artifact get <run-id> --out ./out/
// ---------------------------------------------------------------------------

describe('TARGETS', () => {
it('has all five required keys', () => {
it('has all six required keys', () => {
const keys = Object.keys(TARGETS).sort();
expect(keys).toEqual(['antigravity', 'claude', 'cline', 'codex', 'cursor']);
expect(keys).toEqual(['antigravity', 'claude', 'cline', 'codex', 'cursor', 'windsurf']);
});

it('claude is GA', () => {
expect(TARGETS.claude.status).toBe('ga');
});

it('cursor, cline, antigravity, and codex are experimental', () => {
it('cursor, cline, windsurf, antigravity, and codex are experimental', () => {
expect(TARGETS.cursor.status).toBe('experimental');
expect(TARGETS.cline.status).toBe('experimental');
expect(TARGETS.windsurf.status).toBe('experimental');
expect(TARGETS.antigravity.status).toBe('experimental');
expect(TARGETS.codex.status).toBe('experimental');
});
Expand All @@ -88,6 +89,7 @@ describe('TARGETS', () => {
expect(TARGETS.antigravity.mode).toBe('own-file');
expect(TARGETS.cursor.mode).toBe('own-file');
expect(TARGETS.cline.mode).toBe('own-file');
expect(TARGETS.windsurf.mode).toBe('own-file');
});

it('codex target has mode managed-section', () => {
Expand Down Expand Up @@ -255,11 +257,66 @@ describe('renderForTarget("cline")', () => {
});
});

describe('renderForTarget("windsurf")', () => {
const result = renderForTarget('windsurf', 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/kiro key
expect(fm).not.toContain('alwaysApply:'); // cursor .mdc key
});

it('preserves the skill body after the frontmatter and ends with a newline', () => {
expect(result.content).toContain('# TestSprite Verification Loop');
expect(result.content.endsWith('\n')).toBe(true);
});
});

describe('windsurf renders the compact body within the rules budget', () => {
// Regression: the full skill body (~21 KB) rendered to ~22 KB, which exceeds
// the Windsurf `.windsurf/rules/*.md` 12 K-character cap and would be silently
// truncated. The windsurf target therefore renders the COMPACT body.
// Uses the REAL bodies (no stub) so the size is the bytes a user receives.
const windsurf = renderForTarget('windsurf');
const claude = renderForTarget('claude');

it('fits within the 12 000-character Windsurf rules budget', () => {
expect(windsurf.content.length).toBeLessThan(12_000);
});

it('uses the compact body, not the full one', () => {
// 'The verification loop that flies' appears only in the full body.
expect(claude.content).toContain('The verification loop that flies');
expect(windsurf.content).not.toContain('The verification loop that flies');
// ...but the core skill identity and a load-bearing command survive.
expect(windsurf.content).toContain('# TestSprite Verification Loop');
expect(windsurf.content).toContain('testsprite test run');
});

it('is materially smaller than the full-body claude render', () => {
expect(windsurf.content.length).toBeLessThan(claude.content.length);
});
});

// ---------------------------------------------------------------------------
// Content integrity — load-bearing command strings must survive any body trim
// ---------------------------------------------------------------------------

describe('content integrity — own-file targets', () => {
// windsurf is intentionally excluded: it renders the COMPACT body (its rules
// file is budget-capped), so it is covered by its own budget test below
// rather than these full-body load-bearing-string checks.
const ownFileTargets: Array<'claude' | 'cursor' | 'cline' | 'antigravity'> = [
'claude',
'cursor',
Expand Down
36 changes: 34 additions & 2 deletions src/lib/agent-targets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';

export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex';
export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex' | 'windsurf';

export interface TargetSpec {
status: 'ga' | 'experimental';
Expand All @@ -12,6 +12,15 @@ export interface TargetSpec {
* a potentially user-authored file (codex target, AGENTS.md).
*/
mode: 'own-file' | 'managed-section';
/**
* When true, render the trimmed/compact skill body (see {@link loadCodexSkillBody})
* instead of the full one. Used for targets with a tight per-rule budget where
* the full ~21 KB skill would be silently truncated — currently `windsurf`
* (`.windsurf/rules/*.md` files cap at 12 K characters). `managed-section`
* (codex) already uses the compact body via its own path; this flag covers
* own-file targets that need it too.
*/
compactBody?: boolean;
/** wrap the canonical body in this target's frontmatter/header */
wrap(body: string): string;
}
Expand All @@ -33,6 +42,19 @@ function wrapMdc(body: string): string {
return `---\ndescription: ${SKILL_DESCRIPTION}\nalwaysApply: false\n---\n\n${body}\n`;
}

/**
* 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 this verification skill
* wants. (The other triggers are `always_on`, `manual`, and `glob`.)
* Workspace rule files have a 12 KB budget, well above this skill's size.
*/
function wrapWindsurf(body: string): string {
return `---\ntrigger: model_decision\ndescription: ${SKILL_DESCRIPTION}\n---\n\n${body}\n`;
}

export const TARGETS: Record<AgentTarget, TargetSpec> = {
claude: {
status: 'ga',
Expand All @@ -58,6 +80,16 @@ export const TARGETS: Record<AgentTarget, TargetSpec> = {
mode: 'own-file',
wrap: body => body,
},
windsurf: {
status: 'experimental',
path: '.windsurf/rules/testsprite-verify.md',
mode: 'own-file',
// Windsurf workspace rules are budget-capped (12 K chars for a
// `.windsurf/rules/*.md` file); the full skill body (~21 KB) would be
// truncated, so render the compact variant — it still fits comfortably.
compactBody: true,
wrap: wrapWindsurf,
},
/**
* codex target — managed-section mode.
*
Expand Down Expand Up @@ -124,7 +156,7 @@ export function renderForTarget(t: AgentTarget, body?: string): { path: string;
const resolvedBody =
body !== undefined
? body
: spec.mode === 'managed-section'
: spec.mode === 'managed-section' || spec.compactBody === true
? loadCodexSkillBody()
: loadSkillBody();
return { path: spec.path, content: spec.wrap(resolvedBody) };
Expand Down
13 changes: 7 additions & 6 deletions test/__snapshots__/help.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exports[`--help snapshots > agent 1`] = `
"Usage: testsprite agent [options] [command]

Install TestSprite guidance into coding-agent config (Claude Code, Cursor,
Cline, Antigravity, Codex)
Cline, Windsurf, Antigravity, Codex)

Options:
-h, --help display help for command
Expand All @@ -25,8 +25,8 @@ Write the TestSprite verification-loop skill file into a project for a coding
agent

Options:
--target <t> Agent target(s): claude, cursor, cline, antigravity, codex
(comma-separated or repeated) (default: [])
--target <t> Agent target(s): claude, cursor, cline, windsurf, antigravity,
codex (comma-separated or repeated) (default: [])
--dir <path> 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
Expand Down Expand Up @@ -110,7 +110,8 @@ Options:
--from-env Read TESTSPRITE_API_KEY from the environment instead of
prompting (default: false)
--agent <target> Coding-agent target to install: claude, antigravity,
cursor, cline, codex (default: claude) (default: "claude")
cursor, cline, windsurf, codex (default: claude) (default:
"claude")
--no-agent Skip the agent skill install (configure credentials only)
--force Overwrite an existing skill file (a .bak backup is kept)
--dir <path> Project root for the skill install (default: current
Expand Down Expand Up @@ -579,8 +580,8 @@ Commands:
project Manage TestSprite projects
test Inspect TestSprite tests
agent Install TestSprite guidance into coding-agent
config (Claude Code, Cursor, Cline, Antigravity,
Codex)
config (Claude Code, Cursor, Cline, Windsurf,
Antigravity, Codex)
usage|credits Show credit balance and plan/entitlement info
(proactive pre-flight before a large test run)
help [command] display help for command
Expand Down
37 changes: 29 additions & 8 deletions test/e2e/agent-install.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,20 @@ describe('content integrity', () => {
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');
}

// (b) branding — the renamed H1 must be present.

// (b) branding — the renamed H1 must be present in every body variant.
expect(content).toContain('TestSprite Verification Loop');
// Match the verification-loop intro line used in the asset
expect(content).toContain('The verification loop that flies');
// The verification-loop intro line lives only in the FULL body; compact-
// body targets (e.g. windsurf, budget-capped) ship the trimmed body and
// legitimately omit it.
if (!TARGETS[target].compactBody) {
expect(content).toContain('The verification loop that flies');
}

// (c) Load-bearing command strings — a body trim that drops these must fail CI
expect(content, `${target}: missing 'testsprite test run'`).toContain('testsprite test run');
Expand Down Expand Up @@ -381,13 +388,13 @@ describe('dry-run', () => {
// ---------------------------------------------------------------------------

describe('multi-target install', () => {
it('--target=claude,cursor,cline,antigravity,codex writes all five targets, exit 0', () => {
it('--target=claude,cursor,cline,windsurf,antigravity,codex writes all six targets, exit 0', () => {
const tmpDir = freshTmpDir();

const result = runCli([
'agent',
'install',
'--target=claude,cursor,cline,antigravity,codex',
'--target=claude,cursor,cline,windsurf,antigravity,codex',
'--dir',
tmpDir,
'--output',
Expand All @@ -400,7 +407,14 @@ describe('multi-target install', () => {
action: string;
path: string;
}>;
const allTargets: AgentTarget[] = ['claude', 'cursor', 'cline', 'antigravity', 'codex'];
const allTargets: AgentTarget[] = [
'claude',
'cursor',
'cline',
'windsurf',
'antigravity',
'codex',
];

for (const target of allTargets) {
const entry = parsed.find(r => r.target === target);
Expand Down Expand Up @@ -636,7 +650,14 @@ describe('managed-section (codex target)', () => {
// ---------------------------------------------------------------------------
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', 'codex']);
expect(Object.keys(TARGETS)).toEqual([
'claude',
'antigravity',
'cursor',
'cline',
'windsurf',
'codex',
]);
});
});

Expand Down
Loading