diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 8ae4469d..f75fdf46 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -67,6 +67,13 @@ of Anthropic API key or Vertex flags. After you choose a backend, `ktx setup` writes that backend's per-role model preset to `ktx.yaml`. To change a model, edit the matching `llm.models.` value in `ktx.yaml`. +With `--no-input`, `ktx setup` does not assume a default LLM provider, because +every backend needs credentials only you can supply. Pass `--llm-backend` +explicitly. Note that `--target` selects the agent integration, not the LLM +provider: `ktx setup --target claude-code --no-input` still needs +`--llm-backend claude-code` to use your Claude subscription for **ktx** LLM +calls. + ### Embeddings | Flag | Description | @@ -276,6 +283,7 @@ Use `ktx status` for repeatable readiness checks after setup exits. |-------|-------|----------| | Setup resumes an unexpected project | `KTX_PROJECT_DIR` or nearest `ktx.yaml` points to another directory | Pass `--project-dir ` explicitly | | Setup cannot run in CI | Required values are missing and `--no-input` disables prompts | Provide the relevant automation flags or create a fixture `ktx.yaml` | +| `Missing LLM backend: pass --llm-backend …` | `--no-input` setup ran without an LLM backend; `--target` does not select one | Pass `--llm-backend claude-code`, `codex`, `anthropic`, or `vertex` (with that backend's credential flags) | | Provider health check fails | Provider key, model id, Vertex project, or Vertex location is invalid | Fix the `env:` or `file:` reference and rerun setup | | Python runtime is missing | The selected setup needs runtime-backed agent, query-history, Looker, or local embedding features | Accept the interactive prompt, rerun with `--yes`, or run the suggested `ktx admin runtime install` command | | `--enable-query-history` is rejected | The selected database driver does not support query history | Use Postgres, BigQuery, or Snowflake, or rerun without query-history flags | diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 418b27f9..e8510210 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -2,7 +2,7 @@ import { type Command, InvalidArgumentError, Option } from '@commander-js/extra- import type { KtxCliCommandContext } from '../cli-program.js'; import { resolveCommandProjectDir } from '../cli-program.js'; import type { KtxSetupDatabaseDriver } from '../setup-databases.js'; -import type { KtxSetupLlmBackend } from '../setup-models.js'; +import { isKtxSetupLlmBackend, type KtxSetupLlmBackend } from '../setup-models.js'; import type { KtxSetupSourceType } from '../setup-sources.js'; async function runSetupArgs( @@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' { } function llmBackend(value: string): KtxSetupLlmBackend { - if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') { + if (isKtxSetupLlmBackend(value)) { return value; } throw new InvalidArgumentError(`invalid choice '${value}'`); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 911579a9..fbbabbdb 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -51,7 +51,27 @@ export type KtxSetupModelResult = | { status: 'missing-input'; projectDir: string } | { status: 'failed'; projectDir: string }; -export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code' | 'codex'; +// Single source of truth for the LLM backends a user can pick during setup. +// The CLI arg parser, the interactive prompt, and the missing-backend error all +// derive from this list, so adding a backend is one edit. Order is the prompt's +// preference order (subscription backends first). +const KTX_SETUP_LLM_BACKENDS = ['claude-code', 'codex', 'anthropic', 'vertex'] as const; +export type KtxSetupLlmBackend = (typeof KTX_SETUP_LLM_BACKENDS)[number]; + +/** Validates a raw CLI or prompt value against the setup-selectable LLM backends. */ +export function isKtxSetupLlmBackend(value: string): value is KtxSetupLlmBackend { + return KTX_SETUP_LLM_BACKENDS.some((backend) => backend === value); +} + +// Display labels for the interactive provider prompt. The Record key type forces +// every backend to carry a label, so adding one to KTX_SETUP_LLM_BACKENDS fails +// to compile until its prompt option exists here. +const KTX_SETUP_LLM_BACKEND_LABELS: Record = { + 'claude-code': 'Claude subscription (Pro/Max)', + codex: 'Codex subscription', + anthropic: 'Anthropic API key', + vertex: 'Google Vertex AI for Anthropic Claude', +}; /** @internal */ export interface KtxSetupModelPromptAdapter { @@ -135,7 +155,18 @@ const execFileAsync = promisify(execFile); type ChooseBackendResult = | { status: 'ready'; backend: KtxSetupLlmBackend; prompted: boolean } - | { status: 'back' }; + | { status: 'back' } + | { status: 'missing-input' }; + +// Non-interactive setup cannot pick a provider safely: every backend needs +// something the user must supply (an API key, gcloud ADC, or a logged-in local +// CLI), so there is no credential-free default to fall back to. Name the hidden +// --llm-backend flag and its choices here instead, mirroring how the other +// automation errors guide users to the flag they need. +const MISSING_LLM_BACKEND_MESSAGE = + `Missing LLM backend: pass --llm-backend with one of ${KTX_SETUP_LLM_BACKENDS.join(', ')}. ` + + 'claude-code and codex use local CLI authentication; anthropic also needs --anthropic-api-key-env or ' + + '--anthropic-api-key-file, and vertex also needs --vertex-project.'; type VertexConfigChoice = | { @@ -446,7 +477,8 @@ async function chooseBackend( return { status: 'ready', backend: explicit, prompted: false }; } if (args.inputMode === 'disabled') { - return { status: 'ready', backend: 'anthropic', prompted: false }; + io.stderr.write(`${MISSING_LLM_BACKEND_MESSAGE}\n`); + return { status: 'missing-input' }; } const prompts = deps.prompts ?? createPromptAdapter(); @@ -458,21 +490,20 @@ async function chooseBackend( const choice = await prompts.select({ message: 'Which LLM provider should KTX use?', options: [ - { value: 'claude-code', label: 'Claude subscription (Pro/Max)' }, - { value: 'codex', label: 'Codex subscription' }, - { value: 'anthropic', label: 'Anthropic API key' }, - { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, + ...KTX_SETUP_LLM_BACKENDS.map((backend) => ({ value: backend, label: KTX_SETUP_LLM_BACKEND_LABELS[backend] })), { value: 'back', label: 'Back' }, ], }); if (choice === 'back') { return { status: 'back' }; } - return { - status: 'ready', - backend: choice === 'vertex' || choice === 'claude-code' || choice === 'codex' ? choice : 'anthropic', - prompted: true, - }; + if (isKtxSetupLlmBackend(choice)) { + return { status: 'ready', backend: choice, prompted: true }; + } + // Options are derived from KTX_SETUP_LLM_BACKENDS, so the only other value is + // 'back' (handled above). Treat any unexpected value as a cancel rather than + // silently assuming a provider. + return { status: 'back' }; } function resolveProvidedVertexRef( diff --git a/packages/cli/test/setup-models.test.ts b/packages/cli/test/setup-models.test.ts index f09691e0..ba5ce21f 100644 --- a/packages/cli/test/setup-models.test.ts +++ b/packages/cli/test/setup-models.test.ts @@ -814,7 +814,7 @@ describe('setup Anthropic model step', () => { expect(io.stderr()).toContain(`Missing Anthropic API key file: ${missingSecretPath}`); }); - it('does not recommend skipping when non-interactive setup is missing an Anthropic credential source', async () => { + it('fails clearly when non-interactive setup has no LLM backend instead of assuming Anthropic', async () => { const io = makeIo(); const result = await runKtxSetupAnthropicModelStep( @@ -823,10 +823,17 @@ describe('setup Anthropic model step', () => { ); expect(result.status).toBe('missing-input'); - expect(io.stderr()).toContain( - 'Missing Anthropic API key: pass --anthropic-api-key-env or --anthropic-api-key-file.', - ); - expect(io.stderr()).not.toContain('--skip-llm'); + const stderr = io.stderr(); + expect(stderr).toContain('Missing LLM backend: pass --llm-backend'); + // Names every backend so the user can choose without reading hidden --help flags. + expect(stderr).toContain('claude-code'); + expect(stderr).toContain('codex'); + expect(stderr).toContain('anthropic'); + expect(stderr).toContain('vertex'); + // Does not mislead with an Anthropic-key error the user never opted into. + expect(stderr).not.toContain('Missing Anthropic API key'); + // Does not nudge users to skip the LLM. + expect(stderr).not.toContain('--skip-llm'); }); it('writes pasted keys to .ktx/secrets and never prints the key', async () => { diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index 04ca32d1..546136fa 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -664,6 +664,38 @@ describe('setup status', () => { expect(testIo.stderr()).toBe(''); }); + it('fails clearly when a non-interactive run has an agent target but no LLM backend', async () => { + const testIo = makeIo(); + + // --target selects agent integration, not the LLM provider. A non-interactive + // run with no --llm-backend must say so plainly instead of assuming Anthropic. + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'auto', + agents: false, + target: 'claude-code', + skipAgents: true, + inputMode: 'disabled', + yes: true, + cliVersion: '0.2.0', + skipLlm: false, + skipEmbeddings: true, + skipDatabases: true, + skipSources: true, + databaseSchemas: [], + }, + testIo.io, + ), + ).resolves.toBe(1); + + const stderr = testIo.stderr(); + expect(stderr).toContain('Missing LLM backend: pass --llm-backend'); + expect(stderr).not.toContain('Missing Anthropic API key'); + }); + it('preserves a newly created missing project directory when a later setup step fails', async () => { const projectDir = join(tempDir, 'missing-project'); const testIo = makeIo();