feat(cli): improve setup progress UX (#69)

This commit is contained in:
Andrey Avtomonov 2026-05-13 17:01:48 +02:00 committed by GitHub
parent d7147f9ca1
commit 754e4a9039
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1125 additions and 346 deletions

View file

@ -2,6 +2,7 @@ import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts';
export interface KtxCliSpinner {
start(message: string): void;
message(message: string): void;
stop(message: string): void;
error(message: string): void;
}

View file

@ -231,6 +231,38 @@ describe('renderContextBuildView', () => {
expect(output).toContain('(15s)');
});
it('shows how long a running target has gone without a progress update', () => {
const state = initViewState([
{ connectionId: 'notion-main', driver: 'notion', operation: 'source-ingest', debugCommand: '', steps: ['source-ingest', 'memory-update'] },
]);
state.contextSources[0].status = 'running';
state.contextSources[0].startedAt = 1_000;
state.contextSources[0].elapsedMs = 113_000;
state.contextSources[0].progressUpdatedAtMs = 46_000;
state.contextSources[0].detailLine = '[45%] No work units to process; finalizing ingest';
const output = renderContextBuildView(state, { styled: false });
expect(output).toContain('No work units to process; finalizing ingest');
expect(output).toContain('last update 1m08s ago');
expect(output).toContain('(1m53s)');
});
it('does not show progress age while updates are recent', () => {
const state = initViewState([
{ connectionId: 'notion-main', driver: 'notion', operation: 'source-ingest', debugCommand: '', steps: ['source-ingest', 'memory-update'] },
]);
state.contextSources[0].status = 'running';
state.contextSources[0].startedAt = 1_000;
state.contextSources[0].elapsedMs = 40_000;
state.contextSources[0].progressUpdatedAtMs = 25_000;
state.contextSources[0].detailLine = '[45%] Planning work units';
const output = renderContextBuildView(state, { styled: false });
expect(output).not.toContain('last update');
});
it('renders completion summary when all targets are done', () => {
const state = initViewState([
{ connectionId: 'warehouse', driver: 'postgres', operation: 'scan', debugCommand: '', steps: ['scan'] },
@ -480,7 +512,10 @@ describe('runContextBuild', () => {
expect.objectContaining({ connectionId: 'warehouse', operation: 'scan' }),
expect.objectContaining({ scanMode: 'enriched', detectRelationships: true }),
expect.anything(),
{},
expect.objectContaining({
scanProgress: expect.anything(),
ingestProgress: expect.any(Function),
}),
);
});
@ -563,6 +598,43 @@ describe('runContextBuild', () => {
]);
});
it('publishes structured target progress without expanding the compact source rows', async () => {
const io = makeIo({ isTTY: true });
const project = projectWithConnections({
warehouse: { driver: 'postgres' },
});
const progressUpdates: Array<Array<{ connectionId: string; percent?: number; message?: string }>> = [];
const executeTarget = vi.fn(async (target, _args, _targetIo, deps) => {
await deps.scanProgress?.update(0.37, 'Generating descriptions 3/8 tables', { transient: true });
return successResult(target.connectionId, target.driver, target.operation);
});
await runContextBuild(
project,
{ projectDir: '/tmp/project', inputMode: 'disabled' },
io.io,
{
executeTarget,
now: () => 1000,
onSourceProgress: (sources) => {
progressUpdates.push(
sources.map((s) => ({
connectionId: s.connectionId,
...(s.percent !== undefined ? { percent: s.percent } : {}),
...(s.message !== undefined ? { message: s.message } : {}),
})),
);
},
sourceProgressThrottleMs: 0,
},
);
expect(progressUpdates).toContainEqual([
{ connectionId: 'warehouse', percent: 37, message: 'Generating descriptions 3/8 tables' },
]);
expect(io.stdout()).toContain('Generating descriptions 3/8 tables');
});
it('returns report IDs and artifact paths parsed from target output', async () => {
const io = makeIo();
const project = projectWithConnections({
@ -679,4 +751,27 @@ describe('viewStateFromSourceProgress', () => {
expect(output).toContain('dbt-main');
expect(output).toContain('ingesting...');
});
it('renders persisted percent and message as compact source-row progress', () => {
const state = viewStateFromSourceProgress(
[
{
connectionId: 'warehouse',
operation: 'scan',
status: 'running',
startedAtMs: 900,
percent: 63,
message: 'Building embeddings 2/4 batches',
updatedAtMs: 950,
},
],
1000,
);
const output = renderContextBuildView(state, { styled: false });
expect(output).toContain('warehouse');
expect(output).toContain('63%');
expect(output).toContain('Building embeddings 2/4 batches');
expect(output.match(/warehouse/g)).toHaveLength(1);
});
});

View file

@ -1,9 +1,12 @@
import { spawn } from 'node:child_process';
import { mkdirSync, openSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { KtxProgressPort, KtxProgressUpdateOptions } from '@ktx/context/scan';
import type { KtxCliIo } from './index.js';
import type { KtxIngestProgressUpdate } from './ingest.js';
import type {
KtxPublicIngestArgs,
KtxPublicIngestDeps,
KtxPublicIngestPlanTarget,
KtxPublicIngestProject,
KtxPublicIngestTargetResult,
@ -25,6 +28,7 @@ export interface ContextBuildTargetState {
failureText: string | null;
startedAt: number | null;
elapsedMs: number;
progressUpdatedAtMs: number | null;
}
export interface ContextBuildViewState {
@ -55,6 +59,9 @@ export interface ContextBuildSourceProgressUpdate {
status: 'queued' | 'running' | 'done' | 'failed';
startedAtMs?: number;
elapsedMs?: number;
percent?: number;
message?: string;
updatedAtMs?: number;
summaryText?: string;
}
@ -64,6 +71,7 @@ export interface ContextBuildDeps {
setupKeystroke?: (onDetach: () => void, onCtrlC: () => void) => (() => void) | null;
onDetach?: () => void;
onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void;
sourceProgressThrottleMs?: number;
}
// --- Rendering ---
@ -118,6 +126,7 @@ function extractPercent(detailLine: string | null): number | null {
const BAR_WIDTH = 12;
const BAR_FILLED = '█';
const BAR_EMPTY = '░';
const STALE_PROGRESS_UPDATE_MS = 30_000;
function renderProgressBar(percent: number, styled: boolean): string {
const filled = Math.round((percent / 100) * BAR_WIDTH);
@ -126,6 +135,19 @@ function renderProgressBar(percent: number, styled: boolean): string {
return styled ? cyan(bar) : bar;
}
function staleProgressText(target: ContextBuildTargetState, styled: boolean): string | null {
if (target.startedAt === null || target.progressUpdatedAtMs === null || target.elapsedMs <= 0) {
return null;
}
const currentTimeMs = target.startedAt + target.elapsedMs;
const staleMs = currentTimeMs - target.progressUpdatedAtMs;
if (staleMs < STALE_PROGRESS_UPDATE_MS) {
return null;
}
const text = `last update ${formatDuration(staleMs)} ago`;
return styled ? dim(text) : text;
}
function targetDetail(target: ContextBuildTargetState, styled: boolean): string {
if (target.status === 'done') {
const parts: string[] = [];
@ -147,6 +169,8 @@ function targetDetail(target: ContextBuildTargetState, styled: boolean): string
parts.push(`${renderProgressBar(percent, styled)} ${percent}%`);
}
parts.push(progressText);
const stale = staleProgressText(target, styled);
if (stale) parts.push(stale);
if (elapsed) parts.push(styled ? dim(elapsed) : elapsed);
return parts.join(' ');
}
@ -309,15 +333,42 @@ function createCaptureIo(onProgress: (message: string) => void, isTTY: boolean):
// --- Source progress helpers ---
function progressFieldsFromDetailLine(
detailLine: string | null,
updatedAtMs: number | null,
): Pick<ContextBuildSourceProgressUpdate, 'percent' | 'message' | 'updatedAtMs'> {
if (!detailLine) return {};
const percent = extractPercent(detailLine);
const message = detailLine.replace(/^\[\d+%\]\s*/, '');
return {
...(percent !== null ? { percent } : {}),
...(message ? { message } : {}),
...(updatedAtMs !== null ? { updatedAtMs } : {}),
};
}
function detailLineFromProgressSource(source: ContextBuildSourceProgressUpdate): string | null {
if (!source.message) return null;
if (typeof source.percent === 'number' && Number.isFinite(source.percent)) {
const percent = Math.max(0, Math.min(100, Math.round(source.percent)));
return `[${percent}%] ${source.message}`;
}
return source.message;
}
function collectSourceProgress(targets: ContextBuildTargetState[]): ContextBuildSourceProgressUpdate[] {
return targets.map((t) => ({
connectionId: t.target.connectionId,
operation: t.target.operation,
status: t.status,
...(t.startedAt !== null ? { startedAtMs: t.startedAt } : {}),
...(t.elapsedMs > 0 ? { elapsedMs: t.elapsedMs } : {}),
...(t.summaryText ? { summaryText: t.summaryText } : {}),
}));
return targets.map((t) => {
const progressFields = progressFieldsFromDetailLine(t.detailLine, t.progressUpdatedAtMs);
return {
connectionId: t.target.connectionId,
operation: t.target.operation,
status: t.status,
...(t.startedAt !== null ? { startedAtMs: t.startedAt } : {}),
...(t.elapsedMs > 0 ? { elapsedMs: t.elapsedMs } : {}),
...progressFields,
...(t.summaryText ? { summaryText: t.summaryText } : {}),
};
});
}
export function viewStateFromSourceProgress(
@ -328,11 +379,12 @@ export function viewStateFromSourceProgress(
const makeTarget = (s: ContextBuildSourceProgressUpdate): ContextBuildTargetState => ({
target: { connectionId: s.connectionId, driver: '', operation: s.operation, debugCommand: '', steps: [] },
status: s.status,
detailLine: null,
detailLine: detailLineFromProgressSource(s),
summaryText: s.summaryText ?? null,
failureText: null,
startedAt: s.startedAtMs ?? null,
elapsedMs: s.status === 'running' && s.startedAtMs ? now - s.startedAtMs : (s.elapsedMs ?? 0),
progressUpdatedAtMs: s.updatedAtMs ?? null,
});
return {
@ -453,6 +505,7 @@ function makeTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTargetS
failureText: null,
startedAt: null,
elapsedMs: 0,
progressUpdatedAtMs: null,
};
}
@ -534,6 +587,34 @@ export function initViewState(targets: KtxPublicIngestPlanTarget[]): ContextBuil
};
}
function formatProgressDetail(update: Pick<KtxIngestProgressUpdate, 'percent' | 'message'>): string {
const percent = Math.max(0, Math.min(100, Math.round(update.percent)));
return `[${percent}%] ${update.message}`;
}
function createContextBuildProgressPort(
onProgress: (update: KtxIngestProgressUpdate) => void,
state: { progress: number } = { progress: 0 },
start = 0,
weight = 1,
): KtxProgressPort {
return {
async update(value: number, message?: string, options?: KtxProgressUpdateOptions): Promise<void> {
const absoluteValue = start + Math.max(0, Math.min(1, value)) * weight;
state.progress = Math.max(state.progress, Math.min(1, absoluteValue));
if (!message) return;
onProgress({
percent: Math.max(0, Math.min(100, Math.round(state.progress * 100))),
message,
...(options?.transient !== undefined ? { transient: options.transient } : {}),
});
},
startPhase(phaseWeight: number): KtxProgressPort {
return createContextBuildProgressPort(onProgress, state, state.progress, weight * phaseWeight);
},
};
}
export async function runContextBuild(
project: KtxPublicIngestProject,
args: ContextBuildArgs,
@ -572,6 +653,19 @@ export async function runContextBuild(
const execTarget = deps.executeTarget ?? executePublicIngestTarget;
const reportIds = new Set<string>();
const artifactPaths = new Set<string>();
const sourceProgressThrottleMs = deps.sourceProgressThrottleMs ?? 750;
let lastSourceProgressPublishedAt = Number.NEGATIVE_INFINITY;
const publishSourceProgress = (force = false): boolean => {
if (!deps.onSourceProgress) return false;
const now = nowFn();
if (!force && now - lastSourceProgressPublishedAt < sourceProgressThrottleMs) {
return false;
}
lastSourceProgressPublishedAt = now;
deps.onSourceProgress(collectSourceProgress(orderedTargets));
return true;
};
let detached = false;
let exiting = false;
@ -623,20 +717,34 @@ export async function runContextBuild(
targetState.status = 'running';
targetState.startedAt = nowFn();
paint(true);
deps.onSourceProgress?.(collectSourceProgress(orderedTargets));
publishSourceProgress(true);
let hasPendingProgressPublish = false;
const updateTargetProgress = (update: KtxIngestProgressUpdate) => {
targetState.detailLine = formatProgressDetail(update);
targetState.progressUpdatedAtMs = nowFn();
paint(true);
hasPendingProgressPublish = !publishSourceProgress(false);
};
const capture = createCaptureIo(
(message) => {
targetState.detailLine = message;
targetState.progressUpdatedAtMs = nowFn();
paint(true);
hasPendingProgressPublish = !publishSourceProgress(false);
},
false,
);
const progressDeps: KtxPublicIngestDeps = {
scanProgress: createContextBuildProgressPort(updateTargetProgress),
ingestProgress: updateTargetProgress,
};
let result: KtxPublicIngestTargetResult | null = null;
let thrownError: unknown = null;
try {
result = await execTarget(targetState.target, runArgs, capture.io, {});
result = await execTarget(targetState.target, runArgs, capture.io, progressDeps);
} catch (error) {
if (exiting) {
throw error;
@ -644,6 +752,10 @@ export async function runContextBuild(
thrownError = error;
}
if (hasPendingProgressPublish) {
publishSourceProgress(true);
}
targetState.elapsedMs = nowFn() - (targetState.startedAt ?? nowFn());
const failed = thrownError !== null || result?.steps.some((s) => s.status === 'failed') === true;
targetState.status = failed ? 'failed' : 'done';
@ -669,7 +781,7 @@ export async function runContextBuild(
if (failed) hasFailure = true;
paint(true);
deps.onSourceProgress?.(collectSourceProgress(orderedTargets));
publishSourceProgress(true);
}
} finally {
if (spinnerInterval) clearInterval(spinnerInterval);

View file

@ -103,6 +103,88 @@ describe('runKtxIngest', () => {
expect(statusIo.stderr()).toBe('');
});
it('emits structured progress for non-TTY local ingest runs', async () => {
const projectDir = join(tempDir, 'project');
await writeWarehouseConfig(projectDir);
const progressEvents: Array<{ percent: number; message: string; transient?: boolean }> = [];
const runLocal = vi.fn(async (input: RunLocalIngestOptions): Promise<LocalIngestResult> => {
input.memoryFlow?.emit({ type: 'source_acquired', adapter: 'fake', trigger: 'manual_resync', fileCount: 2 });
input.memoryFlow?.emit({ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 0 });
input.memoryFlow?.emit({ type: 'work_unit_started', unitKey: 'orders', skills: [], stepBudget: 4 });
input.memoryFlow?.emit({ type: 'work_unit_step', unitKey: 'orders', stepIndex: 2, stepBudget: 4 });
return completedLocalBundleRun(input, 'cli-local-progress-1');
});
const io = makeIo();
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'warehouse',
adapter: 'fake',
outputMode: 'plain',
},
io.io,
{
runLocalIngest: runLocal,
jobIdFactory: () => 'cli-local-progress-1',
progress: (event) => progressEvents.push(event),
},
),
).resolves.toBe(0);
expect(progressEvents).toEqual(
expect.arrayContaining([
{ percent: 5, message: 'Fetching source files for warehouse/fake' },
{ percent: 15, message: 'Fetched 2 source files from fake' },
{ percent: 45, message: 'Planned 2 work units' },
expect.objectContaining({
message: 'Processing work units: 0/2 complete, 1 active; latest orders step 2/4',
transient: true,
}),
]),
);
expect(io.stderr()).not.toContain('[15%] Fetched 2 source files from fake');
});
it('describes zero-work-unit ingest progress as finalizing instead of appearing half-planned', async () => {
const projectDir = join(tempDir, 'project');
await writeWarehouseConfig(projectDir);
const progressEvents: Array<{ percent: number; message: string; transient?: boolean }> = [];
const runLocal = vi.fn(async (input: RunLocalIngestOptions): Promise<LocalIngestResult> => {
input.memoryFlow?.emit({ type: 'source_acquired', adapter: 'fake', trigger: 'manual_resync', fileCount: 2 });
input.memoryFlow?.emit({ type: 'chunks_planned', chunkCount: 0, workUnitCount: 0, evictionCount: 0 });
return completedLocalBundleRun(input, 'cli-local-zero-progress-1');
});
const io = makeIo();
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'warehouse',
adapter: 'fake',
outputMode: 'plain',
},
io.io,
{
runLocalIngest: runLocal,
jobIdFactory: () => 'cli-local-zero-progress-1',
progress: (event) => progressEvents.push(event),
},
),
).resolves.toBe(0);
expect(progressEvents).toEqual(
expect.arrayContaining([
{ percent: 80, message: 'No work units to process; finalizing ingest' },
]),
);
expect(progressEvents).not.toContainEqual({ percent: 45, message: 'Planned 0 work units' });
});
it('prints provider setup guidance when a skip-llm setup project runs ingest', async () => {
const projectDir = join(tempDir, 'project');
const setupIo = makeIo();
@ -421,6 +503,65 @@ describe('runKtxIngest', () => {
expect(io.stdout()).not.toContain('status=running job=metabase-child-1');
});
it('emits structured progress for Metabase fan-out without writing progress to JSON output', async () => {
const projectDir = join(tempDir, 'project');
await writeMetabaseConfig(projectDir);
const io = makeIo();
const progressEvents: Array<{ percent: number; message: string }> = [];
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'prod-metabase',
adapter: 'metabase',
outputMode: 'json',
},
io.io,
{
progress: (event) => progressEvents.push(event),
runLocalMetabaseIngest: async (input) => {
input.progress?.onMetabaseFanoutPlanned?.({
metabaseConnectionId: 'prod-metabase',
children: [{ metabaseDatabaseId: 1, targetConnectionId: 'warehouse_a' }],
});
input.progress?.onMetabaseChildStarted?.({
metabaseConnectionId: 'prod-metabase',
metabaseDatabaseId: 1,
targetConnectionId: 'warehouse_a',
jobId: 'metabase-child-1',
});
input.progress?.onMetabaseChildCompleted?.({
metabaseConnectionId: 'prod-metabase',
metabaseDatabaseId: 1,
targetConnectionId: 'warehouse_a',
jobId: 'metabase-child-1',
status: 'done',
});
return {
metabaseConnectionId: 'prod-metabase',
status: 'all_succeeded',
totals: { workUnits: 0, failedWorkUnits: 0 },
children: [],
};
},
},
),
).resolves.toBe(0);
expect(progressEvents).toEqual(
expect.arrayContaining([
{ percent: 5, message: 'Checking Metabase mappings for prod-metabase' },
{ percent: 10, message: 'Metabase prod-metabase: 1 mapped database' },
{ percent: 25, message: 'Metabase database 1 -> warehouse_a running' },
{ percent: 90, message: 'Metabase database 1 -> warehouse_a done' },
]),
);
expect(io.stdout()).toContain('"status": "all_succeeded"');
expect(io.stderr()).not.toContain('Metabase ingest: prod-metabase');
});
it('runs Metabase scheduled ingest through the public CLI command path with real fan-out', async () => {
const projectDir = join(tempDir, 'metabase-cli-project');
await writeWarehouseConfig(projectDir);

View file

@ -67,7 +67,13 @@ interface KtxIngestIo {
stderr: { write(chunk: string): void };
}
interface KtxIngestDeps {
export interface KtxIngestProgressUpdate {
percent: number;
message: string;
transient?: boolean;
}
export interface KtxIngestDeps {
jobIdFactory?: () => string;
now?: () => Date;
createAdapters?: typeof createKtxCliLocalIngestAdapters;
@ -88,6 +94,7 @@ interface KtxIngestDeps {
| 'logger'
| 'pullConfigOptions'
>;
progress?: (update: KtxIngestProgressUpdate) => void;
}
function reportStatus(report: IngestReportSnapshot): 'done' | 'error' {
@ -145,12 +152,18 @@ function pluralize(count: number, singular: string, plural = `${singular}s`): st
function createMetabaseFanoutProgress(
connectionId: string,
io: KtxIngestIo,
onProgress?: (update: KtxIngestProgressUpdate) => void,
): LocalMetabaseFanoutProgress {
io.stderr.write(`Metabase ingest: ${connectionId}\n`);
io.stderr.write('Checking mappings and scheduled-pull targets...\n');
onProgress?.({ percent: 5, message: `Checking Metabase mappings for ${connectionId}` });
return {
onMetabaseFanoutPlanned(event) {
io.stderr.write(`Targets: ${pluralize(event.children.length, 'mapped database')}\n`);
onProgress?.({
percent: 10,
message: `Metabase ${event.metabaseConnectionId}: ${pluralize(event.children.length, 'mapped database')}`,
});
for (const child of event.children) {
io.stderr.write(`- database=${child.metabaseDatabaseId} target=${child.targetConnectionId} status=queued\n`);
}
@ -159,11 +172,19 @@ function createMetabaseFanoutProgress(
io.stderr.write(
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=running job=${event.jobId}\n`,
);
onProgress?.({
percent: 25,
message: `Metabase database ${event.metabaseDatabaseId} -> ${event.targetConnectionId} running`,
});
},
onMetabaseChildCompleted(event) {
io.stderr.write(
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=${event.status} job=${event.jobId}\n`,
);
onProgress?.({
percent: 90,
message: `Metabase database ${event.metabaseDatabaseId} -> ${event.targetConnectionId} ${event.status}`,
});
},
};
}
@ -231,6 +252,12 @@ function plainIngestEventProgress(
case 'diff_computed':
return { percent: 35, message: `Computed source diff ${formatDiffProgress(event)}` };
case 'chunks_planned':
if (event.workUnitCount === 0) {
return {
percent: 80,
message: 'No work units to process; finalizing ingest',
};
}
return {
percent: 45,
message: `Planned ${pluralize(event.workUnitCount, 'work unit')}`,
@ -296,34 +323,22 @@ function shouldWritePlainIngestProgress(
return outputMode === 'plain' && io.stdout.isTTY === true && env.CI !== 'true';
}
function createPlainIngestProgressRenderer(
function createPlainIngestProgressObserver(
args: Extract<KtxIngestArgs, { command: 'run' }>,
io: KtxIngestIo,
): { start(): void; update(snapshot: MemoryFlowReplayInput): void; flush(): void } {
onProgress: (update: KtxIngestProgressUpdate) => void,
): { start(): void; update(snapshot: MemoryFlowReplayInput): void } {
let printedEvents = 0;
let lastPercent = 0;
let printedCompletion = false;
let hasPendingTransient = false;
const flush = () => {
if (!hasPendingTransient) {
return;
}
io.stderr.write('\n');
hasPendingTransient = false;
};
const write = (percent: number, message: string, options?: { transient?: boolean }) => {
const nextPercent = Math.max(lastPercent, Math.max(0, Math.min(100, percent)));
lastPercent = nextPercent;
const line = `[${nextPercent}%] ${message}`;
if (options?.transient === true) {
io.stderr.write(`\r${line}\u001b[K`);
hasPendingTransient = true;
return;
}
flush();
io.stderr.write(`${line}\n`);
onProgress({
percent: nextPercent,
message,
...(options?.transient !== undefined ? { transient: options.transient } : {}),
});
};
return {
@ -347,6 +362,41 @@ function createPlainIngestProgressRenderer(
write(100, snapshot.status === 'done' ? 'Ingest completed' : 'Ingest failed');
}
},
};
}
function createPlainIngestProgressRenderer(
args: Extract<KtxIngestArgs, { command: 'run' }>,
io: KtxIngestIo,
): { start(): void; update(snapshot: MemoryFlowReplayInput): void; flush(): void } {
let hasPendingTransient = false;
const flush = () => {
if (!hasPendingTransient) {
return;
}
io.stderr.write('\n');
hasPendingTransient = false;
};
const observer = createPlainIngestProgressObserver(args, (update) => {
const line = `[${update.percent}%] ${update.message}`;
if (update.transient === true) {
io.stderr.write(`\r${line}\u001b[K`);
hasPendingTransient = true;
return;
}
flush();
io.stderr.write(`${line}\n`);
});
return {
start() {
observer.start();
},
update(snapshot) {
observer.update(snapshot);
},
flush,
};
}
@ -544,7 +594,15 @@ export async function runKtxIngest(
if (args.adapter === 'metabase') {
const executeMetabaseFanout = deps.runLocalMetabaseIngest ?? runLocalMetabaseIngest;
const progress =
args.outputMode === 'json' ? undefined : createMetabaseFanoutProgress(args.connectionId, io);
args.outputMode === 'json' && !deps.progress
? undefined
: createMetabaseFanoutProgress(
args.connectionId,
args.outputMode === 'json'
? { ...io, stderr: { write: () => undefined } }
: io,
deps.progress,
);
const result = await executeMetabaseFanout({
project,
adapters: createAdapters(project, adapterOptions),
@ -573,8 +631,13 @@ export async function runKtxIngest(
const plainProgress = shouldWritePlainIngestProgress(runOutputMode, io, env)
? createPlainIngestProgressRenderer(args, io)
: null;
const structuredProgress = deps.progress
? createPlainIngestProgressObserver(args, deps.progress)
: null;
const initialMemoryFlow =
shouldUseLiveViz || plainProgress ? initialRunMemoryFlowInput(args, jobId ?? 'pending') : undefined;
shouldUseLiveViz || plainProgress || structuredProgress
? initialRunMemoryFlowInput(args, jobId ?? 'pending')
: undefined;
let latestMemoryFlowSnapshot: MemoryFlowReplayInput | null = initialMemoryFlow ?? null;
if (shouldUseLiveViz && initialMemoryFlow && isTuiCapableIo(io)) {
@ -595,11 +658,13 @@ export async function runKtxIngest(
return;
}
plainProgress?.update(snapshot);
structuredProgress?.update(snapshot);
},
})
: undefined;
plainProgress?.start();
structuredProgress?.start();
try {
const result = await executeLocalIngest({

View file

@ -1,7 +1,8 @@
import { type KtxLocalProject, type KtxProjectConnectionConfig, loadKtxProject } from '@ktx/context/project';
import type { KtxProgressPort } from '@ktx/context/scan';
import type { KtxCliIo } from './index.js';
import type { KtxIngestArgs } from './ingest.js';
import type { KtxScanArgs } from './scan.js';
import type { KtxIngestArgs, KtxIngestDeps, KtxIngestProgressUpdate } from './ingest.js';
import type { KtxScanArgs, KtxScanDeps } from './scan.js';
import { profileMark } from './startup-profile.js';
profileMark('module:public-ingest');
@ -59,8 +60,10 @@ export type KtxPublicIngestProject = Pick<KtxLocalProject, 'projectDir' | 'confi
export interface KtxPublicIngestDeps {
loadProject?: (options: Parameters<typeof loadKtxProject>[0]) => Promise<KtxPublicIngestProject>;
runScan?: (args: KtxScanArgs, io: KtxCliIo) => Promise<number>;
runIngest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
runScan?: (args: KtxScanArgs, io: KtxCliIo, deps?: KtxScanDeps) => Promise<number>;
runIngest?: (args: KtxIngestArgs, io: KtxCliIo, deps?: KtxIngestDeps) => Promise<number>;
scanProgress?: KtxProgressPort;
ingestProgress?: (update: KtxIngestProgressUpdate) => void;
}
const sourceAdapterByDriver = new Map<string, string>([
@ -247,33 +250,35 @@ export async function executePublicIngestTarget(
): Promise<KtxPublicIngestTargetResult> {
if (target.operation === 'scan') {
const { runKtxScan } = await import('./scan.js');
const exitCode = await (deps.runScan ?? runKtxScan)(
{
command: 'run',
projectDir: args.projectDir,
connectionId: target.connectionId,
mode: args.scanMode ?? 'structural',
detectRelationships: args.detectRelationships ?? false,
dryRun: false,
},
io,
);
const scanArgs: KtxScanArgs = {
command: 'run',
projectDir: args.projectDir,
connectionId: target.connectionId,
mode: args.scanMode ?? 'structural',
detectRelationships: args.detectRelationships ?? false,
dryRun: false,
};
const runScan = deps.runScan ?? runKtxScan;
const exitCode = deps.scanProgress
? await runScan(scanArgs, io, { progress: deps.scanProgress })
: await runScan(scanArgs, io);
return markTargetResult(target, exitCode === 0 ? 'done' : 'failed');
}
const { runKtxIngest } = await import('./ingest.js');
const exitCode = await (deps.runIngest ?? runKtxIngest)(
{
command: 'run',
projectDir: args.projectDir,
connectionId: target.connectionId,
adapter: target.adapter ?? target.driver,
...(target.sourceDir ? { sourceDir: target.sourceDir } : {}),
outputMode: sourceIngestOutputMode(args, io),
inputMode: args.inputMode,
},
io,
);
const ingestArgs: KtxIngestArgs = {
command: 'run',
projectDir: args.projectDir,
connectionId: target.connectionId,
adapter: target.adapter ?? target.driver,
...(target.sourceDir ? { sourceDir: target.sourceDir } : {}),
outputMode: sourceIngestOutputMode(args, io),
inputMode: args.inputMode,
};
const runIngest = deps.runIngest ?? runKtxIngest;
const exitCode = deps.ingestProgress
? await runIngest(ingestArgs, io, { progress: deps.ingestProgress })
: await runIngest(ingestArgs, io);
return markTargetResult(target, exitCode === 0 ? 'done' : 'failed');
}

View file

@ -570,6 +570,59 @@ describe('runKtxScan', () => {
expect(io.stdout()).toContain('[55%] Semantic layer comparison found 5 changes across 18 tables');
});
it('uses injected structured progress without requiring TTY progress output', async () => {
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
const progressEvents: Array<{ progress: number; message?: string; transient?: boolean }> = [];
const structuredProgress = {
async update(progress: number, message?: string, options?: { transient?: boolean }) {
progressEvents.push({
progress,
...(message !== undefined ? { message } : {}),
...(options?.transient !== undefined ? { transient: options.transient } : {}),
});
},
startPhase() {
return structuredProgress;
},
};
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
await input.progress?.update(0.42, 'Generating descriptions 4/10 tables', { transient: true });
return {
runId: 'scan-run-1',
status: 'done',
done: true,
connectionId: 'warehouse',
mode: 'structural',
dryRun: false,
syncId: 'sync-1',
report,
};
});
const io = makeIo();
await expect(
runKtxScan(
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters, progress: structuredProgress },
),
).resolves.toBe(0);
expect(progressEvents).toContainEqual({
progress: 0.42,
message: 'Generating descriptions 4/10 tables',
transient: true,
});
expect(io.stdout()).not.toContain('[42%] Generating descriptions 4/10 tables');
});
it('updates transient TTY progress messages in place', async () => {
const io = makeIo({ isTTY: true });
const previousCi = process.env.CI;

View file

@ -26,9 +26,10 @@ export interface KtxScanArgs {
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
}
interface KtxScanDeps {
export interface KtxScanDeps {
runLocalScan?: typeof runLocalScan;
createLocalIngestAdapters?: typeof createKtxCliLocalIngestAdapters;
progress?: KtxProgressPort;
}
function shouldUseStyledOutput(io: KtxCliIo): boolean {
@ -257,7 +258,8 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
args.mode !== 'structural' || args.detectRelationships
? await createKtxCliScanConnector(project, args.connectionId)
: undefined;
const progress = createCliScanProgress(io);
const cliProgress = deps.progress ? null : createCliScanProgress(io);
const progress = deps.progress ?? cliProgress;
try {
const result = await (deps.runLocalScan ?? runLocalScan)({
project,
@ -272,12 +274,12 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
...(managedDaemon ? { managedDaemon } : {}),
}),
progress,
...(progress ? { progress } : {}),
});
progress.flush();
cliProgress?.flush();
writeRunSummary(result.report, args.projectDir, io);
} finally {
progress.flush();
cliProgress?.flush();
}
return 0;
} catch (error) {

View file

@ -1,15 +1,17 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cancel, confirm, isCancel, multiselect, select } from '@clack/prompts';
import {
loadKtxProject,
markKtxSetupStateStepComplete,
serializeKtxProjectConfig,
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { withMenuOptionsSpacing, withMultiselectNavigation } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { withMultiselectNavigation } from './prompt-navigation.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal';
export type KtxAgentScope = 'project' | 'global';
@ -238,10 +240,10 @@ export async function removeKtxAgentInstall(projectDir: string, io: KtxCliIo): P
}
export interface KtxSetupAgentsPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
multiselect(options: {
message: string;
options: Array<{ value: string; label: string }>;
options: KtxSetupPromptOption[];
required?: boolean;
}): Promise<string[]>;
cancel(message: string): void;
@ -252,38 +254,11 @@ export interface KtxSetupAgentsDeps {
}
function createPromptAdapter(): KtxSetupAgentsPromptAdapter {
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'back';
}
return String(value);
},
async multiselect(options) {
while (true) {
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return ['back'];
}
const selected = [...value] as string[];
if (selected.length === 0 && !options.required) {
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
if (isCancel(skipConfirmed)) {
cancel('Setup cancelled.');
return ['back'];
}
if (!skipConfirmed) continue;
}
return selected;
}
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({
selectCancelValue: 'back',
multiselectCancelValue: 'back',
confirmEmptyOptionalMultiselect: true,
});
}
const targetDisplayNames: Record<KtxAgentTarget, string> = {

View file

@ -142,6 +142,16 @@ describe('setup context build state', () => {
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
sourceProgress: [
{
connectionId: 'warehouse',
operation: 'scan',
status: 'running',
percent: 42,
message: 'Generating descriptions 4/10 tables',
updatedAtMs: 1000,
},
],
});
const state = await readKtxSetupContextState(tempDir);
@ -155,6 +165,16 @@ describe('setup context build state', () => {
status: `ktx status --project-dir ${tempDir}`,
resume: `ktx setup --project-dir ${tempDir}`,
},
sourceProgress: [
{
connectionId: 'warehouse',
operation: 'scan',
status: 'running',
percent: 42,
message: 'Generating descriptions 4/10 tables',
updatedAtMs: 1000,
},
],
});
expect(JSON.stringify(state)).not.toContain('DATABASE_URL');
expect(JSON.stringify(state)).not.toContain('NOTION_TOKEN');
@ -547,6 +567,79 @@ describe('setup context build state', () => {
expect(output).not.toContain('KTX context built: detached');
});
it('re-renders the compact progress view when watched source messages change', async () => {
await writeReadyProject(tempDir);
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-progress-message',
status: 'detached',
startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:00:00.000Z',
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: [],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-progress-message'),
sourceProgress: [
{
connectionId: 'warehouse',
operation: 'scan' as const,
status: 'running' as const,
startedAtMs: Date.now() - 5000,
percent: 35,
message: 'Inspecting database schema',
updatedAtMs: 1000,
},
],
});
const io = makeIo();
let polls = 0;
const updateRun = async () => {
polls++;
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-progress-message',
status: polls === 1 ? 'detached' : 'completed',
startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: polls === 1 ? '2026-05-09T10:00:01.000Z' : '2026-05-09T10:00:02.000Z',
...(polls === 1 ? {} : { completedAt: '2026-05-09T10:00:02.000Z' }),
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: [],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-progress-message'),
sourceProgress: [
{
connectionId: 'warehouse',
operation: 'scan' as const,
status: polls === 1 ? ('running' as const) : ('done' as const),
startedAtMs: Date.now() - 5000,
elapsedMs: polls === 1 ? undefined : 6000,
percent: polls === 1 ? 76 : undefined,
message: polls === 1 ? 'Building embeddings 3/4 batches' : undefined,
updatedAtMs: polls === 1 ? 2000 : undefined,
summaryText: polls === 1 ? undefined : '42 tables',
},
],
});
};
await expect(
runKtxSetupContextStep(
{ projectDir: tempDir, inputMode: 'auto', autoWatch: true },
io.io,
{
sleep: updateRun,
watchIntervalMs: 1,
},
),
).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-progress-message' });
expect(io.stdout()).toContain('Inspecting database schema');
expect(io.stdout()).toContain('Building embeddings 3/4 batches');
expect(io.stdout()).toContain('warehouse');
});
it('supports d to detach from the progress watch view', async () => {
await writeReadyProject(tempDir);
await writeKtxSetupContextState(tempDir, {

View file

@ -1,7 +1,6 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { cancel, isCancel, select } from '@clack/prompts';
import {
type KtxLocalProject,
loadKtxProject,
@ -19,8 +18,10 @@ import {
runContextBuild,
viewStateFromSourceProgress,
} from './context-build-view.js';
import { withMenuOptionsSpacing } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupContextBuildStatus =
| 'not_started'
@ -99,7 +100,7 @@ interface KtxSetupContextWatchArgs {
}
export interface KtxSetupContextPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
cancel(message: string): void;
}
@ -125,19 +126,7 @@ const SCAN_REPORT_FILE = 'scan-report.json';
const DEFAULT_WATCH_INTERVAL_MS = 2_000;
function createPromptAdapter(): KtxSetupContextPromptAdapter {
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'back';
}
return String(value);
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
}
function statePath(projectDir: string): string {
@ -228,6 +217,9 @@ function normalizeSourceProgress(value: unknown): ContextBuildSourceProgressUpda
status: rec.status as 'queued' | 'running' | 'done' | 'failed',
...(typeof rec.startedAtMs === 'number' ? { startedAtMs: rec.startedAtMs } : {}),
...(typeof rec.elapsedMs === 'number' ? { elapsedMs: rec.elapsedMs } : {}),
...(typeof rec.percent === 'number' ? { percent: rec.percent } : {}),
...(typeof rec.message === 'string' ? { message: rec.message } : {}),
...(typeof rec.updatedAtMs === 'number' ? { updatedAtMs: rec.updatedAtMs } : {}),
...(typeof rec.summaryText === 'string' ? { summaryText: rec.summaryText } : {}),
});
}
@ -920,7 +912,16 @@ async function watchContextStatusWithProgressView(
try {
while (true) {
if (!repainter) {
const currentKey = JSON.stringify(state.sourceProgress?.map((s) => s.status));
const currentKey = JSON.stringify(
state.sourceProgress?.map((s) => ({
id: s.connectionId,
status: s.status,
percent: s.percent,
message: s.message,
summaryText: s.summaryText,
updatedAtMs: s.updatedAtMs,
})),
);
if (currentKey !== lastProgressKey || !isActiveStatus(state.status)) {
io.stdout.write(renderContextBuildView(viewState, viewOpts));
lastProgressKey = currentKey;

View file

@ -3,7 +3,6 @@ import { readFile, writeFile } from 'node:fs/promises';
import { delimiter, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { cancel, confirm, isCancel, multiselect, password, select, text } from '@clack/prompts';
import type { HistoricSqlDialect } from '@ktx/context/ingest';
import {
type KtxProjectConnectionConfig,
@ -15,10 +14,13 @@ import {
import type { KtxTableListEntry } from '@ktx/context/scan';
import type { KtxCliIo } from './cli-runtime.js';
import { runKtxConnection } from './connection.js';
import { withMenuOptionsSpacing, withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
import { runKtxScan } from './scan.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { writeProjectLocalSecretReference } from './setup-secrets.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
const HISTORIC_SQL_WORK_UNIT_MAX_CONCURRENCY = 6;
const execFileAsync = promisify(execFileCallback);
@ -59,11 +61,11 @@ export type KtxSetupDatabasesResult =
export interface KtxSetupDatabasesPromptAdapter {
multiselect(options: {
message: string;
options: Array<{ value: string; label: string }>;
options: KtxSetupPromptOption[];
required?: boolean;
initialValues?: string[];
}): Promise<string[]>;
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
password(options: { message: string }): Promise<string | undefined>;
cancel(message: string): void;
@ -207,50 +209,11 @@ function missingConnectionDetailsPrompt(
}
function createPromptAdapter(): KtxSetupDatabasesPromptAdapter {
return {
async multiselect(options) {
while (true) {
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return ['back'];
}
const selected = [...value] as string[];
if (selected.length === 0 && !options.required) {
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
if (isCancel(skipConfirmed)) {
cancel('Setup cancelled.');
return ['back'];
}
if (!skipConfirmed) continue;
}
return selected;
}
},
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'back';
}
return String(value);
},
async text(options) {
const value = await withSetupInterruptConfirmation(() =>
text({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : String(value);
},
async password(options) {
const value = await withSetupInterruptConfirmation(() =>
password({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : String(value);
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({
selectCancelValue: 'back',
multiselectCancelValue: 'back',
confirmEmptyOptionalMultiselect: true,
});
}
function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null {

View file

@ -55,6 +55,7 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge
failureText: null,
startedAt: null,
elapsedMs: 0,
progressUpdatedAtMs: null,
};
}

View file

@ -90,7 +90,7 @@ describe('setup embeddings step', () => {
message: EMBEDDING_OPTION_PROMPT_MESSAGE,
options: [
{ value: 'sentence-transformers', label: 'Local sentence-transformers embeddings' },
{ value: 'openai', label: 'OpenAI embeddings (recommended)' },
{ value: 'openai', label: 'OpenAI embeddings', hint: 'recommended' },
{ value: 'back', label: 'Back' },
],
});
@ -136,6 +136,7 @@ describe('setup embeddings step', () => {
const spinnerEvents: string[] = [];
const spinner = vi.fn(() => ({
start: (msg: string) => spinnerEvents.push(`start:${msg}`),
message: (msg: string) => spinnerEvents.push(`message:${msg}`),
stop: (msg: string) => spinnerEvents.push(`stop:${msg}`),
error: (msg: string) => spinnerEvents.push(`error:${msg}`),
}));
@ -193,6 +194,7 @@ describe('setup embeddings step', () => {
const spinnerEvents: string[] = [];
const spinner = vi.fn(() => ({
start: (msg: string) => spinnerEvents.push(`start:${msg}`),
message: (msg: string) => spinnerEvents.push(`message:${msg}`),
stop: (msg: string) => spinnerEvents.push(`stop:${msg}`),
error: (msg: string) => spinnerEvents.push(`error:${msg}`),
}));

View file

@ -1,5 +1,4 @@
import { writeFile } from 'node:fs/promises';
import { cancel, isCancel, password, select } from '@clack/prompts';
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
type KtxProjectConfig,
@ -19,9 +18,12 @@ import {
type ManagedLocalEmbeddingsDaemon,
} from './managed-local-embeddings.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { withTextInputNavigation } from './prompt-navigation.js';
import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupEmbeddingBackend = 'openai' | 'sentence-transformers';
@ -46,7 +48,7 @@ export type KtxSetupEmbeddingsResult =
| { status: 'failed'; projectDir: string };
export interface KtxSetupEmbeddingsPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
password(options: { message: string }): Promise<string | undefined>;
cancel(message: string): void;
}
@ -85,25 +87,7 @@ const EMBEDDING_OPTION_PROMPT_CONTEXT =
const LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS = 120_000;
function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'back';
}
return value;
},
async password(options) {
const value = await withSetupInterruptConfirmation(() =>
password({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : value;
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
}
async function hasCompletedEmbeddings(projectDir: string, config: KtxProjectConfig): Promise<boolean> {
@ -293,7 +277,7 @@ async function chooseEmbeddingBackend(
message: `Which embedding option should KTX use?\n\n${EMBEDDING_OPTION_PROMPT_CONTEXT}`,
options: [
{ value: 'sentence-transformers', label: 'Local sentence-transformers embeddings' },
{ value: 'openai', label: 'OpenAI embeddings (recommended)' },
{ value: 'openai', label: 'OpenAI embeddings', hint: 'recommended' },
{ value: 'back', label: 'Back' },
],
});

View file

@ -140,7 +140,7 @@ describe('setup Anthropic model step', () => {
expect.objectContaining({
message: expect.stringContaining('Which Anthropic model should KTX use?'),
options: [
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (recommended)' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', hint: 'recommended' },
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
{ value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
{ value: 'manual', label: 'Enter a model ID manually' },
@ -763,7 +763,7 @@ describe('setup Anthropic model step', () => {
expect.objectContaining({
message: expect.stringContaining('Which Anthropic model should KTX use?'),
options: expect.arrayContaining([
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (recommended)' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', hint: 'recommended' },
]),
}),
);

View file

@ -1,7 +1,6 @@
import { execFile, spawn } from 'node:child_process';
import { writeFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import { cancel, isCancel, password, select, text } from '@clack/prompts';
import { resolveLocalKtxLlmConfig } from '@ktx/context';
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
@ -13,9 +12,12 @@ import {
} from '@ktx/context/project';
import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm';
import type { KtxCliIo } from './cli-runtime.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { withTextInputNavigation } from './prompt-navigation.js';
import { envCredentialReference, writeProjectLocalSecretReference } from './setup-secrets.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
export interface KtxSetupModelArgs {
projectDir: string;
@ -47,7 +49,7 @@ export interface AnthropicModelChoice {
export type KtxSetupLlmBackend = 'anthropic' | 'vertex';
export interface KtxSetupModelPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
password(options: { message: string }): Promise<string | undefined>;
cancel(message: string): void;
@ -145,31 +147,7 @@ interface GcloudProjectChoice {
type GcloudCommandRunner = (args: string[], io: KtxCliIo) => Promise<GcloudAuthResult>;
function createPromptAdapter(): KtxSetupModelPromptAdapter {
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'back';
}
return value;
},
async text(options) {
const value = await withSetupInterruptConfirmation(() =>
text({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : value;
},
async password(options) {
const value = await withSetupInterruptConfirmation(() =>
password({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : value;
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
}
function createIndentedCommandIo(io: KtxCliIo): KtxCliIo {
@ -786,7 +764,8 @@ async function chooseModel(
const modelOptions = [
...selectableModels.map((model) => ({
value: model.id,
label: `${model.label || model.id}${model.recommended ? ' (recommended)' : ''}`,
label: model.label || model.id,
...(model.recommended ? { hint: 'recommended' } : {}),
})),
{ value: 'manual', label: 'Enter a model ID manually' },
{ value: 'back', label: 'Back' },
@ -827,7 +806,8 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt
options: [
...selectableModels.map((model) => ({
value: model.id,
label: `${model.label || model.id}${model.recommended ? ' (recommended)' : ''}`,
label: model.label || model.id,
...(model.recommended ? { hint: 'recommended' } : {}),
})),
{ value: 'manual', label: 'Enter a model ID manually' },
{ value: 'back', label: 'Back' },

View file

@ -2,7 +2,6 @@ import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join, resolve } from 'node:path';
import { cancel, isCancel, select, text } from '@clack/prompts';
import {
initKtxProject,
type KtxLocalProject,
@ -13,8 +12,11 @@ import {
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { gray } from './io/symbols.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { withTextInputNavigation } from './prompt-navigation.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupProjectMode = 'auto' | 'new' | 'existing' | 'prompt-new';
export type KtxSetupInputMode = 'auto' | 'disabled';
@ -34,7 +36,7 @@ export type KtxSetupProjectResult =
| { status: 'missing-input'; projectDir: string };
export interface KtxSetupProjectPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
cancel(message: string): void;
}
@ -55,28 +57,7 @@ type PromptProjectDirResult =
const DEFAULT_NEW_PROJECT_FOLDER_NAME = 'ktx-project';
function createClackSetupProjectPromptAdapter(): KtxSetupProjectPromptAdapter {
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'exit';
}
return value;
},
async text(options) {
const value = await withSetupInterruptConfirmation(() =>
text({ ...options, message: withTextInputNavigation(options.message) }),
);
if (isCancel(value)) {
return undefined;
}
return value;
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({ selectCancelValue: 'exit' });
}
function hasProjectConfig(projectDir: string): boolean {

View file

@ -0,0 +1,205 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
const mocks = vi.hoisted(() => {
const cancelSymbol = Symbol('cancel');
return {
cancelSymbol,
cancel: vi.fn(),
confirm: vi.fn(),
intro: vi.fn(),
isCancel: vi.fn((value: unknown): value is symbol => value === cancelSymbol),
log: { info: vi.fn() },
multiselect: vi.fn(),
note: vi.fn(),
password: vi.fn(),
select: vi.fn(),
text: vi.fn(),
withSetupInterruptConfirmation: vi.fn((prompt: () => Promise<unknown>) => prompt()),
};
});
vi.mock('@clack/prompts', () => ({
cancel: mocks.cancel,
confirm: mocks.confirm,
intro: mocks.intro,
isCancel: mocks.isCancel,
log: mocks.log,
multiselect: mocks.multiselect,
note: mocks.note,
password: mocks.password,
select: mocks.select,
text: mocks.text,
}));
vi.mock('./setup-interrupt.js', () => ({
withSetupInterruptConfirmation: mocks.withSetupInterruptConfirmation,
}));
describe('setup prompt adapter', () => {
beforeEach(() => {
mocks.cancel.mockReset();
mocks.confirm.mockReset();
mocks.intro.mockReset();
mocks.isCancel.mockClear();
mocks.log.info.mockReset();
mocks.multiselect.mockReset();
mocks.note.mockReset();
mocks.password.mockReset();
mocks.select.mockReset();
mocks.text.mockReset();
mocks.withSetupInterruptConfirmation.mockClear();
});
it('passes select hint and disabled options through Clack and delegates cancellation handling', async () => {
mocks.select.mockResolvedValueOnce('openai');
const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
const options: KtxSetupPromptOption[] = [
{ value: 'local', label: 'Local embeddings', disabled: true },
{ value: 'openai', label: 'OpenAI embeddings', hint: 'recommended' },
];
await expect(
adapter.select({
message: 'Which embedding option should KTX use?\n\nKTX uses embeddings for search.',
options,
}),
).resolves.toBe('openai');
expect(mocks.withSetupInterruptConfirmation).toHaveBeenCalledTimes(1);
expect(mocks.select).toHaveBeenCalledWith({
message: 'Which embedding option should KTX use?\n\nKTX uses embeddings for search.\n',
options,
});
});
it('maps select cancellation to the configured sentinel', async () => {
mocks.select.mockResolvedValueOnce(mocks.cancelSymbol);
const adapter = createKtxSetupPromptAdapter({
selectCancelValue: 'exit',
cancelOnSelectCancel: false,
});
await expect(adapter.select({ message: 'What do you want to do?', options: [] })).resolves.toBe('exit');
expect(mocks.cancel).not.toHaveBeenCalled();
});
it('decorates text and password prompts with setup navigation copy', async () => {
mocks.text.mockResolvedValueOnce('analytics-ktx');
mocks.password.mockResolvedValueOnce('secret');
const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
await expect(adapter.text({ message: 'Project folder path', placeholder: './analytics-ktx' })).resolves.toBe(
'analytics-ktx',
);
await expect(adapter.password({ message: 'Anthropic API key' })).resolves.toBe('secret');
expect(mocks.text).toHaveBeenCalledWith({
message: 'Project folder path\n│ Press Escape to go back.\n│',
placeholder: './analytics-ktx',
});
expect(mocks.password).toHaveBeenCalledWith({
message: 'Anthropic API key\n│ Press Escape to go back.\n│',
});
});
it('passes multiselect hint and disabled options through Clack', async () => {
mocks.multiselect.mockResolvedValueOnce(['postgres']);
const adapter = createKtxSetupPromptAdapter({
selectCancelValue: 'back',
multiselectCancelValue: 'back',
confirmEmptyOptionalMultiselect: true,
});
const options: KtxSetupPromptOption[] = [
{ value: 'postgres', label: 'PostgreSQL', hint: 'recommended' },
{ value: 'snowflake', label: 'Snowflake', disabled: true },
];
await expect(adapter.multiselect({ message: 'Which primary sources?', options, required: true })).resolves.toEqual([
'postgres',
]);
expect(mocks.multiselect).toHaveBeenCalledWith({
message: 'Which primary sources?',
options,
required: true,
});
});
it('confirms an empty optional multiselect and retries when skip is declined', async () => {
mocks.multiselect.mockResolvedValueOnce([]).mockResolvedValueOnce(['postgres']);
mocks.confirm.mockResolvedValueOnce(false);
const adapter = createKtxSetupPromptAdapter({
selectCancelValue: 'back',
multiselectCancelValue: 'back',
confirmEmptyOptionalMultiselect: true,
});
await expect(adapter.multiselect({ message: 'Which primary sources?', options: [], required: false })).resolves.toEqual([
'postgres',
]);
expect(mocks.confirm).toHaveBeenCalledWith({ message: 'Nothing selected. Skip this step?', initialValue: false });
expect(mocks.multiselect).toHaveBeenCalledTimes(2);
});
it('maps multiselect cancellation to the configured back value', async () => {
mocks.multiselect.mockResolvedValueOnce(mocks.cancelSymbol);
const adapter = createKtxSetupPromptAdapter({
selectCancelValue: 'back',
multiselectCancelValue: 'back',
confirmEmptyOptionalMultiselect: true,
});
await expect(adapter.multiselect({ message: 'Which primary sources?', options: [] })).resolves.toEqual(['back']);
expect(mocks.cancel).toHaveBeenCalledWith('Setup cancelled.');
});
it('keeps setup intro and note plain for non-stream output', async () => {
const { createKtxSetupUiAdapter } = await import('./setup-prompts.js');
const chunks: string[] = [];
const io = {
stdout: {
isTTY: true,
write(chunk: string) {
chunks.push(chunk);
},
},
stderr: { write: vi.fn() },
};
const ui = createKtxSetupUiAdapter();
ui.intro('KTX setup', io);
ui.note(' $ ktx status', 'What you can do next', io);
expect(chunks.join('')).toBe('KTX setup\n\nWhat you can do next:\n $ ktx status\n');
expect(mocks.intro).not.toHaveBeenCalled();
expect(mocks.note).not.toHaveBeenCalled();
});
it('uses Clack intro and note for writable TTY output', async () => {
const { createKtxSetupUiAdapter } = await import('./setup-prompts.js');
const output = {
columns: 80,
isTTY: true,
on: vi.fn(),
write: vi.fn(),
};
const io = {
stdout: output,
stderr: { write: vi.fn() },
};
const ui = createKtxSetupUiAdapter();
ui.intro('KTX setup', io);
ui.note(' $ ktx status', 'What you can do next', io);
expect(mocks.intro).toHaveBeenCalledWith('KTX setup', { output });
expect(mocks.note).toHaveBeenCalledWith(' $ ktx status', 'What you can do next', { output });
});
});

View file

@ -0,0 +1,172 @@
import type { Writable } from 'node:stream';
import {
cancel,
confirm,
intro,
isCancel,
log,
multiselect,
note,
password,
select,
text,
} from '@clack/prompts';
import type { KtxCliIo } from './cli-runtime.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
export interface KtxSetupPromptOption<Value extends string = string> {
value: Value;
label: string;
hint?: string;
disabled?: boolean;
}
interface KtxSetupSelectOptions<Value extends string = string> {
message: string;
options: Array<KtxSetupPromptOption<Value>>;
initialValue?: Value;
maxItems?: number;
}
interface KtxSetupMultiselectOptions<Value extends string = string> {
message: string;
options: Array<KtxSetupPromptOption<Value>>;
required?: boolean;
initialValues?: Value[];
maxItems?: number;
cursorAt?: Value;
}
interface KtxSetupTextOptions {
message: string;
placeholder?: string;
initialValue?: string;
defaultValue?: string;
}
interface KtxSetupPasswordOptions {
message: string;
mask?: string;
}
export interface KtxSetupPromptAdapter {
select(options: KtxSetupSelectOptions): Promise<string>;
multiselect(options: KtxSetupMultiselectOptions): Promise<string[]>;
text(options: KtxSetupTextOptions): Promise<string | undefined>;
password(options: KtxSetupPasswordOptions): Promise<string | undefined>;
cancel(message: string): void;
log(message: string): void;
}
export interface KtxSetupPromptAdapterOptions {
selectCancelValue: 'back' | 'exit';
multiselectCancelValue?: 'back';
confirmEmptyOptionalMultiselect?: boolean;
cancelOnSelectCancel?: boolean;
cancelOnMultiselectCancel?: boolean;
cancelMessage?: string;
}
const DEFAULT_SETUP_CANCEL_MESSAGE = 'Setup cancelled.';
export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOptions): KtxSetupPromptAdapter {
const cancelMessage = options.cancelMessage ?? DEFAULT_SETUP_CANCEL_MESSAGE;
const cancelOnSelectCancel = options.cancelOnSelectCancel ?? true;
const cancelOnMultiselectCancel = options.cancelOnMultiselectCancel ?? true;
const multiselectCancelValue = options.multiselectCancelValue ?? 'back';
return {
async select(promptOptions) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(promptOptions)));
if (isCancel(value)) {
if (cancelOnSelectCancel) {
cancel(cancelMessage);
}
return options.selectCancelValue;
}
return String(value);
},
async multiselect(promptOptions) {
while (true) {
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(promptOptions)));
if (isCancel(value)) {
if (cancelOnMultiselectCancel) {
cancel(cancelMessage);
}
return [multiselectCancelValue];
}
const selected = [...value].map(String);
if (
selected.length === 0 &&
!promptOptions.required &&
options.confirmEmptyOptionalMultiselect === true
) {
const skipConfirmed = await confirm({
message: 'Nothing selected. Skip this step?',
initialValue: false,
});
if (isCancel(skipConfirmed)) {
cancel(cancelMessage);
return [multiselectCancelValue];
}
if (!skipConfirmed) {
continue;
}
}
return selected;
}
},
async text(promptOptions) {
const value = await withSetupInterruptConfirmation(() =>
text({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
);
return isCancel(value) ? undefined : String(value);
},
async password(promptOptions) {
const value = await withSetupInterruptConfirmation(() =>
password({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
);
return isCancel(value) ? undefined : String(value);
},
cancel(message) {
cancel(message);
},
log(message) {
log.info(message);
},
};
}
export interface KtxSetupUiAdapter {
intro(title: string, io: KtxCliIo): void;
note(message: string, title: string, io: KtxCliIo): void;
}
function isWritableTtyOutput(output: KtxCliIo['stdout']): output is KtxCliIo['stdout'] & Writable {
return (
output.isTTY === true &&
typeof (output as { on?: unknown }).on === 'function' &&
typeof (output as { columns?: unknown }).columns !== 'undefined'
);
}
export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
return {
intro(title, io) {
if (isWritableTtyOutput(io.stdout)) {
intro(title, { output: io.stdout });
return;
}
io.stdout.write(`${title}\n`);
},
note(message, title, io) {
if (isWritableTtyOutput(io.stdout)) {
note(message, title, { output: io.stdout });
return;
}
io.stdout.write(`\n${title}:\n`);
io.stdout.write(`${message}\n`);
},
};
}

View file

@ -1,12 +1,13 @@
import { cancel, isCancel, select } from '@clack/prompts';
import { withMenuOptionsSpacing } from './prompt-navigation.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
import type { KtxSetupStatus } from './setup.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
export type KtxSetupReadyAction = 'models' | 'embeddings' | 'databases' | 'sources' | 'context' | 'agents' | 'exit';
export interface KtxSetupReadyMenuPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
cancel(message: string): void;
}
@ -30,19 +31,7 @@ export function isKtxSetupReady(status: KtxSetupStatus): boolean {
}
function createPromptAdapter(): KtxSetupReadyMenuPromptAdapter {
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'exit';
}
return String(value);
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({ selectCancelValue: 'exit' });
}
export async function runKtxSetupReadyChangeMenu(

View file

@ -2,7 +2,6 @@ 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 { cancel, confirm, isCancel, log, multiselect, password, select, text } from '@clack/prompts';
import { localConnectionTypeForConfig, resolveNotionAuthToken } from '@ktx/context/connections';
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
@ -29,10 +28,13 @@ import {
import type { KtxCliIo } from './cli-runtime.js';
import { pickNotionRootPages } from './notion-page-picker.js';
import { runKtxSourceMapping } from './source-mapping.js';
import { withMenuOptionsSpacing, withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
import { runKtxPublicIngest } from './public-ingest.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
import { writeProjectLocalSecretReference } from './setup-secrets.js';
import {
createKtxSetupPromptAdapter,
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupSourceType = 'dbt' | 'metricflow' | 'metabase' | 'looker' | 'lookml' | 'notion';
@ -73,11 +75,11 @@ export type KtxSetupSourcesResult =
export interface KtxSetupSourcesPromptAdapter {
multiselect(options: {
message: string;
options: Array<{ value: string; label: string; hint?: string }>;
options: KtxSetupPromptOption[];
initialValues?: string[];
required?: boolean;
}): Promise<string[]>;
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
password(options: { message: string }): Promise<string | undefined>;
cancel(message: string): void;
@ -135,53 +137,11 @@ const PRIMARY_SOURCE_DRIVERS = new Set([
]);
function createPromptAdapter(): KtxSetupSourcesPromptAdapter {
return {
async multiselect(options) {
while (true) {
const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return ['back'];
}
const selected = [...value] as string[];
if (selected.length === 0 && !options.required) {
const skipConfirmed = await confirm({ message: 'Nothing selected. Skip this step?', initialValue: false });
if (isCancel(skipConfirmed)) {
cancel('Setup cancelled.');
return ['back'];
}
if (!skipConfirmed) continue;
}
return selected;
}
},
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
cancel('Setup cancelled.');
return 'back';
}
return String(value);
},
async text(options) {
const value = await withSetupInterruptConfirmation(() =>
text({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : String(value);
},
async password(options) {
const value = await withSetupInterruptConfirmation(() =>
password({ ...options, message: withTextInputNavigation(options.message) }),
);
return isCancel(value) ? undefined : String(value);
},
cancel(message) {
cancel(message);
},
log(message) {
log.info(message);
},
};
return createKtxSetupPromptAdapter({
selectCancelValue: 'back',
multiselectCancelValue: 'back',
confirmEmptyOptionalMultiselect: true,
});
}
function isRecord(value: unknown): value is Record<string, unknown> {

View file

@ -1,6 +1,5 @@
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { cancel, isCancel, select } from '@clack/prompts';
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
import {
ktxLocalStateDbPath,
@ -10,7 +9,7 @@ import {
} from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
import { isKtxSetupExitError } from './setup-interrupt.js';
import {
type KtxAgentScope,
type KtxAgentTarget,
@ -38,7 +37,12 @@ import {
runKtxSetupReadyChangeMenu,
} from './setup-ready-menu.js';
import { type KtxSetupSourcesDeps, type KtxSetupSourceType, runKtxSetupSourcesStep } from './setup-sources.js';
import { withMenuOptionsSpacing } from './prompt-navigation.js';
import {
createKtxSetupPromptAdapter,
createKtxSetupUiAdapter,
type KtxSetupPromptOption,
type KtxSetupUiAdapter,
} from './setup-prompts.js';
import {
readKtxSetupContextState,
type KtxSetupContextDeps,
@ -147,6 +151,7 @@ export interface KtxSetupDeps {
contextDeps?: KtxSetupContextDeps;
readyMenuDeps?: KtxSetupReadyMenuDeps;
entryMenuDeps?: KtxSetupEntryMenuDeps;
setupUi?: KtxSetupUiAdapter;
}
const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']);
@ -164,7 +169,7 @@ type KtxSetupFlowStatus =
| 'interrupted';
export interface KtxSetupEntryMenuPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
cancel(message: string): void;
}
@ -173,18 +178,10 @@ export interface KtxSetupEntryMenuDeps {
}
function createEntryMenuPromptAdapter(): KtxSetupEntryMenuPromptAdapter {
return {
async select(options) {
const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options)));
if (isCancel(value)) {
return 'exit';
}
return String(value);
},
cancel(message) {
cancel(message);
},
};
return createKtxSetupPromptAdapter({
selectCancelValue: 'exit',
cancelOnSelectCancel: false,
});
}
async function runKtxSetupEntryMenu(
@ -448,7 +445,8 @@ export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSet
}
async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
io.stdout.write('KTX setup\n');
const setupUi = deps.setupUi ?? createKtxSetupUiAdapter();
setupUi.intro('KTX setup', io);
let entryAction: KtxSetupEntryAction | undefined;
let projectResult: Awaited<ReturnType<typeof runKtxSetupProjectStep>>;
const canShowEntryMenu =
@ -745,14 +743,15 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
const status = await readKtxSetupStatus(projectResult.projectDir);
io.stdout.write(formatKtxSetupStatus(status));
io.stdout.write('\nWhat you can do next:\n');
io.stdout.write(
`${formatSetupNextStepLines({
setupUi.note(
formatSetupNextStepLines({
setupReady: setupStatusReady(status),
hasContextTargets: setupHasContextTargets(status),
contextReady: setupContextReady(status),
agentIntegrationReady: status.agents.some((agent) => agent.ready),
}).join('\n')}\n`,
}).join('\n'),
'What you can do next',
io,
);
return 0;
}