Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/commands/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 3 additions & 3 deletions src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1072,7 +1072,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command {
)
.option(
'--target <t>',
'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)',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
collect,
[],
)
Expand All @@ -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(
Expand Down
87 changes: 87 additions & 0 deletions src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
27 changes: 26 additions & 1 deletion src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Comment on lines +145 to +151

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report the actual saved endpoint when skipping configuration.

When returning early due to --skip-if-configured, the apiUrl variable includes overrides from opts.endpointUrl and envApiUrl. Because writeProfile is intentionally skipped on this path, those overrides are never persisted to disk. Passing them to out.print creates misleading output (e.g., claiming the profile is already configured with a new endpoint that hasn't actually been saved).

Consider reporting the endpoint that is actually stored in the profile (or the default) to accurately reflect the preserved state.

💚 Proposed fix
-    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;
-    }
+    if (opts.skipIfConfigured && existingProfile?.apiKey) {
+      const activeApiUrl = existingProfile.apiUrl ?? DEFAULT_API_URL;
+      out.print({ profile: opts.profile, apiUrl: activeApiUrl, status: 'already_configured' }, data => {
+        const d = data as { profile: string; apiUrl: string };
+        return `Profile "${d.profile}" already configured. Endpoint: ${d.apiUrl}`;
+      });
+      return;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
if (opts.skipIfConfigured && existingProfile?.apiKey) {
const activeApiUrl = existingProfile.apiUrl ?? DEFAULT_API_URL;
out.print({ profile: opts.profile, apiUrl: activeApiUrl, status: 'already_configured' }, data => {
const d = data as { profile: string; apiUrl: string };
return `Profile "${d.profile}" already configured. Endpoint: ${d.apiUrl}`;
});
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/auth.ts` around lines 145 - 151, Update the early-return branch
in the skipIfConfigured flow to report the endpoint actually persisted in
existingProfile rather than the override-resolved apiUrl. Fall back to the
configured default only when the saved profile has no endpoint, while preserving
the existing already_configured output and avoiding any profile write.


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);
}
Expand Down
97 changes: 97 additions & 0 deletions src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
40 changes: 35 additions & 5 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -196,9 +202,16 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
// -------------------------------------------------------------------------
const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY);
const hasKeySource = Boolean(opts.apiKey) || opts.fromEnv;
// --skip-if-configured counts as a key source when a saved key already exists:
// runConfigure will short-circuit before prompting, so no TTY is needed.
const credentialsPath = deps.credentialsPath;
const savedKey = credentialsPath
? readProfile(opts.profile, { path: credentialsPath })?.apiKey
: readProfile(opts.profile)?.apiKey;
const skipWillApply = Boolean(opts.skipIfConfigured) && Boolean(savedKey) && !hasKeySource;
// Non-interactive guard: no TTY + no key source → exit 5. Skipped under
// --dry-run, which is documented to work without credentials or network.
if (!isTTY && !hasKeySource && !opts.dryRun) {
if (!isTTY && !hasKeySource && !skipWillApply && !opts.dryRun) {
throw new CLIError(
'No API key available in non-interactive mode. ' +
'Pass --api-key <key>, --from-env (reads TESTSPRITE_API_KEY), or run interactively.',
Expand All @@ -208,7 +221,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
// JSON-output guard: an interactive secret prompt writes to stdout and would
// corrupt init's single-JSON-object output contract. In --output json mode
// require a non-interactive key source. Skipped under --dry-run (never prompts).
if (opts.output === 'json' && !hasKeySource && !opts.dryRun) {
if (opts.output === 'json' && !hasKeySource && !skipWillApply && !opts.dryRun) {
throw new CLIError(
'Interactive API-key prompt is unavailable in --output json mode (it would corrupt JSON stdout). ' +
'Pass --api-key <key> or --from-env.',
Expand All @@ -223,7 +236,13 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
stderrFn('[dry-run] no writes or network calls — preview only');
stderrFn(
`[dry-run] would configure profile="${opts.profile}" (key source: ${
opts.apiKey ? 'flag' : opts.fromEnv ? 'env' : 'prompt'
opts.apiKey
? 'flag'
: opts.fromEnv
? 'env'
: opts.skipIfConfigured
? 'skip-if-configured'
: 'prompt'
})`,
Comment on lines +239 to 246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use skipWillApply for accurate dry-run reporting.

In --dry-run mode, the reported key source uses opts.skipIfConfigured directly. However, if the flag is passed but no saved key exists, the actual run will fall back to prompting. Using the already computed skipWillApply boolean ensures the dry-run preview accurately reflects the runtime behavior.

💚 Proposed fix
       `[dry-run] would configure profile="${opts.profile}" (key source: ${
         opts.apiKey
           ? 'flag'
           : opts.fromEnv
             ? 'env'
-            : opts.skipIfConfigured
+            : skipWillApply
               ? 'skip-if-configured'
               : 'prompt'
       })`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
opts.apiKey
? 'flag'
: opts.fromEnv
? 'env'
: opts.skipIfConfigured
? 'skip-if-configured'
: 'prompt'
})`,
opts.apiKey
? 'flag'
: opts.fromEnv
? 'env'
: skipWillApply
? 'skip-if-configured'
: 'prompt'
})`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/init.ts` around lines 239 - 246, Update the key-source reporting
expression in the init command to use the computed skipWillApply boolean instead
of opts.skipIfConfigured. Preserve the existing precedence for apiKey, fromEnv,
and prompt so dry-run output matches the runtime behavior when no saved key
exists.

);

Expand Down Expand Up @@ -265,7 +284,12 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
// force fromEnv=false so runConfigure uses the injected key (toAuthDeps wires it
// as the prompt) instead of reading TESTSPRITE_API_KEY from the environment (codex).
await runConfigure(
{ ...opts, fromEnv: opts.apiKey ? false : opts.fromEnv },
{
...opts,
fromEnv: opts.apiKey ? false : opts.fromEnv,
// --api-key always overwrites; skip only applies when no explicit key source was given.
skipIfConfigured: opts.apiKey ? false : opts.skipIfConfigured,
},
// commandTag:'init' tags ONLY this configure-validate GET /me with
// `X-CLI-Command: init` → counted as cli.initialized. The whoami banner call
// below builds deps WITHOUT a tag, so init emits exactly one cli.initialized.
Expand Down Expand Up @@ -475,6 +499,7 @@ interface SetupCmdOpts {
force?: boolean;
dir?: string;
yes?: boolean;
skipIfConfigured?: boolean;
}

/** Attach the onboarding flags shared by `setup` and the `init` alias. */
Expand All @@ -498,7 +523,11 @@ function addSetupOptions(
.option('--no-agent', 'Skip the agent skill install (configure credentials only)')
.option('--force', 'Overwrite an existing skill file (a .bak backup is kept)')
.option('--dir <path>', '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. */
Expand Down Expand Up @@ -539,6 +568,7 @@ function buildSetupOptions(
force: Boolean(cmdOpts.force),
dir: cmdOpts.dir,
yes: Boolean(cmdOpts.yes),
skipIfConfigured: Boolean(cmdOpts.skipIfConfigured),
rawArgConflict,
};
}
Expand Down
Loading
Loading