diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index fc24f1e7..ad0dfd44 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -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; } diff --git a/packages/cli/src/context-build-view.test.ts b/packages/cli/src/context-build-view.test.ts index 3df1f6d7..c8dc5130 100644 --- a/packages/cli/src/context-build-view.test.ts +++ b/packages/cli/src/context-build-view.test.ts @@ -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> = []; + 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); + }); }); diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index e021b144..38f3d674 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -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 { + 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): 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 { + 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(); const artifactPaths = new Set(); + 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); diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index 24f8c1ca..ece7bb5a 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -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 => { + 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 => { + 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); diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index 6e0648b5..c1096b2b 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -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, - 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, + 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({ diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index f8296177..830e1597 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -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[0]) => Promise; - runScan?: (args: KtxScanArgs, io: KtxCliIo) => Promise; - runIngest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise; + runScan?: (args: KtxScanArgs, io: KtxCliIo, deps?: KtxScanDeps) => Promise; + runIngest?: (args: KtxIngestArgs, io: KtxCliIo, deps?: KtxIngestDeps) => Promise; + scanProgress?: KtxProgressPort; + ingestProgress?: (update: KtxIngestProgressUpdate) => void; } const sourceAdapterByDriver = new Map([ @@ -247,33 +250,35 @@ export async function executePublicIngestTarget( ): Promise { 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'); } diff --git a/packages/cli/src/scan.test.ts b/packages/cli/src/scan.test.ts index 74d52f35..28c60ea0 100644 --- a/packages/cli/src/scan.test.ts +++ b/packages/cli/src/scan.test.ts @@ -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 => { + 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; diff --git a/packages/cli/src/scan.ts b/packages/cli/src/scan.ts index bca6057d..ef5679cc 100644 --- a/packages/cli/src/scan.ts +++ b/packages/cli/src/scan.ts @@ -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) { diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index da9486f5..9505307d 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -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; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; multiselect(options: { message: string; - options: Array<{ value: string; label: string }>; + options: KtxSetupPromptOption[]; required?: boolean; }): Promise; 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 = { diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/src/setup-context.test.ts index 9115d7a5..d1a658d1 100644 --- a/packages/cli/src/setup-context.test.ts +++ b/packages/cli/src/setup-context.test.ts @@ -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, { diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 94589bdc..8d0d1aeb 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -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; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; 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; diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 58ee61d9..67ad6938 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -1,5 +1,4 @@ import { writeFile } from 'node:fs/promises'; -import { cancel, confirm, isCancel, multiselect, password, select, text } from '@clack/prompts'; import type { HistoricSqlDialect } from '@ktx/context/ingest'; import { type KtxProjectConnectionConfig, @@ -11,10 +10,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; @@ -55,11 +57,11 @@ export type KtxSetupDatabasesResult = export interface KtxSetupDatabasesPromptAdapter { multiselect(options: { message: string; - options: Array<{ value: string; label: string }>; + options: KtxSetupPromptOption[]; required?: boolean; initialValues?: string[]; }): Promise; - select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; text(options: { message: string; placeholder?: string; initialValue?: string }): Promise; password(options: { message: string }): Promise; cancel(message: string): void; @@ -202,50 +204,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 { diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts index fe211e67..40583168 100644 --- a/packages/cli/src/setup-demo-tour.ts +++ b/packages/cli/src/setup-demo-tour.ts @@ -55,6 +55,7 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge failureText: null, startedAt: null, elapsedMs: 0, + progressUpdatedAtMs: null, }; } diff --git a/packages/cli/src/setup-embeddings.test.ts b/packages/cli/src/setup-embeddings.test.ts index e66aa05a..c2d5cad2 100644 --- a/packages/cli/src/setup-embeddings.test.ts +++ b/packages/cli/src/setup-embeddings.test.ts @@ -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}`), })); diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index ba3333f1..d9b43a75 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -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; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; password(options: { message: string }): Promise; 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 { @@ -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' }, ], }); diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index 2e83ade2..e310ea90 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -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' }, ]), }), ); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 37ebdeec..bd05bd44 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -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; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; text(options: { message: string; placeholder?: string }): Promise; password(options: { message: string }): Promise; cancel(message: string): void; @@ -145,31 +147,7 @@ interface GcloudProjectChoice { type GcloudCommandRunner = (args: string[], io: KtxCliIo) => Promise; 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' }, diff --git a/packages/cli/src/setup-project.ts b/packages/cli/src/setup-project.ts index a6b4ca71..fa2dd3ed 100644 --- a/packages/cli/src/setup-project.ts +++ b/packages/cli/src/setup-project.ts @@ -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; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; text(options: { message: string; placeholder?: string }): Promise; 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 { diff --git a/packages/cli/src/setup-prompts.test.ts b/packages/cli/src/setup-prompts.test.ts new file mode 100644 index 00000000..23ffd669 --- /dev/null +++ b/packages/cli/src/setup-prompts.test.ts @@ -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) => 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 }); + }); +}); diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts new file mode 100644 index 00000000..ad97ec48 --- /dev/null +++ b/packages/cli/src/setup-prompts.ts @@ -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: Value; + label: string; + hint?: string; + disabled?: boolean; +} + +interface KtxSetupSelectOptions { + message: string; + options: Array>; + initialValue?: Value; + maxItems?: number; +} + +interface KtxSetupMultiselectOptions { + message: string; + options: Array>; + 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; + multiselect(options: KtxSetupMultiselectOptions): Promise; + text(options: KtxSetupTextOptions): Promise; + password(options: KtxSetupPasswordOptions): Promise; + 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`); + }, + }; +} diff --git a/packages/cli/src/setup-ready-menu.ts b/packages/cli/src/setup-ready-menu.ts index a101e45a..c975d991 100644 --- a/packages/cli/src/setup-ready-menu.ts +++ b/packages/cli/src/setup-ready-menu.ts @@ -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; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; 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( diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 313dfbe0..173528bd 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -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,10 +75,10 @@ export type KtxSetupSourcesResult = export interface KtxSetupSourcesPromptAdapter { multiselect(options: { message: string; - options: Array<{ value: string; label: string }>; + options: KtxSetupPromptOption[]; required?: boolean; }): Promise; - select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; text(options: { message: string; placeholder?: string; initialValue?: string }): Promise; password(options: { message: string }): Promise; cancel(message: string): void; @@ -134,53 +136,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 { diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 1ab48f0b..b2837a64 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -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, @@ -148,6 +152,7 @@ export interface KtxSetupDeps { contextDeps?: KtxSetupContextDeps; readyMenuDeps?: KtxSetupReadyMenuDeps; entryMenuDeps?: KtxSetupEntryMenuDeps; + setupUi?: KtxSetupUiAdapter; } const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']); @@ -165,7 +170,7 @@ type KtxSetupFlowStatus = | 'interrupted'; export interface KtxSetupEntryMenuPromptAdapter { - select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise; + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; cancel(message: string): void; } @@ -174,18 +179,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( @@ -449,7 +446,8 @@ export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSet } async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise { - io.stdout.write('KTX setup\n'); + const setupUi = deps.setupUi ?? createKtxSetupUiAdapter(); + setupUi.intro('KTX setup', io); let entryAction: KtxSetupEntryAction | undefined; let projectResult: Awaited>; const canShowEntryMenu = @@ -747,14 +745,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; }