From dc3ab2d3cc7637e30dee87c2d933ee3bff60cdc4 Mon Sep 17 00:00:00 2001 From: merlinsantiago982-cmd Date: Mon, 29 Jun 2026 09:44:25 +0800 Subject: [PATCH] feat(agent): add gemini install target --- DOCUMENTATION.md | 7 ++- README.md | 4 +- src/commands/agent.test.ts | 34 ++++++++++- src/commands/agent.ts | 60 ++++++++++++------- src/lib/agent-targets.test.ts | 42 +++++++++++-- src/lib/agent-targets.ts | 53 ++++++++++++---- test/__snapshots__/help.snapshot.test.ts.snap | 13 ++-- 7 files changed, 163 insertions(+), 50 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 3746f46..833fc45 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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 `.bak` first. diff --git a/README.md b/README.md index 2d90012..7e916d8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` 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). diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index bb558f3..2fd4b76 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -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); @@ -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 @@ -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 () => { @@ -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 () => { @@ -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(); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index c27e2b8..ece7085 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -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'; // --------------------------------------------------------------------------- @@ -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`; } /** @@ -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'); @@ -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; @@ -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[] = []; @@ -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. @@ -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. @@ -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' }); @@ -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.`, ); } } @@ -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. @@ -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 @@ -713,7 +727,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { ) .option( '--target ', - '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, [], ) @@ -721,7 +735,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { .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( diff --git a/src/lib/agent-targets.test.ts b/src/lib/agent-targets.test.ts index 2a2262c..19f1899 100644 --- a/src/lib/agent-targets.test.ts +++ b/src/lib/agent-targets.test.ts @@ -60,20 +60,21 @@ testsprite test artifact get --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', () => { @@ -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'); + }); }); // --------------------------------------------------------------------------- @@ -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:'); + }); +}); diff --git a/src/lib/agent-targets.ts b/src/lib/agent-targets.ts index fb83ca2..645216e 100644 --- a/src/lib/agent-targets.ts +++ b/src/lib/agent-targets.ts @@ -1,19 +1,29 @@ import { readFileSync } from 'node:fs'; -export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex'; +export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex' | 'gemini'; + +export interface ManagedSectionSpec { + begin: string; + end: string; + /** Optional documented load budget for root instruction files such as AGENTS.md. */ + loadBudgetBytes?: number; + loadBudgetLabel?: string; +} export interface TargetSpec { status: 'ga' | 'experimental'; /** repo-relative landing path, POSIX separators */ path: string; /** - * 'own-file': the CLI owns the whole file (existing 4 targets). + * 'own-file': the CLI owns the whole file. * 'managed-section': the CLI writes only a sentinel-delimited section inside - * a potentially user-authored file (codex target, AGENTS.md). + * a potentially user-authored root instruction file. */ mode: 'own-file' | 'managed-section'; /** wrap the canonical body in this target's frontmatter/header */ wrap(body: string): string; + /** Sentinel and budget metadata for managed-section targets. */ + managedSection?: ManagedSectionSpec; } export const SKILL_NAME = 'testsprite-verify'; @@ -33,6 +43,11 @@ function wrapMdc(body: string): string { return `---\ndescription: ${SKILL_DESCRIPTION}\nalwaysApply: false\n---\n\n${body}\n`; } +/** Sentinel pair that bounds our managed section in AGENTS.md. */ +export const MANAGED_SECTION_BEGIN = + ''; +export const MANAGED_SECTION_END = ''; + export const TARGETS: Record = { claude: { status: 'ga', @@ -79,14 +94,32 @@ export const TARGETS: Record = { // wrap is a no-op for managed-section — content is authored as plain Markdown // with no frontmatter (AGENTS.md is plain prose, not a skill schema). wrap: body => body, + managedSection: { + begin: MANAGED_SECTION_BEGIN, + end: MANAGED_SECTION_END, + loadBudgetBytes: 32768, + loadBudgetLabel: 'Codex may not load content beyond its 32 KiB budget', + }, + }, + /** + * gemini target — managed-section mode. + * + * Gemini CLI reads GEMINI.md as the project instruction file. Like AGENTS.md, + * it is commonly user-authored project context, so we merge a sentinel-delimited + * section rather than owning the whole file. + */ + gemini: { + status: 'experimental', + path: 'GEMINI.md', + mode: 'managed-section', + wrap: body => body, + managedSection: { + begin: '', + end: '', + }, }, }; -/** Sentinel pair that bounds our managed section in AGENTS.md. */ -export const MANAGED_SECTION_BEGIN = - ''; -export const MANAGED_SECTION_END = ''; - type ReadFn = (url: URL) => string; const defaultRead: ReadFn = (url: URL) => readFileSync(url, 'utf8'); @@ -106,7 +139,7 @@ export function loadSkillBody(read: ReadFn = defaultRead): string { /** * Load the trimmed codex skill body (plain Markdown, no frontmatter). - * Designed for AGENTS.md managed-section injection. + * Designed for managed-section injection into root instruction files. */ export function loadCodexSkillBody(read: ReadFn = defaultRead): string { return read(new URL('../../skills/testsprite-verify.codex.md', import.meta.url)); @@ -116,7 +149,7 @@ export function loadCodexSkillBody(read: ReadFn = defaultRead): string { * Convenience for piece-2: returns the exact bytes to write for a target. * * For own-file targets, `body` defaults to the full skill body. - * For the codex managed-section target, the trimmed codex body is used instead — + * For managed-section targets, the trimmed Markdown body is used instead — * pass an explicit `body` to override in tests. */ export function renderForTarget(t: AgentTarget, body?: string): { path: string; content: string } { diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index b4ea59b..c312656 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, Codex) +Cline, Antigravity, Codex, Gemini CLI) Options: -h, --help display help for command @@ -25,11 +25,11 @@ Write the TestSprite verification-loop skill file into a project for a coding agent Options: - --target Agent target(s): claude, cursor, cline, antigravity, codex - (comma-separated or repeated) (default: []) + --target Agent target(s): claude, cursor, cline, antigravity, codex, + gemini (comma-separated or repeated) (default: []) --dir Project root to write into (default: cwd) --force For own-file targets: overwrite existing file (a .bak backup is - kept). For codex (managed-section): replaces the section + kept). For managed-section targets: replaces the section unconditionally; user content outside the section is never destroyed. -h, --help display help for command @@ -110,7 +110,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, codex (default: claude) (default: "claude") + cursor, cline, codex, gemini (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 @@ -580,7 +581,7 @@ Commands: test Inspect TestSprite tests agent Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, - Codex) + Codex, Gemini CLI) usage|credits Show credit balance and plan/entitlement info (proactive pre-flight before a large test run) help [command] display help for command