refactor(cli): remove interactive gcloud auth from Vertex AI setup

Instead of spawning an interactive gcloud login flow, tell users to run
gcloud auth application-default login themselves before continuing. Also
adds a Vertex-specific model list and spinner progress for LLM health
checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-13 13:29:50 -07:00
parent fa9237956e
commit ea33e51e8f
2 changed files with 129 additions and 134 deletions

View file

@ -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();

View file

@ -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<AnthropicModelChoice[]>;
healthCheck?: (config: KtxLlmConfig) => Promise<KtxLlmHealthCheckResult>;
runGcloudAuth?: (io: KtxCliIo) => Promise<GcloudAuthResult>;
readGcloudProject?: () => Promise<string | undefined>;
listGcloudProjects?: () => Promise<GcloudProjectChoice[]>;
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<GcloudAuthResult>;
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<GcloudAuthResult> {
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<GcloudAuthResult> {
io.stdout.write('│ Running gcloud auth application-default login...\n');
return await runGcloud(['auth', 'application-default', 'login'], createIndentedCommandIo(io));
}
async function defaultReadGcloudProject(): Promise<string | undefined> {
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<KtxLlmHealthCheckResult>,
deps: KtxSetupModelDeps,
): Promise<KtxLlmHealthCheckResult> {
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<VertexAuthChoice> {
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`);