From 54df468fbce90c547a37669d72e97e2a25958d4e Mon Sep 17 00:00:00 2001 From: Tasfia-17 Date: Sat, 11 Jul 2026 03:20:36 +0600 Subject: [PATCH] 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 --- src/commands/agent.test.ts | 4 +- src/commands/agent.ts | 4 +- src/lib/agent-targets.test.ts | 34 ++++++++++++-- src/lib/agent-targets.ts | 30 ++++++++++++- test/__snapshots__/help.snapshot.test.ts.snap | 12 ++--- test/e2e/agent-install.e2e.test.ts | 44 +++++++++++++++++-- test/e2e/setup.e2e.test.ts | 1 + 7 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 24d8ec6..0c34332 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -779,8 +779,8 @@ describe('runList', () => { const json = JSON.parse(capture.stdout.join('\n')) as ListResult[]; expect(Array.isArray(json)).toBe(true); - // 8 targets × 2 default skills = 16 rows - expect(json).toHaveLength(16); + // 9 targets x 2 default skills = 18 rows + expect(json).toHaveLength(18); const targets = json.map(r => r.target); expect(targets).toContain('claude'); expect(targets).toContain('cursor'); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 7d7e7c8..c61fba9 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1062,7 +1062,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, Kiro, Windsurf, Copilot, Codex)', + 'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Kiro, Windsurf, Copilot, Gemini, Codex)', ); agent @@ -1072,7 +1072,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { ) .option( '--target ', - 'Agent target(s): claude, cursor, cline, antigravity, kiro, windsurf, copilot, codex (comma-separated or repeated)', + 'Agent target(s): claude, cursor, cline, antigravity, kiro, windsurf, copilot, gemini, codex (comma-separated or repeated)', collect, [], ) diff --git a/src/lib/agent-targets.test.ts b/src/lib/agent-targets.test.ts index 2ac9158..8d6e8c0 100644 --- a/src/lib/agent-targets.test.ts +++ b/src/lib/agent-targets.test.ts @@ -82,7 +82,7 @@ testsprite test artifact get --out ./out/ // --------------------------------------------------------------------------- describe('TARGETS', () => { - it('has all eight required keys', () => { + it('has all nine required keys', () => { const keys = Object.keys(TARGETS).sort(); expect(keys).toEqual([ 'antigravity', @@ -91,6 +91,7 @@ describe('TARGETS', () => { 'codex', 'copilot', 'cursor', + 'gemini', 'kiro', 'windsurf', ]); @@ -100,11 +101,12 @@ describe('TARGETS', () => { expect(TARGETS.claude.status).toBe('ga'); }); - it('cursor, cline, windsurf, copilot, antigravity, kiro, and codex are experimental', () => { + it('cursor, cline, windsurf, copilot, gemini, antigravity, kiro, and codex are experimental', () => { expect(TARGETS.cursor.status).toBe('experimental'); expect(TARGETS.cline.status).toBe('experimental'); expect(TARGETS.windsurf.status).toBe('experimental'); expect(TARGETS.copilot.status).toBe('experimental'); + expect(TARGETS.gemini.status).toBe('experimental'); expect(TARGETS.antigravity.status).toBe('experimental'); expect(TARGETS.kiro.status).toBe('experimental'); expect(TARGETS.codex.status).toBe('experimental'); @@ -127,8 +129,9 @@ describe('TARGETS', () => { expect(TARGETS.copilot.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', () => { @@ -386,6 +389,31 @@ describe('renderForTarget("copilot")', () => { }); }); +describe('renderForTarget("gemini")', () => { + const result = renderForTarget('gemini', 'testsprite-verify', STUB_BODY); + + it('returns GEMINI.md as the landing path', () => { + expect(result.path).toBe('GEMINI.md'); + }); + + it('does not emit YAML frontmatter (plain Markdown, no --- fence)', () => { + expect(result.content.startsWith('---\n')).toBe(false); + expect(result.content).not.toContain('name:'); + expect(result.content).not.toContain('applyTo:'); + expect(result.content).not.toContain('trigger:'); + }); + + it('contains the stub body verbatim', () => { + expect(result.content).toContain(STUB_BODY); + }); + + it('renders the verify body content (managed-section, compact codex body)', () => { + // Uses real bodies: gemini uses the codex contribution body (compact). + const gemini = renderForTarget('gemini', 'testsprite-verify'); + expect(gemini.content).toContain('testsprite test run'); + }); +}); + // --------------------------------------------------------------------------- // Content integrity — load-bearing command strings must survive any body trim // --------------------------------------------------------------------------- diff --git a/src/lib/agent-targets.ts b/src/lib/agent-targets.ts index 7e6f94d..e7eeae8 100644 --- a/src/lib/agent-targets.ts +++ b/src/lib/agent-targets.ts @@ -10,7 +10,8 @@ export type AgentTarget = | 'codex' | 'kiro' | 'windsurf' - | 'copilot'; + | 'copilot' + | 'gemini'; export interface TargetSpec { status: 'ga' | 'experimental'; @@ -189,6 +190,8 @@ export function pathFor(target: AgentTarget, skill: string): string { return `.windsurf/rules/${skill}.md`; case 'copilot': return `.github/instructions/${skill}.instructions.md`; + case 'gemini': + return 'GEMINI.md'; case 'codex': return 'AGENTS.md'; } @@ -243,11 +246,34 @@ export const TARGETS: Record = { // GitHub Copilot path-specific instructions: frontmatter carries `applyTo`. // `applyTo: '**'` means the file is ALWAYS injected into Copilot requests // (there is no on-demand "model decides" mode like Cursor/Windsurf), so - // render the compact body to keep the always-on context cost small — the + // render the compact body to keep the always-on context cost small -- the // same reasoning that drives windsurf's compact render. compactBody: true, wrap: wrapCopilot, }, + /** + * gemini target -- managed-section mode (GEMINI.md). + * + * Gemini CLI reads project-level instructions from a `GEMINI.md` file in + * the repo root. The file is plain Markdown, loaded at the start of every + * session (always-on). Because all installed skills share this single file + * we use managed-section mode -- the same approach as codex/AGENTS.md -- + * writing only a sentinel-delimited section so any existing user content + * in GEMINI.md is never clobbered. + * + * The compact body is used (same reasoning as windsurf/copilot): GEMINI.md + * is always-injected, so keeping it small reduces context cost. + * + * --force replaces the managed section unconditionally but never touches + * content outside the sentinels. + */ + gemini: { + status: 'experimental', + path: pathFor('gemini', SKILL_NAME), + mode: 'managed-section', + // GEMINI.md is plain Markdown; wrap is a no-op (no frontmatter). + wrap: (_name, _description, body) => body, + }, /** * codex target — managed-section mode. * diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 62efe5d..c14b5ee 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -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, Kiro, Windsurf, Copilot, Codex) +Cline, Antigravity, Kiro, Windsurf, Copilot, Gemini, Codex) Options: -h, --help display help for command @@ -29,8 +29,8 @@ into a project for a coding agent Options: --target Agent target(s): claude, cursor, cline, antigravity, kiro, - windsurf, copilot, codex (comma-separated or repeated) - (default: []) + windsurf, copilot, gemini, codex (comma-separated or + repeated) (default: []) --skill Skill(s) to install: testsprite-verify, testsprite-onboard (comma-separated or repeated; default: all) (default: []) --dir Project root to write into (default: cwd) @@ -116,8 +116,8 @@ Options: --from-env Read TESTSPRITE_API_KEY from the environment instead of prompting (default: false) --agent Coding-agent target to install: claude, antigravity, - cursor, cline, kiro, windsurf, copilot, codex (default: - claude) (default: "claude") + cursor, cline, kiro, windsurf, copilot, gemini, 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 Project root for the skill install (default: current @@ -680,7 +680,7 @@ Commands: test Inspect TestSprite tests agent Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, - Kiro, Windsurf, Copilot, Codex) + Kiro, Windsurf, Copilot, Gemini, Codex) usage|credits Show credit balance and plan/entitlement info (proactive pre-flight before a large test run) doctor Diagnose CLI setup: version, Node, profile, diff --git a/test/e2e/agent-install.e2e.test.ts b/test/e2e/agent-install.e2e.test.ts index 4fb4584..3f9ad3c 100644 --- a/test/e2e/agent-install.e2e.test.ts +++ b/test/e2e/agent-install.e2e.test.ts @@ -179,8 +179,12 @@ describe('content integrity', () => { expect(content.startsWith('---'), `copilot: should start with ---`).toBe(true); expect(content).toContain("applyTo: '**'"); expect(content).toContain('description:'); + } else if (target === 'gemini') { + // GEMINI.md is plain Markdown -- no frontmatter expected. + expect(content.startsWith('---'), `gemini: must NOT start with ---`).toBe(false); + expect(content).not.toContain('applyTo:'); + expect(content).not.toContain('trigger:'); } - // (b) branding — the renamed H1 must be present in every body variant expect(content).toContain('TestSprite Verification Loop'); // The full-body intro line lives only in the FULL body; compact-body targets @@ -219,6 +223,9 @@ describe('content integrity', () => { } else if (target === 'copilot') { expect(content.startsWith('---'), `copilot/onboard: should start with ---`).toBe(true); expect(content).toContain("applyTo: '**'"); + } else if (target === 'gemini') { + // GEMINI.md is plain Markdown -- no frontmatter. + expect(content.startsWith('---'), `gemini/onboard: must NOT start with ---`).toBe(false); } // Load-bearing onboard string: the skill body must reference setup @@ -264,8 +271,38 @@ describe('content integrity', () => { // (d) No frontmatter fence — AGENTS.md is plain prose expect(content.startsWith('---'), 'codex: must NOT start with ---').toBe(false); }); -}); + it('gemini GEMINI.md contains ONE managed section with verify and onboard content', () => { + const tmpDir = freshTmpDir(); + runCli(['agent', 'install', '--target=gemini', '--dir', tmpDir, '--output', 'json']); + + const filePath = join(tmpDir, TARGETS.gemini.path); + const content = readFileSync(filePath, 'utf8'); + + // (a) Exactly ONE pair of sentinels + const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const beginCount = (content.match(new RegExp(escRe(MANAGED_SECTION_BEGIN), 'g')) ?? []).length; + const endCount = (content.match(new RegExp(escRe(MANAGED_SECTION_END), 'g')) ?? []).length; + expect(beginCount, 'exactly one BEGIN sentinel').toBe(1); + expect(endCount, 'exactly one END sentinel').toBe(1); + + // BEGIN must come before END + expect(content.indexOf(MANAGED_SECTION_BEGIN)).toBeLessThan( + content.indexOf(MANAGED_SECTION_END), + ); + + // (b) Load-bearing command strings from the compact verify body + expect(content).toContain('testsprite test run'); + expect(content).toContain('--wait'); + expect(content).toContain('test artifact get'); + + // (c) Onboard one-liner must be present + expect(content).toContain('First-time setup'); + + // (d) No frontmatter fence -- GEMINI.md is plain prose + expect(content.startsWith('---'), 'gemini: must NOT start with ---').toBe(false); + }); +}); // --------------------------------------------------------------------------- // 3. Idempotent re-run // --------------------------------------------------------------------------- @@ -811,7 +848,7 @@ describe('agent list', () => { }>; expect(Array.isArray(parsed)).toBe(true); - // Expected: 8 targets × 2 skills = 16 rows + // Expected: targets x 2 skills per target = total rows (computed dynamically) const expectedCount = Object.keys(TARGETS).length * DEFAULT_SKILLS.length; expect(parsed.length).toBe(expectedCount); @@ -848,6 +885,7 @@ describe('matrix coverage guard', () => { 'kiro', 'windsurf', 'copilot', + 'gemini', 'codex', ]); }); diff --git a/test/e2e/setup.e2e.test.ts b/test/e2e/setup.e2e.test.ts index 5a07434..3ad17cb 100644 --- a/test/e2e/setup.e2e.test.ts +++ b/test/e2e/setup.e2e.test.ts @@ -230,6 +230,7 @@ describe('matrix coverage guard', () => { 'kiro', 'windsurf', 'copilot', + 'gemini', 'codex', ]); });