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
7 changes: 4 additions & 3 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,16 @@ Every artifact in the bundle shares one `snapshotId`; the CLI will not mix a fai
```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 gemini # install into GEMINI.md for Gemini CLI (managed-section)
testsprite agent install cursor # .cursor/rules/testsprite-verify.mdc
testsprite agent install cline # .clinerules/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 targets with status + mode + path
```

Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `antigravity` (experimental).
Supported targets: `claude` (GA), `codex` (experimental), `gemini` (experimental), `cursor` (experimental), `cline` (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.
The `codex` and `gemini` targets use **managed-section mode** — they write only a sentinel-delimited section inside your existing `AGENTS.md` or `GEMINI.md`, so your project instructions are never clobbered. Re-running without `--force` replaces the section in-place; user content outside the sentinels is always preserved.

Re-running with `--force` on **own-file targets** (claude, cursor, cline, antigravity) backs up the existing file to `<path>.bak` first.

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`, `antigravity`, `codex`, `gemini`, 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`, `gemini`, `cursor`, `cline`, `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
34 changes: 32 additions & 2 deletions src/commands/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ describe('runInstall — empty target', () => {
// ---------------------------------------------------------------------------

describe('runList', () => {
it('returns all five targets with correct status', async () => {
it('returns all six targets with correct status', async () => {
const { capture, deps } = makeCapture();

await runList({ profile: 'default', output: 'text', debug: false, dryRun: false }, deps);
Expand All @@ -706,6 +706,7 @@ describe('runList', () => {
expect(out).toContain('cline');
expect(out).toContain('antigravity');
expect(out).toContain('codex');
expect(out).toContain('gemini');
expect(out).toContain('ga');
expect(out).toContain('experimental');
// All matrix paths present
Expand All @@ -714,6 +715,7 @@ describe('runList', () => {
expect(out).toContain(TARGETS.cline.path);
expect(out).toContain(TARGETS.antigravity.path);
expect(out).toContain(TARGETS.codex.path);
expect(out).toContain(TARGETS.gemini.path);
});

it('JSON mode emits array of {target, status, path, mode}', async () => {
Expand All @@ -723,19 +725,22 @@ 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('antigravity');
expect(targets).toContain('codex');
expect(targets).toContain('gemini');
const claudeEntry = json.find(r => r.target === 'claude');
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');
expect(codexEntry?.mode).toBe('managed-section');
const geminiEntry = json.find(r => r.target === 'gemini');
expect(geminiEntry?.mode).toBe('managed-section');
});

it('text mode has a header row', async () => {
Expand Down Expand Up @@ -1353,6 +1358,31 @@ describe('runInstall — codex managed-section: create (AGENTS.md absent)', () =
});
});

describe('runInstall — gemini managed-section: append (GEMINI.md exists, no sentinels)', () => {
it('appends the section to existing GEMINI.md content', async () => {
const { store, fs: agentFs, seedFile } = makeMemFs();
const { capture, deps } = makeCapture();

const geminiAbs = path.resolve(CWD, TARGETS.gemini.path);
const existingContent = '# Project Instructions\n\nKeep existing Gemini CLI notes.\n';
seedFile(geminiAbs, existingContent);

await runInstall(
{ ...BASE_OPTS, target: ['gemini'], force: false },
{ cwd: CWD, fs: agentFs, ...deps },
);

const written = store.get(geminiAbs)!;
expect(written).toContain('# Project Instructions');
expect(written).toContain('Keep existing Gemini CLI notes.');
expect(written).toContain(TARGETS.gemini.managedSection!.begin);
expect(written).toContain(TARGETS.gemini.managedSection!.end);
expect(written).toContain('testsprite test run');
expect(capture.stdout.join('\n')).toContain('gemini');
expect(capture.stdout.join('\n')).toContain('section-installed');
});
});

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();
Expand Down
60 changes: 37 additions & 23 deletions src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import { GLOBAL_OPTS_HINT, Output } from '../lib/output.js';
import { promptText } from '../lib/prompt.js';
import {
type AgentTarget,
type ManagedSectionSpec,
TARGETS,
loadSkillBody,
loadCodexSkillBody,
renderForTarget,
MANAGED_SECTION_BEGIN,
MANAGED_SECTION_END,
} from '../lib/agent-targets.js';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -150,8 +149,8 @@ async function writeBackup(agentFs: AgentFs, abs: string, existing: string): Pro
* Build the section block to inject (sentinels + body + trailing newline).
* Uses \n throughout; the caller handles CRLF normalisation.
*/
function buildSection(body: string): string {
return `${MANAGED_SECTION_BEGIN}\n${body.trimEnd()}\n${MANAGED_SECTION_END}\n`;
function buildSection(body: string, managed: ManagedSectionSpec): string {
return `${managed.begin}\n${body.trimEnd()}\n${managed.end}\n`;
}

/**
Expand Down Expand Up @@ -182,7 +181,11 @@ type SectionState =
* - CRLF files are handled by stripping trailing \r from each line before
* comparison.
*/
function classifySection(existing: string, section: string): SectionState {
function classifySection(
existing: string,
section: string,
managed: ManagedSectionSpec,
): SectionState {
// Split on LF; strip trailing CR so CRLF files normalise correctly.
const lines = existing.split('\n');

Expand All @@ -193,8 +196,8 @@ function classifySection(existing: string, section: string): SectionState {

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);
if (stripped === managed.begin) beginLines.push(i);
else if (stripped === managed.end) endLines.push(i);
}

const hasBegin = beginLines.length > 0;
Expand Down Expand Up @@ -391,7 +394,7 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr

// 4. Load skill bodies (lazy — only touch disk if a target actually needs it)
let ownFileBody: string | undefined;
let codexBody: string | undefined;
let managedSectionBody: string | undefined;

const results: InstallResult[] = [];

Expand All @@ -410,11 +413,16 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
}

// -----------------------------------------------------------------------
// managed-section mode (codex target)
// managed-section mode (root instruction files such as AGENTS.md/GEMINI.md)
// -----------------------------------------------------------------------
if (spec.mode === 'managed-section') {
if (codexBody === undefined) codexBody = loadCodexSkillBody();
const section = buildSection(codexBody);
const managed = spec.managedSection;
if (managed === undefined) {
throw new CLIError(`managed-section target "${t}" is missing sentinel metadata`, 5);
}
const managedConfig = managed;
if (managedSectionBody === undefined) managedSectionBody = loadCodexSkillBody();
const section = buildSection(managedSectionBody, managedConfig);

if (opts.dryRun) {
// Dry-run: report what would happen without writing disk.
Expand Down Expand Up @@ -458,7 +466,7 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
}
}
if (existing !== null) {
const state = classifySection(existing, section);
const state = classifySection(existing, section, managedConfig);
if (state.kind === 'corrupt') {
// The real install would refuse with exit 5 — dry-run reports
// the same outcome rather than a misleading success.
Expand All @@ -477,9 +485,12 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
}
}
const wouldBeBytes = Buffer.byteLength(wouldBeContent, 'utf8');
if (wouldBeBytes > AGENTS_MD_CODEX_BUDGET_BYTES) {
if (
managedConfig.loadBudgetBytes !== undefined &&
wouldBeBytes > managedConfig.loadBudgetBytes
) {
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.`,
`[warn] ${relPath} will be ${wouldBeBytes} bytes after this write — ${managedConfig.loadBudgetLabel ?? `the target agent may not load content beyond its ${managedConfig.loadBudgetBytes} byte budget`}. Trim ${relPath} to stay within the limit.`,
);
}
dryRunLines.push({ abs, bytes, note: 'managed section' });
Expand All @@ -498,15 +509,18 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
}

/**
* [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.
* Emit a stderr warn when the target has a documented load budget and the
* would-be file content exceeds it. We still write — this is visibility,
* not a refusal.
*/
function warnIfOverBudget(wouldBeContent: string): void {
const byteLen = Buffer.byteLength(wouldBeContent, 'utf8');
if (byteLen > AGENTS_MD_CODEX_BUDGET_BYTES) {
if (
managedConfig.loadBudgetBytes !== undefined &&
byteLen > managedConfig.loadBudgetBytes
) {
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.`,
`[warn] ${relPath} will be ${byteLen} bytes after this write — ${managedConfig.loadBudgetLabel ?? `the target agent may not load content beyond its ${managedConfig.loadBudgetBytes} byte budget`}. Trim ${relPath} to stay within the limit.`,
);
}
}
Expand All @@ -529,7 +543,7 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
results.push({ target: t, path: relPath, action: 'section-installed' });
} else {
const existing = await agentFs.readFile(abs);
const state = classifySection(existing, section);
const state = classifySection(existing, section, managedConfig);

if (state.kind === 'corrupt') {
// BEGIN without matching END (or vice-versa) — never destroy user content.
Expand Down Expand Up @@ -703,7 +717,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, Antigravity, Codex, Gemini CLI)',
);

agent
Expand All @@ -713,15 +727,15 @@ 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, antigravity, codex, gemini (comma-separated or repeated)',
collect,
[],
)
.option('--dir <path>', '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.',
'For managed-section targets: replaces the section unconditionally; user content outside the section is never destroyed.',
)
.addHelpText('after', GLOBAL_OPTS_HINT)
.action(
Expand Down
42 changes: 38 additions & 4 deletions src/lib/agent-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,21 @@ 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', 'gemini']);
});

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

it('cursor, cline, antigravity, and codex are experimental', () => {
it('cursor, cline, antigravity, codex, and gemini are experimental', () => {
expect(TARGETS.cursor.status).toBe('experimental');
expect(TARGETS.cline.status).toBe('experimental');
expect(TARGETS.antigravity.status).toBe('experimental');
expect(TARGETS.codex.status).toBe('experimental');
expect(TARGETS.gemini.status).toBe('experimental');
});

it('each target has a non-empty POSIX path', () => {
Expand All @@ -90,13 +91,18 @@ describe('TARGETS', () => {
expect(TARGETS.cline.mode).toBe('own-file');
});

it('codex target has mode managed-section', () => {
it('codex and gemini targets have mode managed-section', () => {
expect(TARGETS.codex.mode).toBe('managed-section');
expect(TARGETS.gemini.mode).toBe('managed-section');
});

it('codex target path is AGENTS.md', () => {
expect(TARGETS.codex.path).toBe('AGENTS.md');
});

it('gemini target path is GEMINI.md', () => {
expect(TARGETS.gemini.path).toBe('GEMINI.md');
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -380,3 +386,31 @@ describe('content integrity — codex target (testsprite-verify.codex.md)', () =
expect(result.content).not.toContain('alwaysApply:');
});
});

// ---------------------------------------------------------------------------
// content integrity — gemini target
// ---------------------------------------------------------------------------

describe('content integrity — gemini target (GEMINI.md managed-section body)', () => {
it('renderForTarget("gemini") path is GEMINI.md', () => {
const STUB_GEMINI_BODY =
'# TestSprite Verification Loop\ntestsprite test run\n--wait\ntest artifact get\n';
const result = renderForTarget('gemini', STUB_GEMINI_BODY);
expect(result.path).toBe('GEMINI.md');
});

it('renderForTarget("gemini") content is the body unwrapped (no frontmatter)', () => {
const STUB_GEMINI_BODY =
'# TestSprite Verification Loop\ntestsprite test run\n--wait\ntest artifact get\n';
const result = renderForTarget('gemini', STUB_GEMINI_BODY);
expect(result.content).toBe(STUB_GEMINI_BODY);
expect(result.content).not.toContain('---');
});

it('renderForTarget("gemini") without body arg uses the managed-section asset', () => {
const result = renderForTarget('gemini');
expect(result.content).toContain('testsprite test run');
expect(result.content).not.toContain('name: testsprite-verify');
expect(result.content).not.toContain('alwaysApply:');
});
});
Loading