diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index e310ea90..e4425d69 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -7,10 +7,8 @@ import { BUNDLED_ANTHROPIC_MODELS, fetchAnthropicModels, type KtxSetupModelPromptAdapter, - runKtxSetupGcloudApplicationDefaultAuth, runKtxSetupAnthropicModelStep, } from './setup-models.js'; -import type { KtxCliIo } from './cli-runtime.js'; function makeIo() { let stdout = ''; @@ -34,6 +32,17 @@ function makeIo() { }; } +function makeSpinnerEvents() { + const events: string[] = []; + const spinner = vi.fn(() => ({ + start: (msg: string) => events.push(`start:${msg}`), + message: (msg: string) => events.push(`message:${msg}`), + stop: (msg: string) => events.push(`stop:${msg}`), + error: (msg: string) => events.push(`error:${msg}`), + })); + return { events, spinner }; +} + function makePromptAdapter(options: { providerChoice?: string; selectValues?: string[]; @@ -191,6 +200,7 @@ describe('setup Anthropic model step', () => { it('configures env credentials, selected model, prompt caching, and llm completion state', async () => { const io = makeIo(); + const { events: spinnerEvents, spinner } = makeSpinnerEvents(); const result = await runKtxSetupAnthropicModelStep( { projectDir: tempDir, @@ -203,6 +213,7 @@ describe('setup Anthropic model step', () => { { env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret healthCheck: vi.fn(async () => ({ ok: true as const })), + spinner, }, ); @@ -219,6 +230,10 @@ describe('setup Anthropic model step', () => { expect(config.scan.enrichment.mode).toBe('llm'); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm'); + expect(spinnerEvents).toEqual([ + 'start:Checking Anthropic API LLM (claude-sonnet-4-6).', + 'stop:LLM test passed (Anthropic API, claude-sonnet-4-6)', + ]); expect(io.stdout()).toContain('LLM ready: yes'); expect(io.stdout()).not.toContain('sk-ant-test'); }); @@ -226,6 +241,7 @@ describe('setup Anthropic model step', () => { it('configures Vertex AI provider, selected model, prompt caching, and llm completion state', async () => { const io = makeIo(); const healthCheck = vi.fn(async () => ({ ok: true as const })); + const { events: spinnerEvents, spinner } = makeSpinnerEvents(); const result = await runKtxSetupAnthropicModelStep( { @@ -238,7 +254,7 @@ describe('setup Anthropic model step', () => { skipLlm: false, }, io.io, - { env: {}, healthCheck }, + { env: {}, healthCheck, spinner }, ); expect(result.status).toBe('ready'); @@ -260,13 +276,16 @@ describe('setup Anthropic model step', () => { expect(config.scan.enrichment.mode).toBe('llm'); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm'); + expect(spinnerEvents).toEqual([ + 'start:Checking Vertex AI LLM (claude-sonnet-4-6).', + 'stop:LLM test passed (Vertex AI, claude-sonnet-4-6)', + ]); expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)'); }); - it('can run gcloud auth for Vertex AI and infer project and default location', async () => { + it('uses existing Vertex AI credentials without offering to run gcloud auth', async () => { const io = makeIo(); - const prompts = makePromptAdapter({ selectValues: ['vertex', 'gcloud', 'local-gcp-project', 'claude-sonnet-4-6'] }); - const runGcloudAuth = vi.fn(async () => ({ ok: true as const })); + const prompts = makePromptAdapter({ selectValues: ['vertex', 'existing', 'local-gcp-project', 'claude-sonnet-4-6'] }); const readGcloudProject = vi.fn(async () => 'local-gcp-project'); const listGcloudProjects = vi.fn(async () => [ { projectId: 'local-gcp-project', name: 'Local project' }, @@ -280,7 +299,6 @@ describe('setup Anthropic model step', () => { { prompts, env: {}, - runGcloudAuth, readGcloudProject, listGcloudProjects, healthCheck, @@ -288,7 +306,15 @@ describe('setup Anthropic model step', () => { ); expect(result.status).toBe('ready'); - expect(runGcloudAuth).toHaveBeenCalledWith(io.io); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('How should KTX authenticate with Google Vertex AI?'), + options: [ + { value: 'existing', label: 'Use existing gcloud/Application Default Credentials' }, + { value: 'back', label: 'Back' }, + ], + }), + ); expect(readGcloudProject).toHaveBeenCalled(); expect(listGcloudProjects).toHaveBeenCalled(); expect(prompts.text).not.toHaveBeenCalled(); @@ -303,6 +329,22 @@ describe('setup Anthropic model step', () => { ], }), ); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Which Anthropic model should KTX use?'), + options: [ + { value: 'claude-opus-4-7', label: 'Claude Opus 4.7' }, + { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { value: 'claude-opus-4-5', label: 'Claude Opus 4.5' }, + { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' }, + { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + { value: 'claude-opus-4-1', label: 'Claude Opus 4.1' }, + { value: 'manual', label: 'Enter a model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }), + ); expect(healthCheck).toHaveBeenCalledWith({ backend: 'vertex', vertex: { project: 'local-gcp-project', location: 'us-east5' }, @@ -415,35 +457,6 @@ describe('setup Anthropic model step', () => { ); }); - it('runs only gcloud application-default login for Vertex AI auth', async () => { - const io = makeIo(); - const runGcloud = vi.fn(async () => ({ ok: true as const })); - - await expect(runKtxSetupGcloudApplicationDefaultAuth(io.io, runGcloud)).resolves.toEqual({ ok: true }); - - expect(runGcloud).toHaveBeenCalledTimes(1); - expect(runGcloud).toHaveBeenCalledWith(['auth', 'application-default', 'login'], expect.anything()); - expect(runGcloud).not.toHaveBeenCalledWith(['auth', 'login'], expect.anything()); - expect(io.stdout()).toContain('gcloud auth application-default login'); - expect(io.stdout()).not.toContain('gcloud auth login'); - }); - - it('indents gcloud auth output inside the setup gutter', async () => { - const io = makeIo(); - const runGcloud = vi.fn(async (_args: string[], commandIo: KtxCliIo) => { - commandIo.stdout.write('Your browser has been opened to visit:\n\n https://accounts.example/auth\n'); - commandIo.stderr.write('Credentials saved to file: [/tmp/application_default_credentials.json]\n'); - return { ok: true as const }; - }); - - await expect(runKtxSetupGcloudApplicationDefaultAuth(io.io, runGcloud)).resolves.toEqual({ ok: true }); - - expect(io.stdout()).toContain('│ Your browser has been opened to visit:'); - expect(io.stdout()).toContain('│ https://accounts.example/auth'); - expect(io.stderr()).toContain('│ Credentials saved to file: [/tmp/application_default_credentials.json]'); - expect(io.stdout()).not.toContain('\nYour browser has been opened'); - }); - it('explains common Vertex AI Forbidden health-check causes', async () => { const io = makeIo(); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index bd05bd44..e4c7fcd2 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -1,4 +1,4 @@ -import { execFile, spawn } from 'node:child_process'; +import { execFile } from 'node:child_process'; import { writeFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import { resolveLocalKtxLlmConfig } from '@ktx/context'; @@ -11,6 +11,7 @@ import { serializeKtxProjectConfig, } from '@ktx/context/project'; import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm'; +import { createClackSpinner, type KtxCliSpinner } from './clack.js'; import type { KtxCliIo } from './cli-runtime.js'; import { withTextInputNavigation } from './prompt-navigation.js'; import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js'; @@ -61,9 +62,9 @@ export interface KtxSetupModelDeps { prompts?: KtxSetupModelPromptAdapter; listModels?: (apiKey: string) => Promise; healthCheck?: (config: KtxLlmConfig) => Promise; - runGcloudAuth?: (io: KtxCliIo) => Promise; readGcloudProject?: () => Promise; listGcloudProjects?: () => Promise; + spinner?: () => KtxCliSpinner; } export const BUNDLED_ANTHROPIC_MODEL_REGISTRY_VERSION = '2026-05-07'; @@ -74,6 +75,16 @@ export const BUNDLED_ANTHROPIC_MODELS: AnthropicModelChoice[] = [ { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', recommended: false }, ]; +const VERTEX_ANTHROPIC_MODELS: AnthropicModelChoice[] = [ + { id: 'claude-opus-4-7', label: 'Claude Opus 4.7', recommended: false }, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', recommended: false }, + { id: 'claude-opus-4-6', label: 'Claude Opus 4.6', recommended: false }, + { id: 'claude-opus-4-5', label: 'Claude Opus 4.5', recommended: false }, + { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', recommended: false }, + { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', recommended: false }, + { id: 'claude-opus-4-1', label: 'Claude Opus 4.1', recommended: false }, +]; + const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [ /^claude-sonnet-4$/i, /^claude-opus-4$/i, @@ -91,8 +102,8 @@ const ANTHROPIC_MODEL_PROMPT_CONTEXT = 'into semantic-layer sources and wiki context.'; const VERTEX_AUTH_PROMPT_CONTEXT = - 'KTX can use Google Cloud Application Default Credentials for local Vertex AI access. This opens the normal ' + - 'gcloud browser login flow and does not store Google credentials in ktx.yaml.'; + 'KTX uses Google Cloud Application Default Credentials for local Vertex AI access and does not store Google ' + + 'credentials in ktx.yaml. If needed, run gcloud auth application-default login before continuing.'; const VERTEX_PROJECT_PROMPT_CONTEXT = 'KTX stores the selected Google Cloud project ID in ktx.yaml and uses Application Default Credentials for ' + 'access. Project visibility depends on the signed-in Google account and organization permissions.'; @@ -137,94 +148,17 @@ type VertexConfigChoice = } | { status: 'back' | 'missing-input' }; -type VertexAuthChoice = { status: 'ready' } | { status: 'back' | 'missing-input' }; +type VertexAuthChoice = { status: 'ready' } | { status: 'back' }; -export type GcloudAuthResult = { ok: true } | { ok: false; message: string }; interface GcloudProjectChoice { projectId: string; name?: string; } -type GcloudCommandRunner = (args: string[], io: KtxCliIo) => Promise; function createPromptAdapter(): KtxSetupModelPromptAdapter { return createKtxSetupPromptAdapter({ selectCancelValue: 'back' }); } -function createIndentedCommandIo(io: KtxCliIo): KtxCliIo { - const indentedWriter = (write: (chunk: string) => void) => { - let atLineStart = true; - return (chunk: string) => { - for (const char of chunk) { - if (atLineStart) { - write('│ '); - atLineStart = false; - } - write(char); - if (char === '\n') { - atLineStart = true; - } - } - }; - }; - - return { - stdout: { - isTTY: io.stdout.isTTY, - columns: io.stdout.columns, - write: indentedWriter((chunk) => io.stdout.write(chunk)), - }, - stderr: { - write: indentedWriter((chunk) => io.stderr.write(chunk)), - }, - }; -} - -function runInteractiveGcloud(args: string[], io: KtxCliIo): Promise { - return new Promise((resolve) => { - let settled = false; - const child = spawn('gcloud', args, { stdio: ['inherit', 'pipe', 'pipe'] }); - child.stdout?.on('data', (chunk: Buffer) => { - io.stdout.write(chunk.toString('utf8')); - }); - child.stderr?.on('data', (chunk: Buffer) => { - io.stderr.write(chunk.toString('utf8')); - }); - child.on('error', (error: NodeJS.ErrnoException) => { - if (settled) { - return; - } - settled = true; - if (error.code === 'ENOENT') { - resolve({ ok: false, message: 'gcloud CLI was not found on PATH.' }); - return; - } - resolve({ ok: false, message: error.message }); - }); - child.on('close', (code, signal) => { - if (settled) { - return; - } - settled = true; - if (code === 0) { - resolve({ ok: true }); - return; - } - resolve({ - ok: false, - message: signal ? `gcloud exited after signal ${signal}.` : `gcloud exited with code ${code ?? 'unknown'}.`, - }); - }); - }); -} - -export async function runKtxSetupGcloudApplicationDefaultAuth( - io: KtxCliIo, - runGcloud: GcloudCommandRunner = runInteractiveGcloud, -): Promise { - io.stdout.write('│ Running gcloud auth application-default login...\n'); - return await runGcloud(['auth', 'application-default', 'login'], createIndentedCommandIo(io)); -} - async function defaultReadGcloudProject(): Promise { try { const { stdout } = await execFileAsync('gcloud', ['config', 'get-value', 'project'], { encoding: 'utf8' }); @@ -374,6 +308,53 @@ function buildVertexHealthConfig(vertex: { project?: string; location: string }, }; } +type LlmHealthProvider = 'Anthropic API' | 'Vertex AI'; + +function llmHealthCheckStartText(provider: LlmHealthProvider, model: string): string { + return `Checking ${provider} LLM (${model}).`; +} + +function startLlmHealthCheckProgress( + spinner: KtxCliSpinner, + message: string, +): { succeed(msg: string): void; fail(msg: string): void } { + spinner.start(message); + return { + succeed(msg: string) { + spinner.stop(msg); + }, + fail(msg: string) { + spinner.error(msg); + }, + }; +} + +async function runLlmHealthCheckWithProgress( + config: KtxLlmConfig, + provider: LlmHealthProvider, + model: string, + healthCheck: (config: KtxLlmConfig) => Promise, + deps: KtxSetupModelDeps, +): Promise { + const progress = startLlmHealthCheckProgress( + (deps.spinner ?? createClackSpinner)(), + llmHealthCheckStartText(provider, model), + ); + let health: KtxLlmHealthCheckResult; + try { + health = await healthCheck(config); + } catch (error) { + progress.fail('LLM test failed'); + throw error; + } + if (health.ok) { + progress.succeed(`LLM test passed (${provider}, ${model})`); + } else { + progress.fail('LLM test failed'); + } + return health; +} + function formatVertexHealthFailure(message: string, vertex: { project?: string; location: string }): string { const trimmed = message.trim() || 'unknown error'; if (!/(forbidden|permission|permission_denied|403)/i.test(trimmed)) { @@ -516,7 +497,6 @@ async function chooseBackend( async function chooseVertexAuth( args: KtxSetupModelArgs, - io: KtxCliIo, deps: KtxSetupModelDeps, ): Promise { if (args.inputMode === 'disabled' || args.vertexProject || args.vertexLocation) { @@ -527,7 +507,6 @@ async function chooseVertexAuth( const choice = await prompts.select({ message: `How should KTX authenticate with Google Vertex AI?\n\n${VERTEX_AUTH_PROMPT_CONTEXT}`, options: [ - { value: 'gcloud', label: 'Run gcloud Application Default Credentials login' }, { value: 'existing', label: 'Use existing gcloud/Application Default Credentials' }, { value: 'back', label: 'Back' }, ], @@ -535,15 +514,6 @@ async function chooseVertexAuth( if (choice === 'back') { return { status: 'back' }; } - if (choice !== 'gcloud') { - return { status: 'ready' }; - } - - const result = await (deps.runGcloudAuth ?? runKtxSetupGcloudApplicationDefaultAuth)(io); - if (!result.ok) { - io.stderr.write(`gcloud authentication failed: ${result.message}\n`); - return { status: 'missing-input' }; - } return { status: 'ready' }; } @@ -799,7 +769,7 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt return { status: 'missing-input' }; } - const selectableModels = BUNDLED_ANTHROPIC_MODELS.filter(isSelectableAnthropicModel); + const selectableModels = VERTEX_ANTHROPIC_MODELS.filter(isSelectableAnthropicModel); const prompts = deps.prompts ?? createPromptAdapter(); const choice = await prompts.select({ message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`, @@ -901,7 +871,7 @@ export async function runKtxSetupAnthropicModelStep( : attemptArgs; if (backendChoice.backend === 'vertex') { - const auth = await chooseVertexAuth(backendArgs, io, deps); + const auth = await chooseVertexAuth(backendArgs, deps); if (auth.status === 'back' && backendChoice.prompted) { attemptArgs = buildInteractiveRetryArgs(args); continue; @@ -931,7 +901,13 @@ export async function runKtxSetupAnthropicModelStep( return { status: model.status, projectDir: args.projectDir }; } - const health = await healthCheck(buildVertexHealthConfig(vertex.values, model.model)); + const health = await runLlmHealthCheckWithProgress( + buildVertexHealthConfig(vertex.values, model.model), + 'Vertex AI', + model.model, + healthCheck, + deps, + ); if (health.ok) { await persistLlmConfig(args.projectDir, { backend: 'vertex', vertex: vertex.refs }, model.model); io.stdout.write(`│ LLM ready: yes (${model.model})\n`); @@ -973,7 +949,13 @@ export async function runKtxSetupAnthropicModelStep( return { status: model.status, projectDir: args.projectDir }; } - const health = await healthCheck(buildAnthropicHealthConfig(credential.value, model.model)); + const health = await runLlmHealthCheckWithProgress( + buildAnthropicHealthConfig(credential.value, model.model), + 'Anthropic API', + model.model, + healthCheck, + deps, + ); if (health.ok) { await persistLlmConfig(args.projectDir, { backend: 'anthropic', credentialRef: credential.ref }, model.model); io.stdout.write(`│ LLM ready: yes (${model.model})\n`);