fix(cli): clear error when ktx setup has no LLM backend under --no-input (#281)

* fix(cli): fail clearly when ktx setup has no LLM backend under --no-input

Non-interactive `ktx setup` silently defaulted the LLM backend to `anthropic`
and then failed with `Missing Anthropic API key: pass --anthropic-api-key-env
or --anthropic-api-key-file` — confusing for users who selected a different
provider (e.g. `--target claude-code`) and never asked for the Anthropic API
backend.

That silent default could never succeed: it was reached only when no backend,
Anthropic key, or Vertex flag was supplied, and in exactly that case the
Anthropic credential resolver always failed (no env fallback in disabled mode).
Unlike embeddings, the LLM has no credential-free default (anthropic needs a
key, vertex needs gcloud ADC, claude-code/codex need a logged-in local CLI), so
there is nothing safe to assume.

`chooseBackend` now fails clearly in disabled mode with no backend, naming the
(hidden) `--llm-backend` flag and its choices and noting each backend's
credential needs. `--llm-backend` stays hidden in `--help`, consistent with the
rest of the documented automation surface; the error message is the discovery
path.

- Add a unit test (no backend, disabled -> clear message) and a CLI/integration
  test (`--target claude-code --no-input` -> exit 1, clear message, not the
  Anthropic red herring).
- Document the no-default behavior and add a Common-errors row in
  docs-site ktx-setup.mdx.

* refactor(cli): single source of truth for setup LLM backends

The set of LLM backends a user can pick during `ktx setup` (claude-code,
codex, anthropic, vertex) was hand-enumerated in five places: the
`--llm-backend` arg parser, the `KtxSetupLlmBackend` union, the interactive
prompt's narrowing, the prompt options, and the missing-backend error. Only
some had TypeScript coverage, so adding a backend could silently drift (e.g.
a valid value rejected by the parser, or routed to anthropic by the prompt's
`? : 'anthropic'` fallback).

Collapse them onto one `KTX_SETUP_LLM_BACKENDS` list:
- `KtxSetupLlmBackend` is derived from it.
- `isKtxSetupLlmBackend` is the shared validator; the arg parser and the
  prompt both route through it instead of re-listing literals.
- The prompt options derive from the list, with a `Record<KtxSetupLlmBackend,
  string>` label map so a new backend fails to compile until it has a label.
- The missing-backend error builds its choice list from the same source.

Behavior-preserving: identical accepted values and parse error, identical
prompt options (asserted by an existing test), and the prompt's unreachable
fallback now cancels rather than silently assuming anthropic.
This commit is contained in:
Andrey Avtomonov 2026-06-09 19:11:39 +02:00 committed by GitHub
parent 9ff0e86bb8
commit 0425160857
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 97 additions and 19 deletions

View file

@ -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, writes that backend's per-role model preset to `ktx.yaml`. To change a model,
edit the matching `llm.models.<role>` value in `ktx.yaml`. edit the matching `llm.models.<role>` 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 ### Embeddings
| Flag | Description | | 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 <path>` explicitly | | Setup resumes an unexpected project | `KTX_PROJECT_DIR` or nearest `ktx.yaml` points to another directory | Pass `--project-dir <path>` 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` | | 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 | | 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 | | 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 | | `--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 |

View file

@ -2,7 +2,7 @@ import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-
import type { KtxCliCommandContext } from '../cli-program.js'; import type { KtxCliCommandContext } from '../cli-program.js';
import { resolveCommandProjectDir } from '../cli-program.js'; import { resolveCommandProjectDir } from '../cli-program.js';
import type { KtxSetupDatabaseDriver } from '../setup-databases.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'; import type { KtxSetupSourceType } from '../setup-sources.js';
async function runSetupArgs( async function runSetupArgs(
@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
} }
function llmBackend(value: string): KtxSetupLlmBackend { function llmBackend(value: string): KtxSetupLlmBackend {
if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') { if (isKtxSetupLlmBackend(value)) {
return value; return value;
} }
throw new InvalidArgumentError(`invalid choice '${value}'`); throw new InvalidArgumentError(`invalid choice '${value}'`);

View file

@ -51,7 +51,27 @@ export type KtxSetupModelResult =
| { status: 'missing-input'; projectDir: string } | { status: 'missing-input'; projectDir: string }
| { status: 'failed'; 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<KtxSetupLlmBackend, string> = {
'claude-code': 'Claude subscription (Pro/Max)',
codex: 'Codex subscription',
anthropic: 'Anthropic API key',
vertex: 'Google Vertex AI for Anthropic Claude',
};
/** @internal */ /** @internal */
export interface KtxSetupModelPromptAdapter { export interface KtxSetupModelPromptAdapter {
@ -135,7 +155,18 @@ const execFileAsync = promisify(execFile);
type ChooseBackendResult = type ChooseBackendResult =
| { status: 'ready'; backend: KtxSetupLlmBackend; prompted: boolean } | { 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 = type VertexConfigChoice =
| { | {
@ -446,7 +477,8 @@ async function chooseBackend(
return { status: 'ready', backend: explicit, prompted: false }; return { status: 'ready', backend: explicit, prompted: false };
} }
if (args.inputMode === 'disabled') { 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(); const prompts = deps.prompts ?? createPromptAdapter();
@ -458,21 +490,20 @@ async function chooseBackend(
const choice = await prompts.select({ const choice = await prompts.select({
message: 'Which LLM provider should KTX use?', message: 'Which LLM provider should KTX use?',
options: [ options: [
{ value: 'claude-code', label: 'Claude subscription (Pro/Max)' }, ...KTX_SETUP_LLM_BACKENDS.map((backend) => ({ value: backend, label: KTX_SETUP_LLM_BACKEND_LABELS[backend] })),
{ value: 'codex', label: 'Codex subscription' },
{ value: 'anthropic', label: 'Anthropic API key' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'back', label: 'Back' }, { value: 'back', label: 'Back' },
], ],
}); });
if (choice === 'back') { if (choice === 'back') {
return { status: 'back' }; return { status: 'back' };
} }
return { if (isKtxSetupLlmBackend(choice)) {
status: 'ready', return { status: 'ready', backend: choice, prompted: true };
backend: choice === 'vertex' || choice === 'claude-code' || choice === 'codex' ? choice : 'anthropic', }
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( function resolveProvidedVertexRef(

View file

@ -814,7 +814,7 @@ describe('setup Anthropic model step', () => {
expect(io.stderr()).toContain(`Missing Anthropic API key file: ${missingSecretPath}`); 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 io = makeIo();
const result = await runKtxSetupAnthropicModelStep( const result = await runKtxSetupAnthropicModelStep(
@ -823,10 +823,17 @@ describe('setup Anthropic model step', () => {
); );
expect(result.status).toBe('missing-input'); expect(result.status).toBe('missing-input');
expect(io.stderr()).toContain( const stderr = io.stderr();
'Missing Anthropic API key: pass --anthropic-api-key-env or --anthropic-api-key-file.', expect(stderr).toContain('Missing LLM backend: pass --llm-backend');
); // Names every backend so the user can choose without reading hidden --help flags.
expect(io.stderr()).not.toContain('--skip-llm'); 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 () => { it('writes pasted keys to .ktx/secrets and never prints the key', async () => {

View file

@ -664,6 +664,38 @@ describe('setup status', () => {
expect(testIo.stderr()).toBe(''); 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 () => { it('preserves a newly created missing project directory when a later setup step fails', async () => {
const projectDir = join(tempDir, 'missing-project'); const projectDir = join(tempDir, 'missing-project');
const testIo = makeIo(); const testIo = makeIo();