Skip to content

Commit dc3ab2d

Browse files
feat(agent): add gemini install target
1 parent 15e95de commit dc3ab2d

7 files changed

Lines changed: 163 additions & 50 deletions

File tree

DOCUMENTATION.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,16 @@ Every artifact in the bundle shares one `snapshotId`; the CLI will not mix a fai
110110
```bash
111111
testsprite agent install claude # install the skill for Claude Code
112112
testsprite agent install codex # install into AGENTS.md for Codex (managed-section)
113+
testsprite agent install gemini # install into GEMINI.md for Gemini CLI (managed-section)
113114
testsprite agent install cursor # .cursor/rules/testsprite-verify.mdc
114115
testsprite agent install cline # .clinerules/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 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), `gemini` (experimental), `cursor` (experimental), `cline` (experimental), `antigravity` (experimental).
120121

121-
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.
122+
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.
122123

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

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`, `antigravity`, `codex`, `gemini`, 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`, `gemini`, `cursor`, `cline`, `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: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ describe('runInstall — empty target', () => {
695695
// ---------------------------------------------------------------------------
696696

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

701701
await runList({ profile: 'default', output: 'text', debug: false, dryRun: false }, deps);
@@ -706,6 +706,7 @@ describe('runList', () => {
706706
expect(out).toContain('cline');
707707
expect(out).toContain('antigravity');
708708
expect(out).toContain('codex');
709+
expect(out).toContain('gemini');
709710
expect(out).toContain('ga');
710711
expect(out).toContain('experimental');
711712
// All matrix paths present
@@ -714,6 +715,7 @@ describe('runList', () => {
714715
expect(out).toContain(TARGETS.cline.path);
715716
expect(out).toContain(TARGETS.antigravity.path);
716717
expect(out).toContain(TARGETS.codex.path);
718+
expect(out).toContain(TARGETS.gemini.path);
717719
});
718720

719721
it('JSON mode emits array of {target, status, path, mode}', async () => {
@@ -723,19 +725,22 @@ describe('runList', () => {
723725

724726
const json = JSON.parse(capture.stdout.join('\n')) as ListResult[];
725727
expect(Array.isArray(json)).toBe(true);
726-
expect(json).toHaveLength(5);
728+
expect(json).toHaveLength(6);
727729
const targets = json.map(r => r.target);
728730
expect(targets).toContain('claude');
729731
expect(targets).toContain('cursor');
730732
expect(targets).toContain('cline');
731733
expect(targets).toContain('antigravity');
732734
expect(targets).toContain('codex');
735+
expect(targets).toContain('gemini');
733736
const claudeEntry = json.find(r => r.target === 'claude');
734737
expect(claudeEntry?.status).toBe('ga');
735738
expect(claudeEntry?.path).toBe(TARGETS.claude.path);
736739
// codex entry has mode: managed-section
737740
const codexEntry = json.find(r => r.target === 'codex');
738741
expect(codexEntry?.mode).toBe('managed-section');
742+
const geminiEntry = json.find(r => r.target === 'gemini');
743+
expect(geminiEntry?.mode).toBe('managed-section');
739744
});
740745

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

1361+
describe('runInstall — gemini managed-section: append (GEMINI.md exists, no sentinels)', () => {
1362+
it('appends the section to existing GEMINI.md content', async () => {
1363+
const { store, fs: agentFs, seedFile } = makeMemFs();
1364+
const { capture, deps } = makeCapture();
1365+
1366+
const geminiAbs = path.resolve(CWD, TARGETS.gemini.path);
1367+
const existingContent = '# Project Instructions\n\nKeep existing Gemini CLI notes.\n';
1368+
seedFile(geminiAbs, existingContent);
1369+
1370+
await runInstall(
1371+
{ ...BASE_OPTS, target: ['gemini'], force: false },
1372+
{ cwd: CWD, fs: agentFs, ...deps },
1373+
);
1374+
1375+
const written = store.get(geminiAbs)!;
1376+
expect(written).toContain('# Project Instructions');
1377+
expect(written).toContain('Keep existing Gemini CLI notes.');
1378+
expect(written).toContain(TARGETS.gemini.managedSection!.begin);
1379+
expect(written).toContain(TARGETS.gemini.managedSection!.end);
1380+
expect(written).toContain('testsprite test run');
1381+
expect(capture.stdout.join('\n')).toContain('gemini');
1382+
expect(capture.stdout.join('\n')).toContain('section-installed');
1383+
});
1384+
});
1385+
13561386
describe('runInstall — codex managed-section: append (AGENTS.md exists, no sentinels)', () => {
13571387
it('appends the section to existing AGENTS.md content', async () => {
13581388
const { store, fs: agentFs, seedFile } = makeMemFs();

src/commands/agent.ts

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ import { GLOBAL_OPTS_HINT, Output } from '../lib/output.js';
88
import { promptText } from '../lib/prompt.js';
99
import {
1010
type AgentTarget,
11+
type ManagedSectionSpec,
1112
TARGETS,
1213
loadSkillBody,
1314
loadCodexSkillBody,
1415
renderForTarget,
15-
MANAGED_SECTION_BEGIN,
16-
MANAGED_SECTION_END,
1716
} from '../lib/agent-targets.js';
1817

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

157156
/**
@@ -182,7 +181,11 @@ type SectionState =
182181
* - CRLF files are handled by stripping trailing \r from each line before
183182
* comparison.
184183
*/
185-
function classifySection(existing: string, section: string): SectionState {
184+
function classifySection(
185+
existing: string,
186+
section: string,
187+
managed: ManagedSectionSpec,
188+
): SectionState {
186189
// Split on LF; strip trailing CR so CRLF files normalise correctly.
187190
const lines = existing.split('\n');
188191

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

194197
for (let i = 0; i < lines.length; i++) {
195198
const stripped = (lines[i] ?? '').trimEnd();
196-
if (stripped === MANAGED_SECTION_BEGIN) beginLines.push(i);
197-
else if (stripped === MANAGED_SECTION_END) endLines.push(i);
199+
if (stripped === managed.begin) beginLines.push(i);
200+
else if (stripped === managed.end) endLines.push(i);
198201
}
199202

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

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

396399
const results: InstallResult[] = [];
397400

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

412415
// -----------------------------------------------------------------------
413-
// managed-section mode (codex target)
416+
// managed-section mode (root instruction files such as AGENTS.md/GEMINI.md)
414417
// -----------------------------------------------------------------------
415418
if (spec.mode === 'managed-section') {
416-
if (codexBody === undefined) codexBody = loadCodexSkillBody();
417-
const section = buildSection(codexBody);
419+
const managed = spec.managedSection;
420+
if (managed === undefined) {
421+
throw new CLIError(`managed-section target "${t}" is missing sentinel metadata`, 5);
422+
}
423+
const managedConfig = managed;
424+
if (managedSectionBody === undefined) managedSectionBody = loadCodexSkillBody();
425+
const section = buildSection(managedSectionBody, managedConfig);
418426

419427
if (opts.dryRun) {
420428
// Dry-run: report what would happen without writing disk.
@@ -458,7 +466,7 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
458466
}
459467
}
460468
if (existing !== null) {
461-
const state = classifySection(existing, section);
469+
const state = classifySection(existing, section, managedConfig);
462470
if (state.kind === 'corrupt') {
463471
// The real install would refuse with exit 5 — dry-run reports
464472
// the same outcome rather than a misleading success.
@@ -477,9 +485,12 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
477485
}
478486
}
479487
const wouldBeBytes = Buffer.byteLength(wouldBeContent, 'utf8');
480-
if (wouldBeBytes > AGENTS_MD_CODEX_BUDGET_BYTES) {
488+
if (
489+
managedConfig.loadBudgetBytes !== undefined &&
490+
wouldBeBytes > managedConfig.loadBudgetBytes
491+
) {
481492
stderrFn(
482-
`[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.`,
493+
`[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.`,
483494
);
484495
}
485496
dryRunLines.push({ abs, bytes, note: 'managed section' });
@@ -498,15 +509,18 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
498509
}
499510

500511
/**
501-
* [P2] Emit a stderr warn when the would-be file content exceeds Codex's
502-
* 32 KiB load budget. We still write — this is a warn, not a refusal —
503-
* but the operator needs early visibility so they can trim AGENTS.md.
512+
* Emit a stderr warn when the target has a documented load budget and the
513+
* would-be file content exceeds it. We still write — this is visibility,
514+
* not a refusal.
504515
*/
505516
function warnIfOverBudget(wouldBeContent: string): void {
506517
const byteLen = Buffer.byteLength(wouldBeContent, 'utf8');
507-
if (byteLen > AGENTS_MD_CODEX_BUDGET_BYTES) {
518+
if (
519+
managedConfig.loadBudgetBytes !== undefined &&
520+
byteLen > managedConfig.loadBudgetBytes
521+
) {
508522
stderrFn(
509-
`[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.`,
523+
`[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.`,
510524
);
511525
}
512526
}
@@ -529,7 +543,7 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr
529543
results.push({ target: t, path: relPath, action: 'section-installed' });
530544
} else {
531545
const existing = await agentFs.readFile(abs);
532-
const state = classifySection(existing, section);
546+
const state = classifySection(existing, section, managedConfig);
533547

534548
if (state.kind === 'corrupt') {
535549
// BEGIN without matching END (or vice-versa) — never destroy user content.
@@ -703,7 +717,7 @@ function collect(v: string, prev: string[]): string[] {
703717

704718
export function createAgentCommand(deps: AgentDeps = {}): Command {
705719
const agent = new Command('agent').description(
706-
'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Codex)',
720+
'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Codex, Gemini CLI)',
707721
);
708722

709723
agent
@@ -713,15 +727,15 @@ export function createAgentCommand(deps: AgentDeps = {}): Command {
713727
)
714728
.option(
715729
'--target <t>',
716-
'Agent target(s): claude, cursor, cline, antigravity, codex (comma-separated or repeated)',
730+
'Agent target(s): claude, cursor, cline, antigravity, codex, gemini (comma-separated or repeated)',
717731
collect,
718732
[],
719733
)
720734
.option('--dir <path>', 'Project root to write into (default: cwd)')
721735
.option(
722736
'--force',
723737
'For own-file targets: overwrite existing file (a .bak backup is kept). ' +
724-
'For codex (managed-section): replaces the section unconditionally; user content outside the section is never destroyed.',
738+
'For managed-section targets: replaces the section unconditionally; user content outside the section is never destroyed.',
725739
)
726740
.addHelpText('after', GLOBAL_OPTS_HINT)
727741
.action(

src/lib/agent-targets.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,21 @@ 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', 'gemini']);
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, antigravity, codex, and gemini are experimental', () => {
7373
expect(TARGETS.cursor.status).toBe('experimental');
7474
expect(TARGETS.cline.status).toBe('experimental');
7575
expect(TARGETS.antigravity.status).toBe('experimental');
7676
expect(TARGETS.codex.status).toBe('experimental');
77+
expect(TARGETS.gemini.status).toBe('experimental');
7778
});
7879

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

93-
it('codex target has mode managed-section', () => {
94+
it('codex and gemini targets have mode managed-section', () => {
9495
expect(TARGETS.codex.mode).toBe('managed-section');
96+
expect(TARGETS.gemini.mode).toBe('managed-section');
9597
});
9698

9799
it('codex target path is AGENTS.md', () => {
98100
expect(TARGETS.codex.path).toBe('AGENTS.md');
99101
});
102+
103+
it('gemini target path is GEMINI.md', () => {
104+
expect(TARGETS.gemini.path).toBe('GEMINI.md');
105+
});
100106
});
101107

102108
// ---------------------------------------------------------------------------
@@ -380,3 +386,31 @@ describe('content integrity — codex target (testsprite-verify.codex.md)', () =
380386
expect(result.content).not.toContain('alwaysApply:');
381387
});
382388
});
389+
390+
// ---------------------------------------------------------------------------
391+
// content integrity — gemini target
392+
// ---------------------------------------------------------------------------
393+
394+
describe('content integrity — gemini target (GEMINI.md managed-section body)', () => {
395+
it('renderForTarget("gemini") path is GEMINI.md', () => {
396+
const STUB_GEMINI_BODY =
397+
'# TestSprite Verification Loop\ntestsprite test run\n--wait\ntest artifact get\n';
398+
const result = renderForTarget('gemini', STUB_GEMINI_BODY);
399+
expect(result.path).toBe('GEMINI.md');
400+
});
401+
402+
it('renderForTarget("gemini") content is the body unwrapped (no frontmatter)', () => {
403+
const STUB_GEMINI_BODY =
404+
'# TestSprite Verification Loop\ntestsprite test run\n--wait\ntest artifact get\n';
405+
const result = renderForTarget('gemini', STUB_GEMINI_BODY);
406+
expect(result.content).toBe(STUB_GEMINI_BODY);
407+
expect(result.content).not.toContain('---');
408+
});
409+
410+
it('renderForTarget("gemini") without body arg uses the managed-section asset', () => {
411+
const result = renderForTarget('gemini');
412+
expect(result.content).toContain('testsprite test run');
413+
expect(result.content).not.toContain('name: testsprite-verify');
414+
expect(result.content).not.toContain('alwaysApply:');
415+
});
416+
});

0 commit comments

Comments
 (0)