From 9ce0f2aba7d105182cba5af632bd92a1cab29797 Mon Sep 17 00:00:00 2001 From: Tasfia-17 Date: Sat, 11 Jul 2026 02:25:39 +0600 Subject: [PATCH 1/2] fix(init): skip API key prompt when credentials already exist Add --skip-if-configured to testsprite setup (and the deprecated init alias). When the active profile already has a saved API key and no explicit key source (--api-key or --from-env) is given, the interactive prompt is skipped and the existing key is reused. Motivation: re-running setup to refresh the agent skill -- a common pattern in dotfiles, onboarding scripts, and CI bootstraps -- currently always re-prompts for the key, even when credentials were already configured. The flag makes setup idempotent for the credential step. Behaviour: - runConfigure short-circuits with status:already_configured when skipIfConfigured is true and existingProfile.apiKey is present. - runInit relaxes the non-interactive guard and the --output json guard when skipWillApply is true, so the flag works in CI (isTTY=false) and JSON mode without requiring a separate --api-key. - --api-key always overwrites, regardless of --skip-if-configured. - --from-env always overwrites, regardless of --skip-if-configured. - --dry-run is unaffected (no network, no writes; preview only). New tests (8 cases across auth.test.ts and init.test.ts): - skips prompt and returns early when credentials exist (text + JSON) - proceeds to prompt when no credentials exist - allows isTTY=false CI runs when skip applies - --api-key overwrites despite skip flag - --from-env overwrites despite skip flag Closes #206 --- src/commands/auth.test.ts | 87 +++++++++++++++++ src/commands/auth.ts | 27 +++++- src/commands/init.test.ts | 97 +++++++++++++++++++ src/commands/init.ts | 40 +++++++- test/__snapshots__/help.snapshot.test.ts.snap | 28 +++--- 5 files changed, 261 insertions(+), 18 deletions(-) diff --git a/src/commands/auth.test.ts b/src/commands/auth.test.ts index c0b4e00..63a354d 100644 --- a/src/commands/auth.test.ts +++ b/src/commands/auth.test.ts @@ -1224,3 +1224,90 @@ describe('createAuthCommand surface', () => { expect(err.exitCode).toBe(3); }); }); + +// --------------------------------------------------------------------------- +// runConfigure -- skipIfConfigured +// --------------------------------------------------------------------------- + +describe('runConfigure -- skipIfConfigured', () => { + it('skips the prompt and returns early when credentials already exist', async () => { + const { capture, deps } = makeCapture(); + // Write a saved key first. + writeProfile('default', { apiKey: 'sk-existing' }, { path: credentialsPath }); + const prompt = { secret: vi.fn(async () => 'sk-new') }; + const fetchImpl = vi.fn(); + + await runConfigure( + { profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true }, + { + ...deps, + credentialsPath, + prompt, + fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'], + }, + ); + + // Prompt must never have fired. + expect(prompt.secret).not.toHaveBeenCalled(); + // No network call -- we never validated or wrote a key. + expect(fetchImpl).not.toHaveBeenCalled(); + // The saved key must be untouched. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-existing'); + // Output indicates already_configured. + expect(capture.stdout.join('\n')).toContain('already configured'); + }); + + it('emits already_configured status in JSON mode', async () => { + const { capture, deps } = makeCapture(); + writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath }); + const fetchImpl = vi.fn(); + + await runConfigure( + { profile: 'default', output: 'json', debug: false, fromEnv: false, skipIfConfigured: true }, + { ...deps, credentialsPath, fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'] }, + ); + + expect(fetchImpl).not.toHaveBeenCalled(); + const parsed = JSON.parse(capture.stdout.join('')); + expect(parsed).toMatchObject({ profile: 'default', status: 'already_configured' }); + }); + + it('proceeds normally when no credentials exist and skipIfConfigured is true', async () => { + const { deps } = makeCapture(); + // No pre-existing profile -- skip has no effect, should fall through to prompt. + const prompt = { secret: vi.fn(async () => 'sk-new') }; + + await runConfigure( + { profile: 'default', output: 'text', debug: false, fromEnv: false, skipIfConfigured: true }, + { ...deps, credentialsPath, prompt, fetchImpl: meOkFetch }, + ); + + expect(prompt.secret).toHaveBeenCalledTimes(1); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new'); + }); + + it('ignores skipIfConfigured when --from-env is set', async () => { + const { deps } = makeCapture(); + // Pre-existing key -- but fromEnv should override and write a new one. + writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath }); + + await runConfigure( + { + profile: 'default', + output: 'text', + debug: false, + fromEnv: true, + skipIfConfigured: true, + }, + { + ...deps, + env: { TESTSPRITE_API_KEY: 'sk-from-env' }, + credentialsPath, + fetchImpl: meOkFetch, + }, + ); + + // The env key must overwrite the saved key. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-from-env'); + }); +}); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 59cd43f..ad320ff 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -65,6 +65,17 @@ type CommonOptions = FactoryCommonOptions; interface ConfigureOptions extends CommonOptions { fromEnv: boolean; + /** + * When true and the active profile already has a saved API key, skip the + * interactive key prompt and proceed directly to the skill-install step. + * A CI-safe flag: lets `setup` run idempotently without prompting on + * machines that already have credentials (e.g. re-running setup to + * refresh the agent skill without re-entering the key). + * + * Ignored when an explicit `--api-key` or `--from-env` key source is + * provided -- those paths always overwrite, regardless of existing state. + */ + skipIfConfigured?: boolean; } const DEFAULT_API_URL = 'https://api.testsprite.com'; @@ -125,9 +136,23 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}): apiKey = env.TESTSPRITE_API_KEY?.trim(); if (!apiKey) throw validationError('TESTSPRITE_API_KEY', FROM_ENV_MISSING_KEY); } else { + // --skip-if-configured: when a non-empty API key is already saved for + // this profile, skip the interactive prompt and return early. The + // key is NOT re-validated via GET /me on this path -- the caller + // (runInit) only reaches this when no explicit key source was given, + // and the subsequent whoami call in runInit will surface an expired + // key to the user anyway. + if (opts.skipIfConfigured && existingProfile?.apiKey) { + out.print({ profile: opts.profile, apiUrl, status: 'already_configured' }, data => { + const d = data as { profile: string; apiUrl: string }; + return `Profile "${d.profile}" already configured. Endpoint: ${d.apiUrl}`; + }); + return; + } + const promptApi = deps.prompt ?? { secret: (q: string) => promptSecret(q) }; prelude(`Configuring profile "${opts.profile}".\n`); - // Only the API key is prompted — the endpoint defaults to prod (see above). + // Only the API key is prompted -- the endpoint defaults to prod (see above). apiKey = (await promptApi.secret('TestSprite API key: ')).trim(); if (!apiKey) throw new CLIError('No API key provided.', 5); } diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 617fd6f..c66be21 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, CLIError } from '../lib/errors.js'; import { resetDryRunBannerForTesting } from '../lib/client-factory.js'; +import { readProfile, writeProfile } from '../lib/credentials.js'; import type { MeResponse } from './auth.js'; import type { AgentFs } from './agent.js'; import type { InitDeps } from './init.js'; @@ -1006,3 +1007,99 @@ describe('runInit — telemetry attribution (X-CLI-Command)', () => { expect(initTagged).toHaveLength(1); }); }); + +// --------------------------------------------------------------------------- +// runInit -- skipIfConfigured +// --------------------------------------------------------------------------- + +describe('runInit -- skipIfConfigured', () => { + it('skips the API key prompt and reuses saved credentials when the profile exists', async () => { + const { captured, deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + // Write a saved key before running setup. + writeProfile('default', { apiKey: 'sk-saved' }, { path: credentialsPath }); + // Provide a mock fetch that accepts /me so runWhoami (identity banner) succeeds. + const fetchMock = makeOkFetch(); + const prompt = { secret: vi.fn(async () => 'sk-should-never-be-asked') }; + + await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), { + ...deps, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: false, + prompt, + }); + + // The prompt must never have fired. + expect(prompt.secret).not.toHaveBeenCalled(); + // The saved key must be untouched. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-saved'); + // The summary must still be emitted. + const parsed = JSON.parse(captured.stdout.join('')) as { status: string }; + expect(parsed.status).toBe('initialized'); + }); + + it('proceeds to prompt when skipIfConfigured is true but no credentials exist', async () => { + const { captured, deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + // No pre-existing credentials -- skip has no effect. + const fetchMock = makeOkFetch(); + const prompt = { secret: vi.fn(async () => 'sk-fresh') }; + + await runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'text' }), { + ...deps, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: true, + prompt, + }); + + // With no saved key, the prompt should fire. + expect(prompt.secret).toHaveBeenCalledTimes(1); + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-fresh'); + expect(captured.stdout.join('')).toContain('initialized'); + }); + + it('allows non-interactive (isTTY=false) when skipIfConfigured is true and credentials exist', async () => { + const { deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + writeProfile('default', { apiKey: 'sk-ci' }, { path: credentialsPath }); + const fetchMock = makeOkFetch(); + + // Must not throw exit 5 for "non-interactive mode, no key source". + await expect( + runInit(makeBaseOpts({ skipIfConfigured: true, noAgent: true, output: 'json' }), { + ...deps, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: false, + }), + ).resolves.toBeUndefined(); + + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-ci'); + }); + + it('--api-key takes precedence over skipIfConfigured and overwrites the saved key', async () => { + const { deps } = makeCapture(); + const { fs: agentFs } = makeMemFs(); + writeProfile('default', { apiKey: 'sk-old' }, { path: credentialsPath }); + const fetchMock = makeOkFetch(); + + await runInit( + makeBaseOpts({ apiKey: 'sk-new', skipIfConfigured: true, noAgent: true, output: 'text' }), + { + ...deps, + credentialsPath, + fetchImpl: fetchMock, + fs: agentFs, + isTTY: false, + }, + ); + + // Explicit --api-key must overwrite regardless of skipIfConfigured. + expect(readProfile('default', { path: credentialsPath })?.apiKey).toBe('sk-new'); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index c2d5678..d0db3b4 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -90,6 +90,12 @@ interface InitOptions extends CommonOptions { force: boolean; dir?: string; yes: boolean; + /** + * When true and the active profile already has a saved API key, skip the + * interactive key prompt. Forwarded verbatim to runConfigure. Has no + * effect when --api-key or --from-env is also given. + */ + skipIfConfigured?: boolean; /** Set by the command action when both --agent and --no-agent appear in rawArgs. */ rawArgConflict?: boolean; } @@ -196,9 +202,16 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise, --from-env (reads TESTSPRITE_API_KEY), or run interactively.', @@ -208,7 +221,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise or --from-env.', @@ -223,7 +236,13 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise', 'Project root for the skill install (default: current directory)') - .option('-y, --yes', 'Non-interactive: accept all defaults, never prompt'); + .option('-y, --yes', 'Non-interactive: accept all defaults, never prompt') + .option( + '--skip-if-configured', + 'Skip the API key prompt when credentials already exist for this profile (CI-safe idempotent re-run)', + ); } /** Build {@link InitOptions} from raw Commander opts + globals. */ @@ -539,6 +568,7 @@ function buildSetupOptions( force: Boolean(cmdOpts.force), dir: cmdOpts.dir, yes: Boolean(cmdOpts.yes), + skipIfConfigured: Boolean(cmdOpts.skipIfConfigured), rawArgConflict, }; } diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 62efe5d..76198ad 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -112,18 +112,22 @@ exports[`--help snapshots > init 1`] = ` (deprecated) alias for \`setup\` Options: - --api-key API key to configure (skips the interactive prompt) - --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") - --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 - directory) - -y, --yes Non-interactive: accept all defaults, never prompt - -h, --help display help for command + --api-key API key to configure (skips the interactive prompt) + --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") + --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 + directory) + -y, --yes Non-interactive: accept all defaults, never prompt + --skip-if-configured Skip the API key prompt when credentials already exist + for this profile (CI-safe idempotent re-run) + -h, --help display help for command " `; From 6eeae99b8e3772d7450f8e59a0e0516ac15c15be Mon Sep 17 00:00:00 2001 From: Tasfia-17 Date: Sat, 11 Jul 2026 03:20:36 +0600 Subject: [PATCH 2/2] 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 | 6 +-- src/lib/agent-targets.test.ts | 34 ++++++++++++-- src/lib/agent-targets.ts | 30 ++++++++++++- test/__snapshots__/help.snapshot.test.ts.snap | 18 ++++---- test/e2e/agent-install.e2e.test.ts | 44 +++++++++++++++++-- test/e2e/setup.e2e.test.ts | 1 + 7 files changed, 115 insertions(+), 22 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..2429129 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, [], ) @@ -1086,7 +1086,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 (codex, gemini): 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 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 76198ad..e4b0881 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,15 +29,15 @@ 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) --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. + is kept). For managed-section targets (codex, gemini): + replaces the section unconditionally; user content outside + the section is never destroyed. -h, --help display help for command Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug): @@ -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 @@ -684,7 +684,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', ]); });