diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index a6f2d588..b4f50ea9 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -53,9 +53,10 @@ scripted project creation. They are not shown in `ktx setup --help`. |------|-------------| | `--llm-backend ` | LLM backend: `anthropic`, `vertex`, or `claude-code` | | `--llm-backend claude-code` | Use the local Claude Code session for KTX LLM calls | +| `--llm-model ` | LLM model ID or backend model alias to validate and save | | `--anthropic-api-key-env ` | Environment variable containing the Anthropic API key | | `--anthropic-api-key-file ` | File containing the Anthropic API key | -| `--anthropic-model ` | Anthropic model ID to validate and save | +| `--anthropic-model ` | Legacy alias for `--llm-model` | | `--vertex-project ` | Vertex AI project ID, `env:NAME`, or `file:/path` reference | | `--vertex-location ` | Vertex AI location, `env:NAME`, or `file:/path` reference | | `--skip-llm` | Leave LLM setup incomplete | @@ -63,7 +64,8 @@ scripted project creation. They are not shown in `ktx setup --help`. Choose only one Anthropic credential source. Anthropic credential flags are only valid with the Anthropic backend; Vertex flags are only valid with the Vertex backend. The `claude-code` backend uses local Claude Code authentication instead -of Anthropic API key or Vertex flags. +of Anthropic API key or Vertex flags. For Claude Code, `--llm-model` accepts +`sonnet`, `opus`, `haiku`, or a full Claude model ID. ### Embeddings @@ -144,6 +146,12 @@ ktx setup # Run setup for a specific project directory ktx setup --project-dir ./analytics +# Use Claude Code with Opus for KTX LLM calls +ktx setup \ + --project-dir ./analytics \ + --llm-backend claude-code \ + --llm-model opus + # Script a Postgres connection that reads its URL from the environment ktx setup \ --project-dir ./analytics \ diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index b65b1476..09957e70 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -92,7 +92,9 @@ llm: `claude-code` uses the Claude Code authentication already configured on your machine. It doesn't use `ANTHROPIC_API_KEY`, Vertex credentials, AI Gateway -tokens, or Bedrock credentials. +tokens, or Bedrock credentials. In non-interactive setup, pass +`--llm-model opus`, `--llm-model sonnet`, `--llm-model haiku`, or a full Claude +model ID to select the Claude Code model. Setup checks the selected model before saving. Anthropic API setup fetches live Claude model choices when possible and falls back to bundled defaults if model diff --git a/docs-site/content/docs/guides/llm-configuration.mdx b/docs-site/content/docs/guides/llm-configuration.mdx index 5b7a4a72..054d0b58 100644 --- a/docs-site/content/docs/guides/llm-configuration.mdx +++ b/docs-site/content/docs/guides/llm-configuration.mdx @@ -21,7 +21,7 @@ Set `llm.provider.backend` to one of these values: ## Claude Code -Use aliases or full Claude model ids in `llm.models`: +Use aliases or full Claude model IDs in `llm.models`: ```yaml llm: @@ -36,6 +36,16 @@ llm: repair: sonnet ``` +During setup, choose the Claude Code backend interactively or pass the model in +automation: + +```bash +ktx setup --llm-backend claude-code --llm-model opus --no-input +``` + +For Claude Code, `sonnet`, `opus`, and `haiku` map to the current KTX defaults. +You can also pass a full Claude model ID, such as `claude-opus-4-7`. + `claude-code` keeps KTX tool boundaries intact. KTX exposes only the MCP tools needed for the current KTX agent loop, disables Claude Code built-in tools, keeps plugins empty, and denies every non-KTX tool request through diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 0bdb6c6b..508e9a9a 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -97,6 +97,7 @@ function shouldShowSetupEntryMenu( llmBackend?: KtxSetupLlmBackend; anthropicApiKeyEnv?: string; anthropicApiKeyFile?: string; + llmModel?: string; anthropicModel?: string; vertexProject?: string; vertexLocation?: string; @@ -171,6 +172,7 @@ function shouldShowSetupEntryMenu( 'llmBackend', 'anthropicApiKeyEnv', 'anthropicApiKeyFile', + 'llmModel', 'anthropicModel', 'vertexProject', 'vertexLocation', @@ -236,6 +238,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo .addOption( new Option('--anthropic-api-key-file ', 'File containing the Anthropic API key').hideHelp(), ) + .addOption(new Option('--llm-model ', 'LLM model ID or backend model alias').hideHelp()) .addOption(new Option('--anthropic-model ', 'Anthropic model ID to validate and save').hideHelp()) .addOption(new Option('--vertex-project ', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp()) .addOption(new Option('--vertex-location ', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp()) @@ -361,6 +364,11 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo context.setExitCode(1); return; } + if (options.llmModel && options.anthropicModel) { + context.io.stderr.write('Choose only one LLM model flag: --llm-model or --anthropic-model.\n'); + context.setExitCode(1); + return; + } if ( options.llmBackend && options.llmBackend !== 'anthropic' && @@ -426,6 +434,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo ...(options.llmBackend ? { llmBackend: options.llmBackend } : {}), ...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}), ...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}), + ...(options.llmModel ? { llmModel: options.llmModel } : {}), ...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}), ...(options.vertexProject ? { vertexProject: options.vertexProject } : {}), ...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}), diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 9c0a94fb..1d317b03 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1074,6 +1074,41 @@ describe('runKtxCli', () => { ); }); + it('dispatches the provider-neutral LLM model setup flag to the setup runner', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + [ + '--project-dir', + tempDir, + 'setup', + '--no-input', + '--llm-backend', + 'claude-code', + '--llm-model', + 'opus', + ], + setupIo.io, + { setup }, + ), + ).resolves.toBe(0); + + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'run', + projectDir: tempDir, + inputMode: 'disabled', + cliVersion: '0.0.0-private', + llmBackend: 'claude-code', + llmModel: 'opus', + skipLlm: false, + }), + setupIo.io, + ); + }); + it('rejects conflicting Anthropic credential setup flags', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index 23a7277a..e997eb82 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -209,6 +209,38 @@ describe('setup Anthropic model step', () => { expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'sonnet' })); }); + it('prompts for the Claude Code model during interactive setup', async () => { + const io = makeIo(); + const prompts = makePromptAdapter({ selectValues: ['claude-code', 'opus'] }); + const authProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { projectDir: tempDir, inputMode: 'auto', skipLlm: false }, + io.io, + { prompts, claudeCodeAuthProbe: authProbe }, + ); + + expect(result.status).toBe('ready'); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Which Claude Code model should KTX use?'), + options: [ + { value: 'sonnet', label: 'Claude Sonnet', hint: 'recommended' }, + { value: 'opus', label: 'Claude Opus' }, + { value: 'haiku', label: 'Claude Haiku' }, + { value: 'manual', label: 'Enter a Claude Code model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }), + ); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.llm).toMatchObject({ + provider: { backend: 'claude-code' }, + models: { default: 'opus' }, + }); + expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'opus' })); + }); + it('warns during Claude Code setup when existing prompt-caching fields will be ignored', async () => { await writeFile( join(tempDir, 'ktx.yaml'), @@ -716,7 +748,7 @@ describe('setup Anthropic model step', () => { expect(io.stderr()).not.toContain('--skip-llm'); }); - it('does not recommend skipping when non-interactive setup is missing an Anthropic model', async () => { + it('does not recommend skipping when non-interactive setup is missing an LLM model', async () => { const io = makeIo(); const healthCheck = vi.fn(async () => ({ ok: true as const })); @@ -733,7 +765,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('missing-input'); expect(healthCheck).not.toHaveBeenCalled(); - expect(io.stderr()).toContain('Missing Anthropic model: pass --anthropic-model.'); + expect(io.stderr()).toContain('Missing LLM model: pass --llm-model.'); expect(io.stderr()).not.toContain('--skip-llm'); }); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 5232f4de..e8727f47 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -36,6 +36,7 @@ export interface KtxSetupModelArgs { llmBackend?: KtxSetupLlmBackend; anthropicApiKeyEnv?: string; anthropicApiKeyFile?: string; + llmModel?: string; anthropicModel?: string; vertexProject?: string; vertexLocation?: string; @@ -100,6 +101,12 @@ const VERTEX_ANTHROPIC_MODELS: AnthropicModelChoice[] = [ { id: 'claude-opus-4-1', label: 'Claude Opus 4.1', recommended: false }, ]; +const CLAUDE_CODE_MODELS: AnthropicModelChoice[] = [ + { id: 'sonnet', label: 'Claude Sonnet', recommended: true }, + { id: 'opus', label: 'Claude Opus', recommended: false }, + { id: 'haiku', label: 'Claude Haiku', recommended: false }, +]; + const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [ /^claude-sonnet-4$/i, /^claude-opus-4$/i, @@ -471,12 +478,16 @@ function requestedBackend(args: KtxSetupModelArgs): KtxSetupLlmBackend | undefin if (args.vertexProject || args.vertexLocation) { return 'vertex'; } - if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.anthropicModel) { + if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel || args.anthropicModel) { return 'anthropic'; } return undefined; } +function requestedModel(args: KtxSetupModelArgs): string | undefined { + return args.llmModel ?? args.anthropicModel; +} + async function chooseBackend( args: KtxSetupModelArgs, io: KtxCliIo, @@ -731,11 +742,12 @@ async function chooseModel( io: KtxCliIo, deps: KtxSetupModelDeps, ): Promise { - if (args.anthropicModel) { - return { status: 'ready', model: args.anthropicModel }; + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; } if (args.inputMode === 'disabled') { - io.stderr.write('Missing Anthropic model: pass --anthropic-model.\n'); + io.stderr.write('Missing LLM model: pass --llm-model.\n'); return { status: 'missing-input' }; } @@ -788,11 +800,12 @@ async function chooseModel( } async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: KtxSetupModelDeps): Promise { - if (args.anthropicModel) { - return { status: 'ready', model: args.anthropicModel }; + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; } if (args.inputMode === 'disabled') { - io.stderr.write('Missing Anthropic model: pass --anthropic-model.\n'); + io.stderr.write('Missing LLM model: pass --llm-model.\n'); return { status: 'missing-input' }; } @@ -826,6 +839,44 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt return { status: 'ready', model: choice }; } +async function chooseClaudeCodeModel(args: KtxSetupModelArgs, deps: KtxSetupModelDeps): Promise { + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; + } + if (args.inputMode === 'disabled') { + return { status: 'ready', model: 'sonnet' }; + } + + const prompts = deps.prompts ?? createPromptAdapter(); + const choice = await prompts.select({ + message: `Which Claude Code model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`, + options: [ + ...CLAUDE_CODE_MODELS.map((model) => ({ + value: model.id, + label: model.label, + ...(model.recommended ? { hint: 'recommended' } : {}), + })), + { value: 'manual', label: 'Enter a Claude Code model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }); + if (choice === 'back') { + return { status: 'back' }; + } + if (choice === 'manual') { + const manual = await prompts.text({ + message: withTextInputNavigation('Claude Code model ID'), + placeholder: CLAUDE_CODE_MODELS.find((model) => model.recommended)?.id ?? CLAUDE_CODE_MODELS[0]?.id, + }); + if (manual === undefined) { + return { status: 'back' }; + } + return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' }; + } + return { status: 'ready', model: choice }; +} + async function persistLlmConfig( projectDir: string, provider: @@ -877,6 +928,7 @@ export async function runKtxSetupAnthropicModelStep( !args.llmBackend && !args.anthropicApiKeyEnv && !args.anthropicApiKeyFile && + !args.llmModel && !args.anthropicModel && !args.vertexProject && !args.vertexLocation @@ -943,23 +995,33 @@ export async function runKtxSetupAnthropicModelStep( } if (backendChoice.backend === 'claude-code') { - const model = backendArgs.anthropicModel ?? 'sonnet'; + const model = await chooseClaudeCodeModel(backendArgs, deps); + if (model.status === 'back' && backendChoice.prompted) { + attemptArgs = buildInteractiveRetryArgs(args); + continue; + } + if (model.status === 'invalid-credential') { + return { status: 'failed', projectDir: args.projectDir }; + } + if (model.status !== 'ready') { + return { status: model.status, projectDir: args.projectDir }; + } const probe = deps.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe; - const health = await probe({ projectDir: args.projectDir, model, env: deps.env ?? process.env }); + const health = await probe({ projectDir: args.projectDir, model: model.model, env: deps.env ?? process.env }); if (!health.ok) { io.stderr.write(`${health.message}\n`); return { status: 'failed', projectDir: args.projectDir }; } const warning = formatClaudeCodePromptCachingWarning( ignoredClaudeCodePromptCachingFields( - buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model), + buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model.model), ), ); if (warning) { io.stderr.write(`${warning}\n`); } - await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, model); - io.stdout.write(`│ LLM ready: yes (${model})\n`); + await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, model.model); + io.stdout.write(`│ LLM ready: yes (${model.model})\n`); return { status: 'ready', projectDir: args.projectDir }; } diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index eb554bde..99c2c689 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -77,6 +77,7 @@ export type KtxSetupArgs = llmBackend?: KtxSetupLlmBackend; anthropicApiKeyEnv?: string; anthropicApiKeyFile?: string; + llmModel?: string; anthropicModel?: string; vertexProject?: string; vertexLocation?: string; @@ -547,6 +548,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup ...(args.llmBackend ? { llmBackend: args.llmBackend } : {}), ...(args.anthropicApiKeyEnv ? { anthropicApiKeyEnv: args.anthropicApiKeyEnv } : {}), ...(args.anthropicApiKeyFile ? { anthropicApiKeyFile: args.anthropicApiKeyFile } : {}), + ...(args.llmModel ? { llmModel: args.llmModel } : {}), ...(args.anthropicModel ? { anthropicModel: args.anthropicModel } : {}), ...(args.vertexProject ? { vertexProject: args.vertexProject } : {}), ...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}), diff --git a/packages/context/src/llm/claude-code-runtime.test.ts b/packages/context/src/llm/claude-code-runtime.test.ts index badb05fd..f69c5d75 100644 --- a/packages/context/src/llm/claude-code-runtime.test.ts +++ b/packages/context/src/llm/claude-code-runtime.test.ts @@ -447,4 +447,18 @@ describe('ClaudeCodeKtxLlmRuntime', () => { env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }), }); }); + + it('reports unsupported Claude Code models without framing them as auth failures', async () => { + await expect( + runClaudeCodeAuthProbe({ + projectDir: '/tmp/project', + model: 'gpt-5', + query: vi.fn(), + env: {}, + }), + ).resolves.toEqual({ + ok: false, + message: 'Unsupported Claude Code model "gpt-5". Use sonnet, opus, haiku, or a claude-* model id.', + }); + }); }); diff --git a/packages/context/src/llm/claude-code-runtime.ts b/packages/context/src/llm/claude-code-runtime.ts index f57922e8..5d8edf26 100644 --- a/packages/context/src/llm/claude-code-runtime.ts +++ b/packages/context/src/llm/claude-code-runtime.ts @@ -288,10 +288,20 @@ export async function runClaudeCodeAuthProbe(input: { query?: QueryFn; env?: NodeJS.ProcessEnv; }): Promise<{ ok: true } | { ok: false; message: string }> { + let model: string; + try { + model = resolveClaudeCodeModel(input.model); + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + try { const options = baseOptions({ projectDir: input.projectDir, - model: resolveClaudeCodeModel(input.model), + model, env: input.env, maxTurns: 1, });