Skip to content

Commit 3df4a37

Browse files
committed
feat(agent): add Gemini CLI as an install target
Adds `gemini` to the supported agent-install targets. Gemini CLI reads project-level context from a GEMINI.md file in the repo root, which is loaded at the start of every session (always-on, no on-demand mode). Because all installed skills share a single GEMINI.md file in the project root, the target uses managed-section mode -- the same approach as the codex/AGENTS.md target -- writing only a sentinel-delimited section so any existing user content in GEMINI.md is never clobbered. The landing path is GEMINI.md (repo root). Both testsprite-verify and testsprite-onboard are aggregated into one managed section, consistent with how codex aggregates skills into AGENTS.md. Slots into the existing TARGETS machinery: agent list, setup --agent, and skill-nudge install-detection all pick it up automatically. Updates the AgentTarget union, pathFor, TARGETS, agent command description, help text, unit tests, e2e matrix guards, and the help snapshot. Contributes to CONTRIBUTING.md accepted target: gemini
1 parent d78d0d4 commit 3df4a37

7 files changed

Lines changed: 115 additions & 22 deletions

File tree

src/commands/agent.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,8 @@ describe('runList', () => {
779779

780780
const json = JSON.parse(capture.stdout.join('\n')) as ListResult[];
781781
expect(Array.isArray(json)).toBe(true);
782-
// 8 targets × 2 default skills = 16 rows
783-
expect(json).toHaveLength(16);
782+
// 9 targets x 2 default skills = 18 rows
783+
expect(json).toHaveLength(18);
784784
const targets = json.map(r => r.target);
785785
expect(targets).toContain('claude');
786786
expect(targets).toContain('cursor');

src/commands/agent.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,7 +1062,7 @@ function collect(v: string, prev: string[]): string[] {
10621062

10631063
export function createAgentCommand(deps: AgentDeps = {}): Command {
10641064
const agent = new Command('agent').description(
1065-
'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Kiro, Windsurf, Copilot, Codex)',
1065+
'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Kiro, Windsurf, Copilot, Gemini, Codex)',
10661066
);
10671067

10681068
agent
@@ -1072,7 +1072,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command {
10721072
)
10731073
.option(
10741074
'--target <t>',
1075-
'Agent target(s): claude, cursor, cline, antigravity, kiro, windsurf, copilot, codex (comma-separated or repeated)',
1075+
'Agent target(s): claude, cursor, cline, antigravity, kiro, windsurf, copilot, gemini, codex (comma-separated or repeated)',
10761076
collect,
10771077
[],
10781078
)
@@ -1086,7 +1086,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command {
10861086
.option(
10871087
'--force',
10881088
'For own-file targets: overwrite existing file (a .bak backup is kept). ' +
1089-
'For codex (managed-section): replaces the section unconditionally; user content outside the section is never destroyed.',
1089+
'For managed-section targets (codex, gemini): replaces the section unconditionally; user content outside the section is never destroyed.',
10901090
)
10911091
.addHelpText('after', GLOBAL_OPTS_HINT)
10921092
.action(

src/lib/agent-targets.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ testsprite test artifact get <run-id> --out ./out/
8282
// ---------------------------------------------------------------------------
8383

8484
describe('TARGETS', () => {
85-
it('has all eight required keys', () => {
85+
it('has all nine required keys', () => {
8686
const keys = Object.keys(TARGETS).sort();
8787
expect(keys).toEqual([
8888
'antigravity',
@@ -91,6 +91,7 @@ describe('TARGETS', () => {
9191
'codex',
9292
'copilot',
9393
'cursor',
94+
'gemini',
9495
'kiro',
9596
'windsurf',
9697
]);
@@ -100,11 +101,12 @@ describe('TARGETS', () => {
100101
expect(TARGETS.claude.status).toBe('ga');
101102
});
102103

103-
it('cursor, cline, windsurf, copilot, antigravity, kiro, and codex are experimental', () => {
104+
it('cursor, cline, windsurf, copilot, gemini, antigravity, kiro, and codex are experimental', () => {
104105
expect(TARGETS.cursor.status).toBe('experimental');
105106
expect(TARGETS.cline.status).toBe('experimental');
106107
expect(TARGETS.windsurf.status).toBe('experimental');
107108
expect(TARGETS.copilot.status).toBe('experimental');
109+
expect(TARGETS.gemini.status).toBe('experimental');
108110
expect(TARGETS.antigravity.status).toBe('experimental');
109111
expect(TARGETS.kiro.status).toBe('experimental');
110112
expect(TARGETS.codex.status).toBe('experimental');
@@ -127,8 +129,9 @@ describe('TARGETS', () => {
127129
expect(TARGETS.copilot.mode).toBe('own-file');
128130
});
129131

130-
it('codex target has mode managed-section', () => {
132+
it('codex and gemini targets have mode managed-section', () => {
131133
expect(TARGETS.codex.mode).toBe('managed-section');
134+
expect(TARGETS.gemini.mode).toBe('managed-section');
132135
});
133136

134137
it('codex target path is AGENTS.md', () => {
@@ -386,6 +389,31 @@ describe('renderForTarget("copilot")', () => {
386389
});
387390
});
388391

392+
describe('renderForTarget("gemini")', () => {
393+
const result = renderForTarget('gemini', 'testsprite-verify', STUB_BODY);
394+
395+
it('returns GEMINI.md as the landing path', () => {
396+
expect(result.path).toBe('GEMINI.md');
397+
});
398+
399+
it('does not emit YAML frontmatter (plain Markdown, no --- fence)', () => {
400+
expect(result.content.startsWith('---\n')).toBe(false);
401+
expect(result.content).not.toContain('name:');
402+
expect(result.content).not.toContain('applyTo:');
403+
expect(result.content).not.toContain('trigger:');
404+
});
405+
406+
it('contains the stub body verbatim', () => {
407+
expect(result.content).toContain(STUB_BODY);
408+
});
409+
410+
it('renders the verify body content (managed-section, compact codex body)', () => {
411+
// Uses real bodies: gemini uses the codex contribution body (compact).
412+
const gemini = renderForTarget('gemini', 'testsprite-verify');
413+
expect(gemini.content).toContain('testsprite test run');
414+
});
415+
});
416+
389417
// ---------------------------------------------------------------------------
390418
// Content integrity — load-bearing command strings must survive any body trim
391419
// ---------------------------------------------------------------------------

src/lib/agent-targets.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export type AgentTarget =
1010
| 'codex'
1111
| 'kiro'
1212
| 'windsurf'
13-
| 'copilot';
13+
| 'copilot'
14+
| 'gemini';
1415

1516
export interface TargetSpec {
1617
status: 'ga' | 'experimental';
@@ -189,6 +190,8 @@ export function pathFor(target: AgentTarget, skill: string): string {
189190
return `.windsurf/rules/${skill}.md`;
190191
case 'copilot':
191192
return `.github/instructions/${skill}.instructions.md`;
193+
case 'gemini':
194+
return 'GEMINI.md';
192195
case 'codex':
193196
return 'AGENTS.md';
194197
}
@@ -243,11 +246,34 @@ export const TARGETS: Record<AgentTarget, TargetSpec> = {
243246
// GitHub Copilot path-specific instructions: frontmatter carries `applyTo`.
244247
// `applyTo: '**'` means the file is ALWAYS injected into Copilot requests
245248
// (there is no on-demand "model decides" mode like Cursor/Windsurf), so
246-
// render the compact body to keep the always-on context cost small the
249+
// render the compact body to keep the always-on context cost small -- the
247250
// same reasoning that drives windsurf's compact render.
248251
compactBody: true,
249252
wrap: wrapCopilot,
250253
},
254+
/**
255+
* gemini target -- managed-section mode (GEMINI.md).
256+
*
257+
* Gemini CLI reads project-level instructions from a `GEMINI.md` file in
258+
* the repo root. The file is plain Markdown, loaded at the start of every
259+
* session (always-on). Because all installed skills share this single file
260+
* we use managed-section mode -- the same approach as codex/AGENTS.md --
261+
* writing only a sentinel-delimited section so any existing user content
262+
* in GEMINI.md is never clobbered.
263+
*
264+
* The compact body is used (same reasoning as windsurf/copilot): GEMINI.md
265+
* is always-injected, so keeping it small reduces context cost.
266+
*
267+
* --force replaces the managed section unconditionally but never touches
268+
* content outside the sentinels.
269+
*/
270+
gemini: {
271+
status: 'experimental',
272+
path: pathFor('gemini', SKILL_NAME),
273+
mode: 'managed-section',
274+
// GEMINI.md is plain Markdown; wrap is a no-op (no frontmatter).
275+
wrap: (_name, _description, body) => body,
276+
},
251277
/**
252278
* codex target — managed-section mode.
253279
*

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

Lines changed: 9 additions & 9 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, Kiro, Windsurf, Copilot, Codex)
7+
Cline, Antigravity, Kiro, Windsurf, Copilot, Gemini, Codex)
88
99
Options:
1010
-h, --help display help for command
@@ -29,15 +29,15 @@ into a project for a coding agent
2929
3030
Options:
3131
--target <t> Agent target(s): claude, cursor, cline, antigravity, kiro,
32-
windsurf, copilot, codex (comma-separated or repeated)
33-
(default: [])
32+
windsurf, copilot, gemini, codex (comma-separated or
33+
repeated) (default: [])
3434
--skill <name> Skill(s) to install: testsprite-verify, testsprite-onboard
3535
(comma-separated or repeated; default: all) (default: [])
3636
--dir <path> Project root to write into (default: cwd)
3737
--force For own-file targets: overwrite existing file (a .bak backup
38-
is kept). For codex (managed-section): replaces the section
39-
unconditionally; user content outside the section is never
40-
destroyed.
38+
is kept). For managed-section targets (codex, gemini):
39+
replaces the section unconditionally; user content outside
40+
the section is never destroyed.
4141
-h, --help display help for command
4242
4343
Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug):
@@ -116,8 +116,8 @@ Options:
116116
--from-env Read TESTSPRITE_API_KEY from the environment instead of
117117
prompting (default: false)
118118
--agent <target> Coding-agent target to install: claude, antigravity,
119-
cursor, cline, kiro, windsurf, copilot, codex (default:
120-
claude) (default: "claude")
119+
cursor, cline, kiro, windsurf, copilot, gemini, codex
120+
(default: claude) (default: "claude")
121121
--no-agent Skip the agent skill install (configure credentials
122122
only)
123123
--force Overwrite an existing skill file (a .bak backup is
@@ -764,7 +764,7 @@ Commands:
764764
test Inspect TestSprite tests
765765
agent Install TestSprite guidance into coding-agent
766766
config (Claude Code, Cursor, Cline, Antigravity,
767-
Kiro, Windsurf, Copilot, Codex)
767+
Kiro, Windsurf, Copilot, Gemini, Codex)
768768
usage|credits Show credit balance and plan/entitlement info
769769
(proactive pre-flight before a large test run)
770770
doctor Diagnose CLI setup: version, Node, profile,

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

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,12 @@ describe('content integrity', () => {
179179
expect(content.startsWith('---'), `copilot: should start with ---`).toBe(true);
180180
expect(content).toContain("applyTo: '**'");
181181
expect(content).toContain('description:');
182+
} else if (target === 'gemini') {
183+
// GEMINI.md is plain Markdown -- no frontmatter expected.
184+
expect(content.startsWith('---'), `gemini: must NOT start with ---`).toBe(false);
185+
expect(content).not.toContain('applyTo:');
186+
expect(content).not.toContain('trigger:');
182187
}
183-
184188
// (b) branding — the renamed H1 must be present in every body variant
185189
expect(content).toContain('TestSprite Verification Loop');
186190
// The full-body intro line lives only in the FULL body; compact-body targets
@@ -219,6 +223,9 @@ describe('content integrity', () => {
219223
} else if (target === 'copilot') {
220224
expect(content.startsWith('---'), `copilot/onboard: should start with ---`).toBe(true);
221225
expect(content).toContain("applyTo: '**'");
226+
} else if (target === 'gemini') {
227+
// GEMINI.md is plain Markdown -- no frontmatter.
228+
expect(content.startsWith('---'), `gemini/onboard: must NOT start with ---`).toBe(false);
222229
}
223230

224231
// Load-bearing onboard string: the skill body must reference setup
@@ -264,8 +271,38 @@ describe('content integrity', () => {
264271
// (d) No frontmatter fence — AGENTS.md is plain prose
265272
expect(content.startsWith('---'), 'codex: must NOT start with ---').toBe(false);
266273
});
267-
});
268274

275+
it('gemini GEMINI.md contains ONE managed section with verify and onboard content', () => {
276+
const tmpDir = freshTmpDir();
277+
runCli(['agent', 'install', '--target=gemini', '--dir', tmpDir, '--output', 'json']);
278+
279+
const filePath = join(tmpDir, TARGETS.gemini.path);
280+
const content = readFileSync(filePath, 'utf8');
281+
282+
// (a) Exactly ONE pair of sentinels
283+
const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
284+
const beginCount = (content.match(new RegExp(escRe(MANAGED_SECTION_BEGIN), 'g')) ?? []).length;
285+
const endCount = (content.match(new RegExp(escRe(MANAGED_SECTION_END), 'g')) ?? []).length;
286+
expect(beginCount, 'exactly one BEGIN sentinel').toBe(1);
287+
expect(endCount, 'exactly one END sentinel').toBe(1);
288+
289+
// BEGIN must come before END
290+
expect(content.indexOf(MANAGED_SECTION_BEGIN)).toBeLessThan(
291+
content.indexOf(MANAGED_SECTION_END),
292+
);
293+
294+
// (b) Load-bearing command strings from the compact verify body
295+
expect(content).toContain('testsprite test run');
296+
expect(content).toContain('--wait');
297+
expect(content).toContain('test artifact get');
298+
299+
// (c) Onboard one-liner must be present
300+
expect(content).toContain('First-time setup');
301+
302+
// (d) No frontmatter fence -- GEMINI.md is plain prose
303+
expect(content.startsWith('---'), 'gemini: must NOT start with ---').toBe(false);
304+
});
305+
});
269306
// ---------------------------------------------------------------------------
270307
// 3. Idempotent re-run
271308
// ---------------------------------------------------------------------------
@@ -811,7 +848,7 @@ describe('agent list', () => {
811848
}>;
812849
expect(Array.isArray(parsed)).toBe(true);
813850

814-
// Expected: 8 targets × 2 skills = 16 rows
851+
// Expected: targets x 2 skills per target = total rows (computed dynamically)
815852
const expectedCount = Object.keys(TARGETS).length * DEFAULT_SKILLS.length;
816853
expect(parsed.length).toBe(expectedCount);
817854

@@ -848,6 +885,7 @@ describe('matrix coverage guard', () => {
848885
'kiro',
849886
'windsurf',
850887
'copilot',
888+
'gemini',
851889
'codex',
852890
]);
853891
});

test/e2e/setup.e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ describe('matrix coverage guard', () => {
230230
'kiro',
231231
'windsurf',
232232
'copilot',
233+
'gemini',
233234
'codex',
234235
]);
235236
});

0 commit comments

Comments
 (0)