Skip to content

Commit eea1d02

Browse files
committed
feat(agent): add windsurf as an install target
Windsurf (Cascade) is a widely-used AI editor that reads workspace rules from `.windsurf/rules/*.md`. Add it as an own-file agent target so `testsprite agent install --target windsurf` (and `setup --agent windsurf`) wires the verification-loop skill into a Windsurf project. The rule file uses Cascade's YAML frontmatter with `trigger: model_decision` — the equivalent of the Cursor `.mdc` `alwaysApply: false` mode: only the `description` is surfaced up front and Cascade pulls in the full body when it is relevant, which matches how this skill should activate. (Cascade's other triggers are `always_on`, `manual`, and `glob`.) Budget handling: a `.windsurf/rules/*.md` file is capped at ~12 K characters, and the full skill body (~21 KB) would be silently truncated by Cascade. The windsurf target therefore renders the COMPACT body (the same trimmed variant the codex/AGENTS.md target already uses, ~5 KB) via a new `compactBody` flag on the target spec — the rendered file is ~5.5 KB, well within budget, while still carrying the H1, the when-to-run guidance, and the load-bearing `testsprite test run … --wait` / `test artifact get` commands. `agent.ts` and `renderForTarget` select the same body so installed bytes match the asserted render. The target otherwise flows through existing machinery automatically — the `agent list` table, `setup --agent` choices, and skill-nudge install detection all derive from the TARGETS map. Updated the hardcoded help strings in `agent.ts`, the help snapshot, the agent-targets + agent unit tests (incl. a render test asserting the Cascade frontmatter and a budget test that the windsurf render stays under 12 K and uses the compact body), the e2e matrix guards and multi-target/content-integrity coverage, and the README/DOCUMENTATION target lists.
1 parent 18f6e6e commit eea1d02

9 files changed

Lines changed: 165 additions & 33 deletions

File tree

DOCUMENTATION.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,12 @@ testsprite agent install claude # install the skill for Claude Code
112112
testsprite agent install codex # install into AGENTS.md for Codex (managed-section)
113113
testsprite agent install cursor # .cursor/rules/testsprite-verify.mdc
114114
testsprite agent install cline # .clinerules/testsprite-verify.md
115+
testsprite agent install windsurf # .windsurf/rules/testsprite-verify.md
115116
testsprite agent install antigravity # .agents/skills/testsprite-verify/SKILL.md
116-
testsprite agent list # list all 5 targets with status + mode + path
117+
testsprite agent list # list all 6 targets with status + mode + path
117118
```
118119

119-
Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `antigravity` (experimental).
120+
Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `windsurf` (experimental), `antigravity` (experimental).
120121

121122
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.
122123

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ npm install -g @testsprite/testsprite-cli
6161
testsprite setup
6262
```
6363

64-
`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):
64+
`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):
6565

6666
```bash
6767
TESTSPRITE_API_KEY=sk-... testsprite setup --from-env --yes --agent claude
@@ -110,7 +110,7 @@ Prefer to configure each step by hand (or learn the surface offline with `--dry-
110110
| | `test rerun` | Cheap replay of one/many tests (FE verbatim; BE with deps); `--all --project <id>` reruns all tests |
111111
| | `test wait` | Block on a `runId` until terminal |
112112
| | `test artifact get` | Download the failure bundle for a specific `runId` |
113-
| **Agent** | `agent install` / `agent list` | Add or list coding-agent targets (pure-local): `claude`, `codex`, `cursor`, `cline`, `antigravity` |
113+
| **Agent** | `agent install` / `agent list` | Add or list coding-agent targets (pure-local): `claude`, `codex`, `cursor`, `cline`, `windsurf`, `antigravity` |
114114

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

src/commands/agent.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -723,11 +723,12 @@ describe('runList', () => {
723723

724724
const json = JSON.parse(capture.stdout.join('\n')) as ListResult[];
725725
expect(Array.isArray(json)).toBe(true);
726-
expect(json).toHaveLength(5);
726+
expect(json).toHaveLength(6);
727727
const targets = json.map(r => r.target);
728728
expect(targets).toContain('claude');
729729
expect(targets).toContain('cursor');
730730
expect(targets).toContain('cline');
731+
expect(targets).toContain('windsurf');
731732
expect(targets).toContain('antigravity');
732733
expect(targets).toContain('codex');
733734
const claudeEntry = json.find(r => r.target === 'claude');
@@ -857,11 +858,11 @@ describe('createAgentCommand wiring', () => {
857858
});
858859

859860
// ---------------------------------------------------------------------------
860-
// All four own-file targets installed at once
861+
// All own-file targets installed at once
861862
// ---------------------------------------------------------------------------
862863

863-
describe('runInstall — all four own-file targets', () => {
864-
it('installs all four own-file targets in one invocation', async () => {
864+
describe('runInstall — all own-file targets', () => {
865+
it('installs every own-file target in one invocation', async () => {
865866
const { store, fs: agentFs } = makeMemFs();
866867
const { capture, deps } = makeCapture();
867868

@@ -871,7 +872,7 @@ describe('runInstall — all four own-file targets', () => {
871872
output: 'text',
872873
debug: false,
873874
dryRun: false,
874-
target: ['claude', 'cursor', 'cline', 'antigravity'],
875+
target: [...OWN_FILE_TARGETS],
875876
force: false,
876877
},
877878
{ cwd: CWD, fs: agentFs, ...deps },

src/commands/agent.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -566,8 +566,20 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
566566
// -----------------------------------------------------------------------
567567
// own-file mode (all other targets)
568568
// -----------------------------------------------------------------------
569-
if (ownFileBody === undefined) ownFileBody = loadSkillBody();
570-
const content = renderForTarget(t, ownFileBody).content;
569+
// Budget-capped targets (e.g. windsurf) render the compact body so the
570+
// file is not truncated by the agent; everything else uses the full skill.
571+
// Bodies are loaded lazily and cached so a multi-target install reads each
572+
// source file at most once. Must match renderForTarget's default selection
573+
// so the written bytes equal what the unit tests assert.
574+
let body: string;
575+
if (spec.compactBody === true) {
576+
if (codexBody === undefined) codexBody = loadCodexSkillBody();
577+
body = codexBody;
578+
} else {
579+
if (ownFileBody === undefined) ownFileBody = loadSkillBody();
580+
body = ownFileBody;
581+
}
582+
const content = renderForTarget(t, body).content;
571583

572584
if (opts.dryRun) {
573585
const bytes = Buffer.byteLength(content, 'utf8');
@@ -703,7 +715,7 @@ function collect(v: string, prev: string[]): string[] {
703715

704716
export function createAgentCommand(deps: AgentDeps = {}): Command {
705717
const agent = new Command('agent').description(
706-
'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Codex)',
718+
'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Windsurf, Antigravity, Codex)',
707719
);
708720

709721
agent
@@ -713,7 +725,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command {
713725
)
714726
.option(
715727
'--target <t>',
716-
'Agent target(s): claude, cursor, cline, antigravity, codex (comma-separated or repeated)',
728+
'Agent target(s): claude, cursor, cline, windsurf, antigravity, codex (comma-separated or repeated)',
717729
collect,
718730
[],
719731
)

src/lib/agent-targets.test.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,19 @@ testsprite test artifact get <run-id> --out ./out/
6060
// ---------------------------------------------------------------------------
6161

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

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

72-
it('cursor, cline, antigravity, and codex are experimental', () => {
72+
it('cursor, cline, windsurf, antigravity, and codex are experimental', () => {
7373
expect(TARGETS.cursor.status).toBe('experimental');
7474
expect(TARGETS.cline.status).toBe('experimental');
75+
expect(TARGETS.windsurf.status).toBe('experimental');
7576
expect(TARGETS.antigravity.status).toBe('experimental');
7677
expect(TARGETS.codex.status).toBe('experimental');
7778
});
@@ -88,6 +89,7 @@ describe('TARGETS', () => {
8889
expect(TARGETS.antigravity.mode).toBe('own-file');
8990
expect(TARGETS.cursor.mode).toBe('own-file');
9091
expect(TARGETS.cline.mode).toBe('own-file');
92+
expect(TARGETS.windsurf.mode).toBe('own-file');
9193
});
9294

9395
it('codex target has mode managed-section', () => {
@@ -255,11 +257,66 @@ describe('renderForTarget("cline")', () => {
255257
});
256258
});
257259

260+
describe('renderForTarget("windsurf")', () => {
261+
const result = renderForTarget('windsurf', STUB_BODY);
262+
263+
it('returns the .windsurf/rules path', () => {
264+
expect(result.path).toBe('.windsurf/rules/testsprite-verify.md');
265+
});
266+
267+
it('uses the Cascade frontmatter (trigger: model_decision + description)', () => {
268+
expect(result.content.startsWith('---\n')).toBe(true);
269+
expect(result.content).toContain('trigger: model_decision');
270+
expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`);
271+
});
272+
273+
it('does NOT carry the Claude/Cursor frontmatter keys', () => {
274+
const match = /^---\n([\s\S]*?)\n---/.exec(result.content);
275+
const fm = match?.[1] ?? '';
276+
expect(fm).not.toContain('name:'); // claude/kiro key
277+
expect(fm).not.toContain('alwaysApply:'); // cursor .mdc key
278+
});
279+
280+
it('preserves the skill body after the frontmatter and ends with a newline', () => {
281+
expect(result.content).toContain('# TestSprite Verification Loop');
282+
expect(result.content.endsWith('\n')).toBe(true);
283+
});
284+
});
285+
286+
describe('windsurf renders the compact body within the rules budget', () => {
287+
// Regression: the full skill body (~21 KB) rendered to ~22 KB, which exceeds
288+
// the Windsurf `.windsurf/rules/*.md` 12 K-character cap and would be silently
289+
// truncated. The windsurf target therefore renders the COMPACT body.
290+
// Uses the REAL bodies (no stub) so the size is the bytes a user receives.
291+
const windsurf = renderForTarget('windsurf');
292+
const claude = renderForTarget('claude');
293+
294+
it('fits within the 12 000-character Windsurf rules budget', () => {
295+
expect(windsurf.content.length).toBeLessThan(12_000);
296+
});
297+
298+
it('uses the compact body, not the full one', () => {
299+
// 'The verification loop that flies' appears only in the full body.
300+
expect(claude.content).toContain('The verification loop that flies');
301+
expect(windsurf.content).not.toContain('The verification loop that flies');
302+
// ...but the core skill identity and a load-bearing command survive.
303+
expect(windsurf.content).toContain('# TestSprite Verification Loop');
304+
expect(windsurf.content).toContain('testsprite test run');
305+
});
306+
307+
it('is materially smaller than the full-body claude render', () => {
308+
expect(windsurf.content.length).toBeLessThan(claude.content.length);
309+
});
310+
});
311+
258312
// ---------------------------------------------------------------------------
259313
// Content integrity — load-bearing command strings must survive any body trim
260314
// ---------------------------------------------------------------------------
261315

262316
describe('content integrity — own-file targets', () => {
317+
// windsurf is intentionally excluded: it renders the COMPACT body (its rules
318+
// file is budget-capped), so it is covered by its own budget test below
319+
// rather than these full-body load-bearing-string checks.
263320
const ownFileTargets: Array<'claude' | 'cursor' | 'cline' | 'antigravity'> = [
264321
'claude',
265322
'cursor',

src/lib/agent-targets.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync } from 'node:fs';
22

3-
export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex';
3+
export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex' | 'windsurf';
44

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

45+
/**
46+
* Windsurf (Cascade) reads workspace rules from `.windsurf/rules/*.md` with
47+
* YAML frontmatter. `trigger: model_decision` is the Cascade equivalent of the
48+
* Cursor `.mdc` `alwaysApply: false` mode: only the `description` is surfaced
49+
* up front, and Cascade pulls in the full rule body when the description shows
50+
* it is relevant — exactly the on-demand activation this verification skill
51+
* wants. (The other triggers are `always_on`, `manual`, and `glob`.)
52+
* Workspace rule files have a 12 KB budget, well above this skill's size.
53+
*/
54+
function wrapWindsurf(body: string): string {
55+
return `---\ntrigger: model_decision\ndescription: ${SKILL_DESCRIPTION}\n---\n\n${body}\n`;
56+
}
57+
3658
export const TARGETS: Record<AgentTarget, TargetSpec> = {
3759
claude: {
3860
status: 'ga',
@@ -58,6 +80,16 @@ export const TARGETS: Record<AgentTarget, TargetSpec> = {
5880
mode: 'own-file',
5981
wrap: body => body,
6082
},
83+
windsurf: {
84+
status: 'experimental',
85+
path: '.windsurf/rules/testsprite-verify.md',
86+
mode: 'own-file',
87+
// Windsurf workspace rules are budget-capped (12 K chars for a
88+
// `.windsurf/rules/*.md` file); the full skill body (~21 KB) would be
89+
// truncated, so render the compact variant — it still fits comfortably.
90+
compactBody: true,
91+
wrap: wrapWindsurf,
92+
},
6193
/**
6294
* codex target — managed-section mode.
6395
*
@@ -124,7 +156,7 @@ export function renderForTarget(t: AgentTarget, body?: string): { path: string;
124156
const resolvedBody =
125157
body !== undefined
126158
? body
127-
: spec.mode === 'managed-section'
159+
: spec.mode === 'managed-section' || spec.compactBody === true
128160
? loadCodexSkillBody()
129161
: loadSkillBody();
130162
return { path: spec.path, content: spec.wrap(resolvedBody) };

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ exports[`--help snapshots > agent 1`] = `
44
"Usage: testsprite agent [options] [command]
55
66
Install TestSprite guidance into coding-agent config (Claude Code, Cursor,
7-
Cline, Antigravity, Codex)
7+
Cline, Windsurf, Antigravity, Codex)
88
99
Options:
1010
-h, --help display help for command
@@ -25,8 +25,8 @@ Write the TestSprite verification-loop skill file into a project for a coding
2525
agent
2626
2727
Options:
28-
--target <t> Agent target(s): claude, cursor, cline, antigravity, codex
29-
(comma-separated or repeated) (default: [])
28+
--target <t> Agent target(s): claude, cursor, cline, windsurf, antigravity,
29+
codex (comma-separated or repeated) (default: [])
3030
--dir <path> Project root to write into (default: cwd)
3131
--force For own-file targets: overwrite existing file (a .bak backup is
3232
kept). For codex (managed-section): replaces the section
@@ -110,7 +110,8 @@ Options:
110110
--from-env Read TESTSPRITE_API_KEY from the environment instead of
111111
prompting (default: false)
112112
--agent <target> Coding-agent target to install: claude, antigravity,
113-
cursor, cline, codex (default: claude) (default: "claude")
113+
cursor, cline, windsurf, codex (default: claude) (default:
114+
"claude")
114115
--no-agent Skip the agent skill install (configure credentials only)
115116
--force Overwrite an existing skill file (a .bak backup is kept)
116117
--dir <path> Project root for the skill install (default: current
@@ -579,8 +580,8 @@ Commands:
579580
project Manage TestSprite projects
580581
test Inspect TestSprite tests
581582
agent Install TestSprite guidance into coding-agent
582-
config (Claude Code, Cursor, Cline, Antigravity,
583-
Codex)
583+
config (Claude Code, Cursor, Cline, Windsurf,
584+
Antigravity, Codex)
584585
usage|credits Show credit balance and plan/entitlement info
585586
(proactive pre-flight before a large test run)
586587
help [command] display help for command

test/e2e/agent-install.e2e.test.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,20 @@ describe('content integrity', () => {
161161
content.trimStart().startsWith('#'),
162162
`cline: should start with a markdown heading`,
163163
).toBe(true);
164+
} else if (target === 'windsurf') {
165+
// Windsurf Cascade frontmatter: trigger + description (no name/alwaysApply)
166+
expect(content.startsWith('---'), `windsurf: should start with ---`).toBe(true);
167+
expect(content).toContain('trigger: model_decision');
164168
}
165169

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

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

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

387394
const result = runCli([
388395
'agent',
389396
'install',
390-
'--target=claude,cursor,cline,antigravity,codex',
397+
'--target=claude,cursor,cline,windsurf,antigravity,codex',
391398
'--dir',
392399
tmpDir,
393400
'--output',
@@ -400,7 +407,14 @@ describe('multi-target install', () => {
400407
action: string;
401408
path: string;
402409
}>;
403-
const allTargets: AgentTarget[] = ['claude', 'cursor', 'cline', 'antigravity', 'codex'];
410+
const allTargets: AgentTarget[] = [
411+
'claude',
412+
'cursor',
413+
'cline',
414+
'windsurf',
415+
'antigravity',
416+
'codex',
417+
];
404418

405419
for (const target of allTargets) {
406420
const entry = parsed.find(r => r.target === target);
@@ -636,7 +650,14 @@ describe('managed-section (codex target)', () => {
636650
// ---------------------------------------------------------------------------
637651
describe('matrix coverage guard', () => {
638652
it('TARGETS matches the documented, e2e-covered set (update this list when adding a target)', () => {
639-
expect(Object.keys(TARGETS)).toEqual(['claude', 'antigravity', 'cursor', 'cline', 'codex']);
653+
expect(Object.keys(TARGETS)).toEqual([
654+
'claude',
655+
'antigravity',
656+
'cursor',
657+
'cline',
658+
'windsurf',
659+
'codex',
660+
]);
640661
});
641662
});
642663

0 commit comments

Comments
 (0)