refactor(cli): remove Vertex AI auth step and add gcloud project listing retry

The Vertex AI auth step only offered one option (use existing ADC credentials)
making it a redundant click. Remove it and go straight to project selection.

When gcloud project listing fails (e.g. expired credentials), show a
diagnostic message and offer a "Retry" option instead of silently falling
back to an empty list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-13 16:00:04 -07:00
parent b00c1a11a9
commit 6962e7e4c9
2 changed files with 160 additions and 106 deletions

View file

@ -283,9 +283,9 @@ describe('setup Anthropic model step', () => {
expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)');
});
it('uses existing Vertex AI credentials without offering to run gcloud auth', async () => {
it('uses existing Vertex AI credentials without an extra auth choice', async () => {
const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['vertex', 'existing', 'local-gcp-project', 'claude-sonnet-4-6'] });
const prompts = makePromptAdapter({ selectValues: ['vertex', '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' },
@ -306,13 +306,9 @@ describe('setup Anthropic model step', () => {
);
expect(result.status).toBe('ready');
expect(prompts.select).toHaveBeenCalledWith(
expect(prompts.select).not.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();
@ -358,9 +354,45 @@ describe('setup Anthropic model step', () => {
});
});
it('skips the Vertex AI auth choice when Application Default Credentials are the only option', async () => {
const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['vertex', 'local-gcp-project', 'claude-sonnet-4-6'] });
const healthCheck = vi.fn(async () => ({ ok: true as const }));
const result = await runKtxSetupAnthropicModelStep(
{ projectDir: tempDir, inputMode: 'auto', skipLlm: false },
io.io,
{
prompts,
env: {},
readGcloudProject: vi.fn(async () => 'local-gcp-project'),
listGcloudProjects: vi.fn(async () => [{ projectId: 'local-gcp-project', name: 'Local project' }]),
healthCheck,
},
);
expect(result.status).toBe('ready');
expect(prompts.select).not.toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('How should KTX authenticate with Google Vertex AI?'),
}),
);
expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'),
}),
);
expect(healthCheck).toHaveBeenCalledWith(
expect.objectContaining({
backend: 'vertex',
vertex: { project: 'local-gcp-project', location: 'us-east5' },
}),
);
});
it('lets users choose a different visible gcloud project for Vertex AI', async () => {
const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['vertex', 'existing', 'other-gcp-project', 'claude-sonnet-4-6'] });
const prompts = makePromptAdapter({ selectValues: ['vertex', 'other-gcp-project', 'claude-sonnet-4-6'] });
const healthCheck = vi.fn(async () => ({ ok: true as const }));
const result = await runKtxSetupAnthropicModelStep(
@ -395,7 +427,7 @@ describe('setup Anthropic model step', () => {
it('allows manual Vertex AI project entry when gcloud project listing is empty', async () => {
const io = makeIo();
const prompts = makePromptAdapter({
selectValues: ['vertex', 'existing', 'manual', 'claude-sonnet-4-6'],
selectValues: ['vertex', 'manual', 'claude-sonnet-4-6'],
textValues: ['manual-gcp-project'],
});
const healthCheck = vi.fn(async () => ({ ok: true as const }));
@ -434,8 +466,49 @@ describe('setup Anthropic model step', () => {
);
});
it('lets users retry Vertex AI project listing after gcloud auth fails', async () => {
const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['vertex', 'retry', 'other-gcp-project', 'claude-sonnet-4-6'] });
const listGcloudProjects = vi
.fn()
.mockRejectedValueOnce(new Error('Reauthentication failed. cannot prompt during non-interactive execution.'))
.mockResolvedValueOnce([
{ projectId: 'local-gcp-project', name: 'Local project' },
{ projectId: 'other-gcp-project', name: 'Other project' },
]);
const healthCheck = vi.fn(async () => ({ ok: true as const }));
const result = await runKtxSetupAnthropicModelStep(
{ projectDir: tempDir, inputMode: 'auto', skipLlm: false },
io.io,
{
prompts,
env: {},
readGcloudProject: vi.fn(async () => 'local-gcp-project'),
listGcloudProjects,
healthCheck,
},
);
expect(result.status).toBe('ready');
expect(listGcloudProjects).toHaveBeenCalledTimes(2);
expect(io.stdout()).toContain('Could not list Google Cloud projects with gcloud');
expect(io.stdout()).toContain('gcloud auth login --update-adc');
expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'),
options: expect.arrayContaining([{ value: 'retry', label: 'Retry loading Google Cloud projects' }]),
}),
);
expect(healthCheck).toHaveBeenCalledWith(
expect.objectContaining({
vertex: { project: 'other-gcp-project', location: 'us-east5' },
}),
);
});
it('returns from Vertex AI project selection Back to provider selection', async () => {
const prompts = makePromptAdapter({ selectValues: ['vertex', 'existing', 'back', 'back'] });
const prompts = makePromptAdapter({ selectValues: ['vertex', 'back', 'back'] });
const result = await runKtxSetupAnthropicModelStep(
{ projectDir: tempDir, inputMode: 'auto', skipLlm: false },
@ -450,7 +523,7 @@ describe('setup Anthropic model step', () => {
expect(result.status).toBe('back');
expect(prompts.select).toHaveBeenNthCalledWith(
4,
3,
expect.objectContaining({
message: expect.stringContaining('Which LLM provider should KTX use?'),
}),

View file

@ -101,9 +101,6 @@ const ANTHROPIC_MODEL_PROMPT_CONTEXT =
'KTX uses this as the default model for ingest agents that turn schemas, SQL, BI metadata, and docs ' +
'into semantic-layer sources and wiki context.';
const VERTEX_AUTH_PROMPT_CONTEXT =
'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.';
@ -148,8 +145,6 @@ type VertexConfigChoice =
}
| { status: 'back' | 'missing-input' };
type VertexAuthChoice = { status: 'ready' } | { status: 'back' };
interface GcloudProjectChoice {
projectId: string;
name?: string;
@ -170,34 +165,30 @@ async function defaultReadGcloudProject(): Promise<string | undefined> {
}
async function defaultListGcloudProjects(): Promise<GcloudProjectChoice[]> {
try {
const { stdout } = await execFileAsync('gcloud', ['projects', 'list', '--format=json(projectId,name)'], {
encoding: 'utf8',
});
const parsed = JSON.parse(stdout.trim() || '[]') as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((item): GcloudProjectChoice | undefined => {
if (!item || typeof item !== 'object') {
return undefined;
}
const record = item as { projectId?: unknown; name?: unknown };
if (typeof record.projectId !== 'string' || !record.projectId.trim()) {
return undefined;
}
const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : undefined;
return {
projectId: record.projectId.trim(),
...(name ? { name } : {}),
};
})
.filter((project): project is GcloudProjectChoice => Boolean(project));
} catch {
const { stdout } = await execFileAsync('gcloud', ['projects', 'list', '--format=json(projectId,name)'], {
encoding: 'utf8',
});
const parsed = JSON.parse(stdout.trim() || '[]') as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((item): GcloudProjectChoice | undefined => {
if (!item || typeof item !== 'object') {
return undefined;
}
const record = item as { projectId?: unknown; name?: unknown };
if (typeof record.projectId !== 'string' || !record.projectId.trim()) {
return undefined;
}
const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : undefined;
return {
projectId: record.projectId.trim(),
...(name ? { name } : {}),
};
})
.filter((project): project is GcloudProjectChoice => Boolean(project));
}
export async function fetchAnthropicModels(
@ -495,28 +486,6 @@ async function chooseBackend(
return { status: 'ready', backend: choice === 'vertex' ? 'vertex' : 'anthropic', prompted: true };
}
async function chooseVertexAuth(
args: KtxSetupModelArgs,
deps: KtxSetupModelDeps,
): Promise<VertexAuthChoice> {
if (args.inputMode === 'disabled' || args.vertexProject || args.vertexLocation) {
return { status: 'ready' };
}
const prompts = deps.prompts ?? createPromptAdapter();
const choice = await prompts.select({
message: `How should KTX authenticate with Google Vertex AI?\n\n${VERTEX_AUTH_PROMPT_CONTEXT}`,
options: [
{ value: 'existing', label: 'Use existing gcloud/Application Default Credentials' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };
}
return { status: 'ready' };
}
function resolveProvidedVertexRef(
label: 'project' | 'location',
ref: string,
@ -572,51 +541,72 @@ function formatGcloudProjectLabel(project: GcloudProjectChoice, currentProject:
return `${project.projectId}${name}${current}`;
}
function formatGcloudProjectListFailure(error: unknown): string {
const stderr = typeof (error as { stderr?: unknown })?.stderr === 'string' ? (error as { stderr: string }).stderr : '';
const message = error instanceof Error ? error.message : '';
const details = `${stderr}\n${message}`;
const reason = /reauthentication failed|cannot prompt/i.test(details)
? 'gcloud needs reauthentication before it can list projects.'
: 'gcloud returned an error while listing projects.';
return [
`│ Could not list Google Cloud projects with gcloud: ${reason}`,
'│ Run `gcloud auth login --update-adc` in another terminal, then choose Retry loading Google Cloud projects.',
].join('\n');
}
async function chooseInteractiveVertexProject(
currentProject: string | undefined,
io: KtxCliIo,
deps: KtxSetupModelDeps,
): Promise<{ status: 'ready'; ref: string; value: string } | { status: 'back' | 'missing-input' }> {
const prompts = deps.prompts ?? createPromptAdapter();
let projects: GcloudProjectChoice[] = [];
try {
projects = await (deps.listGcloudProjects ?? defaultListGcloudProjects)();
} catch {
io.stderr.write('Could not list Google Cloud projects with gcloud. Enter a project ID manually or choose Back.\n');
}
while (true) {
let projects: GcloudProjectChoice[] = [];
let listFailed = false;
try {
projects = await (deps.listGcloudProjects ?? defaultListGcloudProjects)();
} catch (error) {
listFailed = true;
io.stdout.write(`${formatGcloudProjectListFailure(error)}\n`);
}
const orderedProjects = orderGcloudProjects(projects, currentProject);
if (orderedProjects.length === 0) {
io.stdout.write('│ gcloud did not return any visible Google Cloud projects. Enter a project ID manually or choose Back.\n');
}
const orderedProjects = orderGcloudProjects(projects, currentProject);
if (orderedProjects.length === 0 && !listFailed) {
io.stdout.write('│ gcloud did not return any visible Google Cloud projects. Enter a project ID manually or choose Back.\n');
}
const choice = await prompts.select({
message: `Which Google Cloud project should KTX use for Vertex AI?\n\n${VERTEX_PROJECT_PROMPT_CONTEXT}`,
options: [
...orderedProjects.map((project) => ({
value: project.projectId,
label: formatGcloudProjectLabel(project, currentProject),
})),
{ value: 'manual', label: 'Enter a project ID manually' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };
}
if (choice === 'manual') {
const manual = await prompts.text({
message: withTextInputNavigation('Google Cloud project ID'),
placeholder: currentProject ?? orderedProjects[0]?.projectId,
const choice = await prompts.select({
message: `Which Google Cloud project should KTX use for Vertex AI?\n\n${VERTEX_PROJECT_PROMPT_CONTEXT}`,
options: [
...orderedProjects.map((project) => ({
value: project.projectId,
label: formatGcloudProjectLabel(project, currentProject),
})),
...(listFailed ? [{ value: 'retry', label: 'Retry loading Google Cloud projects' }] : []),
{ value: 'manual', label: 'Enter a project ID manually' },
{ value: 'back', label: 'Back' },
],
});
if (manual === undefined) {
if (choice === 'back') {
return { status: 'back' };
}
const project = normalizeGcloudProjectId(manual);
return project ? { status: 'ready', ref: project, value: project } : { status: 'missing-input' };
}
if (choice === 'retry') {
continue;
}
if (choice === 'manual') {
const manual = await prompts.text({
message: withTextInputNavigation('Google Cloud project ID'),
placeholder: currentProject ?? orderedProjects[0]?.projectId,
});
if (manual === undefined) {
return { status: 'back' };
}
const project = normalizeGcloudProjectId(manual);
return project ? { status: 'ready', ref: project, value: project } : { status: 'missing-input' };
}
return { status: 'ready', ref: choice, value: choice };
return { status: 'ready', ref: choice, value: choice };
}
}
async function chooseVertexConfig(
@ -871,15 +861,6 @@ export async function runKtxSetupAnthropicModelStep(
: attemptArgs;
if (backendChoice.backend === 'vertex') {
const auth = await chooseVertexAuth(backendArgs, deps);
if (auth.status === 'back' && backendChoice.prompted) {
attemptArgs = buildInteractiveRetryArgs(args);
continue;
}
if (auth.status !== 'ready') {
return { status: auth.status, projectDir: args.projectDir };
}
const vertex = await chooseVertexConfig(backendArgs, io, deps);
if (vertex.status === 'back' && backendChoice.prompted) {
attemptArgs = buildInteractiveRetryArgs(args);