fix: improve setup wizard behavior

This commit is contained in:
Andrey Avtomonov 2026-05-17 18:37:43 +02:00
parent 33a142f769
commit 1c30abc51d
13 changed files with 337 additions and 38 deletions

View file

@ -31,6 +31,9 @@ ktx dev <subcommand> [options]
## `dev schema`
`ktx dev schema` does not require a `ktx.yaml` file or a configured project
directory. Use it from any directory to generate editor or agent schema files.
| Flag | Description | Default |
|------|-------------|---------|
| `--output <file>` | Write the schema to a file instead of stdout | — |

View file

@ -63,9 +63,9 @@ Setup supports three LLM provider paths:
| Provider | Use when | Credential model |
|----------|----------|------------------|
| Anthropic API | You have an Anthropic API key | `ANTHROPIC_API_KEY` or a local `file:` secret |
| Claude subscription (Pro/Max) | You want KTX to use your local Claude Code session | Claude Code local authentication |
| Anthropic API key | You have an Anthropic API key | `ANTHROPIC_API_KEY` or a local `file:` secret |
| Google Vertex AI for Anthropic Claude | Your organization runs Claude through Google Cloud | Application Default Credentials plus Vertex project and location |
| Claude Code | You want KTX to use your local Claude Code session | Claude Code local authentication |
For Anthropic API, setup can read the key from the environment or save a pasted
key to `.ktx/secrets/anthropic-api-key`. `ktx.yaml` stores an `env:` or `file:`

View file

@ -1,5 +1,7 @@
import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts';
const ESC = String.fromCharCode(0x1b);
export interface KtxCliSpinner {
start(message: string): void;
message(message: string): void;
@ -7,6 +9,10 @@ export interface KtxCliSpinner {
error(message: string): void;
}
export interface KtxCliSpinnerIo {
stderr: { write(chunk: string): void };
}
export interface KtxCliPromptAdapter {
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
cancel(message: string): void;
@ -31,6 +37,31 @@ export function createClackSpinner(): KtxCliSpinner {
return spinner();
}
function magenta(text: string): string {
return `${ESC}[35m${text}${ESC}[39m`;
}
function red(text: string): string {
return `${ESC}[31m${text}${ESC}[39m`;
}
export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner {
return {
start(message) {
io.stderr.write(`${magenta('◐')} ${message}\n`);
},
message(message) {
io.stderr.write(`${magenta('│')} ${message}\n`);
},
stop(message) {
io.stderr.write(`${magenta('◇')} ${message}\n`);
},
error(message) {
io.stderr.write(`${red('■')} ${message}\n`);
},
};
}
export function createClackPromptAdapter(): KtxCliPromptAdapter {
return {
async confirm(options) {

View file

@ -58,6 +58,7 @@ type CommandPathNode = CommandWithGlobalOptions & {
};
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'sql', 'status', 'mcp']);
const PROJECT_INDEPENDENT_DEV_COMMANDS = new Set(['runtime', 'schema']);
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
@ -172,7 +173,7 @@ function isProjectAwareCommand(path: string[]): boolean {
const rootCommand = path[1];
if (rootCommand === 'dev') {
return path[2] !== undefined && path[2] !== 'runtime';
return path[2] !== undefined && !PROJECT_INDEPENDENT_DEV_COMMANDS.has(path[2]);
}
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
}

View file

@ -102,6 +102,35 @@ describe('dev Commander tree', () => {
}
});
it('prints config schema without requiring a KTX project directory', async () => {
const { mkdtemp, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-dev-schema-'));
const missingProjectDir = join(tempDir, 'missing-project');
const originalProjectDir = process.env.KTX_PROJECT_DIR;
const testIo = makeIo();
try {
process.env.KTX_PROJECT_DIR = missingProjectDir;
await expect(runKtxCli(['dev', 'schema'], testIo.io)).resolves.toBe(0);
expect(JSON.parse(testIo.stdout())).toMatchObject({
title: 'ktx.yaml',
type: 'object',
});
expect(testIo.stderr()).toBe('');
} finally {
if (originalProjectDir === undefined) {
delete process.env.KTX_PROJECT_DIR;
} else {
process.env.KTX_PROJECT_DIR = originalProjectDir;
}
await rm(tempDir, { recursive: true, force: true });
}
});
it('rejects removed dev command groups', async () => {
for (const argv of [
['dev', 'doctor', 'setup'],

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
createManagedPythonSemanticLayerComputePort,
ensureManagedPythonCommandRuntime,
managedRuntimeInstallCommand,
runtimeInstallPolicyFromFlags,
} from './managed-python-command.js';
@ -103,6 +104,17 @@ function installResult(features: KtxRuntimeFeature[] = ['core']): ManagedPythonR
};
}
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 };
}
describe('managedRuntimeInstallCommand', () => {
it('prints the exact command for each managed runtime feature', () => {
expect(managedRuntimeInstallCommand('core')).toBe('ktx dev runtime install --yes');
@ -128,6 +140,51 @@ describe('runtimeInstallPolicyFromFlags', () => {
});
describe('createManagedPythonSemanticLayerComputePort', () => {
it('uses non-animated runtime setup status by default', async () => {
const io = makeIo();
await expect(
ensureManagedPythonCommandRuntime({
cliVersion: '0.2.0',
installPolicy: 'auto',
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime: vi.fn(async () => installResult(['local-embeddings'])),
feature: 'local-embeddings',
}),
).resolves.toMatchObject({
layout: { versionDir: '/runtime/0.2.0' },
});
expect(io.stderr()).toContain('Installing KTX Python runtime (local-embeddings) with uv...');
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0');
expect(io.stderr().match(/Installing KTX Python runtime/g)).toHaveLength(1);
});
it('shows runtime installation progress with the CLI spinner', async () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const options = {
cliVersion: '0.2.0',
installPolicy: 'auto' as const,
io: io.io,
readStatus: vi.fn(async () => missingStatus()),
installRuntime: vi.fn(async () => installResult(['local-embeddings'])),
feature: 'local-embeddings' as const,
spinner,
};
await expect(ensureManagedPythonCommandRuntime(options)).resolves.toMatchObject({
layout: { versionDir: '/runtime/0.2.0' },
});
expect(events).toEqual([
'start:Installing KTX Python runtime (local-embeddings) with uv...',
'stop:KTX Python runtime ready: /runtime/0.2.0',
]);
});
it('uses the managed ktx-daemon executable when the runtime is ready', async () => {
const io = makeIo();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
@ -170,6 +227,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
it('installs the core runtime without prompting when policy is auto', async () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const createPythonCompute = vi.fn(() => compute);
const installRuntime = vi.fn(async () => installResult());
@ -182,6 +240,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
createPythonCompute,
spinner,
}),
).resolves.toBe(compute);
@ -190,12 +249,15 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
features: ['core'],
force: false,
});
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv');
expect(io.stderr()).toContain('KTX Python runtime ready: /runtime/0.2.0');
expect(events).toEqual([
'start:Installing KTX Python runtime (core) with uv...',
'stop:KTX Python runtime ready: /runtime/0.2.0',
]);
});
it('prompts before installing when policy is prompt', async () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const confirmInstall = vi.fn(async () => true);
const installRuntime = vi.fn(async () => installResult());
@ -207,6 +269,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
installRuntime,
createPythonCompute: vi.fn(() => ({ query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() })),
confirmInstall,
spinner,
});
expect(confirmInstall).toHaveBeenCalledWith(
@ -218,10 +281,12 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
features: ['core'],
force: false,
});
expect(events).toContainEqual('start:Installing KTX Python runtime (core) with uv...');
});
it('uses injected runtime confirmation instead of reading process TTY directly', async () => {
const io = makeIo();
const { events, spinner } = makeSpinnerEvents();
const compute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
const installRuntime = vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => installResult());
const confirmInstall = vi.fn(async () => true);
@ -235,6 +300,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
installRuntime,
confirmInstall,
createPythonCompute: () => compute,
spinner,
}),
).resolves.toBe(compute);
@ -242,7 +308,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?',
io.io,
);
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv...');
expect(events).toContainEqual('start:Installing KTX Python runtime (core) with uv...');
});
it('can decide default runtime prompting from injected io capabilities', async () => {

View file

@ -1,6 +1,6 @@
import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from '@ktx/context/daemon';
import type { KtxCliIo } from './cli-runtime.js';
import { createClackPromptAdapter } from './clack.js';
import { createClackPromptAdapter, createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
import {
installManagedPythonRuntime,
readManagedPythonRuntimeStatus,
@ -37,6 +37,7 @@ export interface ManagedPythonCommandDeps {
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
confirmInstall?: (message: string, io: KtxCliIo) => Promise<boolean>;
spinner?: () => KtxCliSpinner;
}
export interface ManagedPythonCommandOptions extends ManagedPythonCommandDeps {
@ -101,14 +102,20 @@ export async function ensureManagedPythonCommandRuntime(
}
}
options.io.stderr.write(`Installing KTX Python runtime (${feature}) with uv...\n`);
const installed = await installRuntime({
cliVersion: options.cliVersion,
features: [feature],
force: false,
});
options.io.stderr.write(`KTX Python runtime ready: ${installed.layout.versionDir}\n`);
return { layout: installed.layout, manifest: installed.manifest };
const progress = (options.spinner ?? (() => createStaticCliSpinner(options.io)))();
progress.start(`Installing KTX Python runtime (${feature}) with uv...`);
try {
const installed = await installRuntime({
cliVersion: options.cliVersion,
features: [feature],
force: false,
});
progress.stop(`KTX Python runtime ready: ${installed.layout.versionDir}`);
return { layout: installed.layout, manifest: installed.manifest };
} catch (error) {
progress.error(`KTX Python runtime install failed: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
export async function createManagedPythonSemanticLayerComputePort(
@ -122,6 +129,7 @@ export async function createManagedPythonSemanticLayerComputePort(
...(options.readStatus ? { readStatus: options.readStatus } : {}),
...(options.installRuntime ? { installRuntime: options.installRuntime } : {}),
...(options.confirmInstall ? { confirmInstall: options.confirmInstall } : {}),
...(options.spinner ? { spinner: options.spinner } : {}),
});
const createPythonCompute = options.createPythonCompute ?? createPythonSemanticLayerComputePort;
return createPythonCompute({

View file

@ -176,12 +176,33 @@ describe('setup embeddings step', () => {
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
expect(spinnerEvents).toContainEqual(
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
expect(io.stdout()).toContain('Embeddings ready: yes');
});
it('uses a short non-animated local embeddings health-check status by default', async () => {
const io = makeIo();
const healthCheck = vi.fn(async () => ({ ok: true as const }));
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
const result = await runKtxSetupEmbeddingsStep(
{
projectDir: tempDir,
inputMode: 'auto',
cliVersion: '0.2.0',
runtimeInstallPolicy: 'auto',
skipEmbeddings: false,
},
io.io,
{ prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) },
);
expect(result.status).toBe('ready');
expect(io.stderr()).toContain('Testing local embeddings (all-MiniLM-L6-v2)');
expect(io.stderr()).not.toContain('First run may take up to 60 seconds');
expect(io.stderr().match(/Testing local embeddings/g)).toHaveLength(1);
});
it('shows live progress while local sentence-transformers embeddings are being tested', async () => {
const io = makeIo();
const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] });
@ -213,9 +234,7 @@ describe('setup embeddings step', () => {
);
await vi.waitFor(() => {
expect(spinnerEvents).toContainEqual(
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
);
expect(spinnerEvents).toContainEqual('start:Testing local embeddings (all-MiniLM-L6-v2)');
});
expect(resolveHealthCheck).toBeDefined();

View file

@ -10,7 +10,7 @@ import {
} from '@ktx/context/project';
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js';
import { createClackSpinner, type KtxCliSpinner } from './clack.js';
import { createStaticCliSpinner, type KtxCliSpinner } from './clack.js';
import {
ensureManagedLocalEmbeddingsDaemon,
managedLocalEmbeddingHealthConfig,
@ -316,10 +316,7 @@ async function promptAfterLocalEmbeddingFailure(
function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, dimensions: number): string {
if (backend === LOCAL_EMBEDDING_BACKEND) {
return [
`Testing local sentence-transformers embeddings (${model}, ${dimensions} dimensions).`,
'First run may take up to 60 seconds.',
].join(' ');
return `Testing local embeddings (${model})`;
}
return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`;
}
@ -424,7 +421,7 @@ export async function runKtxSetupEmbeddingsStep(
dimensions,
credentialValue,
});
const healthSpinner = (deps.spinner ?? createClackSpinner)();
const healthSpinner = (deps.spinner ?? (() => createStaticCliSpinner(io)))();
const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions));
let health: KtxEmbeddingHealthCheckResult;
try {

View file

@ -164,7 +164,7 @@ describe('setup Anthropic model step', () => {
);
});
it('offers Vertex AI as an Anthropic model provider option', async () => {
it('offers Anthropic provider paths in the preferred order', async () => {
const prompts = makePromptAdapter({ providerChoice: 'back' });
const result = await runKtxSetupAnthropicModelStep(
@ -177,10 +177,12 @@ describe('setup Anthropic model step', () => {
expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Which LLM provider should KTX use?'),
options: expect.arrayContaining([
options: [
{ value: 'claude-code', label: 'Claude subscription (Pro/Max)' },
{ value: 'anthropic', label: 'Anthropic API key' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'back', label: 'Back' },
]),
],
}),
);
});

View file

@ -509,12 +509,12 @@ async function chooseBackend(
}
const choice = await prompts.select({
message: 'Which LLM provider should KTX use?',
options: [
{ value: 'anthropic', label: 'Anthropic API' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'claude-code', label: 'Local Claude Code session' },
{ value: 'back', label: 'Back' },
],
options: [
{ value: 'claude-code', label: 'Claude subscription (Pro/Max)' },
{ value: 'anthropic', label: 'Anthropic API key' },
{ value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' },
{ value: 'back', label: 'Back' },
],
});
if (choice === 'back') {
return { status: 'back' };

View file

@ -0,0 +1,137 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
initKtxProject,
type KtxProjectConnectionConfig,
parseKtxProjectConfig,
serializeKtxProjectConfig,
} from '@ktx/context/project';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
runKtxSetupSourcesStep,
type KtxSetupSourcesPromptAdapter,
} from './setup-sources.js';
const notionMocks = vi.hoisted(() => ({
tokens: [] as string[],
retrieveBotUser: vi.fn(async () => ({ name: 'Docs Bot' })),
retrievePage: vi.fn(async () => ({ id: 'page-1' })),
}));
vi.mock('@ktx/context/ingest', async (importOriginal) => {
const actual = await importOriginal<typeof import('@ktx/context/ingest')>();
return {
...actual,
NotionClient: vi.fn().mockImplementation(function NotionClient(token: string) {
notionMocks.tokens.push(token);
return {
retrieveBotUser: notionMocks.retrieveBotUser,
retrievePage: notionMocks.retrievePage,
};
}),
};
});
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
isTTY: true,
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function prompts(values: { multiselect?: string[][]; select?: string[] }): KtxSetupSourcesPromptAdapter {
const multiselectValues = [...(values.multiselect ?? [])];
const selectValues = [...(values.select ?? [])];
return {
multiselect: vi.fn(async () => multiselectValues.shift() ?? []),
select: vi.fn(async () => selectValues.shift() ?? 'back'),
text: vi.fn(async () => ''),
password: vi.fn(async () => undefined),
cancel: vi.fn(),
log: vi.fn(),
};
}
describe('setup sources Notion validation', () => {
let tempDir: string;
let projectDir: string;
beforeEach(async () => {
notionMocks.tokens.length = 0;
notionMocks.retrieveBotUser.mockClear();
notionMocks.retrievePage.mockClear();
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-sources-notion-'));
projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
async function readConfig() {
return parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
}
async function writeConfigConnection(connectionId: string, connection: KtxProjectConnectionConfig) {
const config = await readConfig();
await writeFile(
join(projectDir, 'ktx.yaml'),
serializeKtxProjectConfig({
...config,
connections: {
...config.connections,
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
[connectionId]: connection,
},
setup: {
...config.setup,
database_connection_ids: ['warehouse'],
},
}),
'utf-8',
);
}
it('validates an existing Notion source that uses an inline auth token', async () => {
await writeConfigConnection('notion', {
driver: 'notion',
auth_token: 'ntn_inline_token',
crawl_mode: 'all_accessible',
});
const io = makeIo();
await expect(
runKtxSetupSourcesStep(
{ projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false },
io.io,
{
prompts: prompts({
multiselect: [['notion']],
select: ['existing:notion'],
}),
},
),
).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['notion'] });
expect(notionMocks.tokens).toEqual(['ntn_inline_token']);
expect(notionMocks.retrieveBotUser).toHaveBeenCalledOnce();
expect(io.stderr()).toBe('');
});
});

View file

@ -2,7 +2,10 @@ import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections';
import {
localConnectionTypeForConfig,
resolveNotionConnectionAuthToken,
} from '@ktx/context/connections';
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
cloneOrPull,
@ -620,7 +623,10 @@ async function defaultValidateLookml(connection: KtxProjectConnectionConfig): Pr
}
async function defaultValidateNotion(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
const token = await resolveNotionAuthToken(String(connection.auth_token_ref));
const token = await resolveNotionConnectionAuthToken({
auth_token: stringField(connection.auth_token) ?? null,
auth_token_ref: stringField(connection.auth_token_ref) ?? null,
});
const client: NotionApi = new NotionClient(token);
await client.retrieveBotUser();
const roots = Array.isArray(connection.root_page_ids)