From bdca6d0f044b1cd9018c3f6d11073a9c90e4cb51 Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Tue, 12 May 2026 16:59:30 -0700 Subject: [PATCH 1/7] fix(cli): replace duplicate directory prompt with direct path options Extract confirmProjectDir helper and split the "Create a new project folder" option into "New subfolder (./ktx-project)" and "Custom path" so users reach their target directory with fewer prompts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/setup-project.test.ts | 42 +++--- packages/cli/src/setup-project.ts | 174 +++++++++++++++---------- 2 files changed, 123 insertions(+), 93 deletions(-) diff --git a/packages/cli/src/setup-project.test.ts b/packages/cli/src/setup-project.test.ts index 6c75d554..1df006b4 100644 --- a/packages/cli/src/setup-project.test.ts +++ b/packages/cli/src/setup-project.test.ts @@ -140,10 +140,11 @@ describe('setup project step', () => { expect(result.projectDir).toBe(projectDir); expect(prompts.select).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Which KTX project should setup use?', + message: 'Where should KTX create the project?', options: [ - expect.objectContaining({ value: 'current', label: 'Use current directory' }), - expect.objectContaining({ value: 'new', label: 'Create a new project folder' }), + expect.objectContaining({ value: 'current', label: 'Current directory' }), + expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }), + expect.objectContaining({ value: 'new-custom', label: 'Custom path' }), expect.objectContaining({ value: 'exit', label: 'Exit' }), ], }), @@ -156,7 +157,7 @@ describe('setup project step', () => { it('offers an absolute default destination for a new project folder', async () => { const startDir = join(tempDir, 'start'); const projectDir = join(startDir, 'ktx-project'); - const prompts = makePromptAdapter({ choices: ['new', 'default', 'create'] }); + const prompts = makePromptAdapter({ choices: ['new-default', 'create'] }); const testIo = makeIo({ stdoutIsTty: true }); const result = await runKtxSetupProjectStep( @@ -168,21 +169,16 @@ describe('setup project step', () => { expect(result.status).toBe('ready'); expect(result.projectDir).toBe(projectDir); expect(prompts.select).toHaveBeenNthCalledWith( - 2, + 1, expect.objectContaining({ message: 'Where should KTX create the project?', - options: [ - expect.objectContaining({ - value: 'default', - label: `Create the default project folder: ${projectDir}`, - }), - expect.objectContaining({ value: 'custom', label: 'Enter a custom path' }), - expect.objectContaining({ value: 'back', label: 'Back' }), - ], + options: expect.arrayContaining([ + expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }), + ]), }), ); expect(prompts.select).toHaveBeenNthCalledWith( - 3, + 2, expect.objectContaining({ message: `Create KTX project at ${projectDir}?` }), ); expect(prompts.text).not.toHaveBeenCalled(); @@ -194,7 +190,7 @@ describe('setup project step', () => { it('prompts for a custom path and resolves it inside the current setup directory', async () => { const startDir = join(tempDir, 'start'); const projectDir = join(startDir, 'analytics-ktx'); - const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: 'analytics-ktx' }); + const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: 'analytics-ktx' }); const result = await runKtxSetupProjectStep( { projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false }, @@ -217,7 +213,7 @@ describe('setup project step', () => { const startDir = join(tempDir, 'start'); const homeDir = join(tempDir, 'home'); const projectDir = join(homeDir, 'analytics-ktx'); - const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: '~/analytics-ktx' }); + const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: '~/analytics-ktx' }); const result = await runKtxSetupProjectStep( { projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false }, @@ -235,7 +231,7 @@ describe('setup project step', () => { const homeDir = join(tempDir, 'home'); const customProjectDir = join(homeDir, 'analytics-ktx'); const prompts = makePromptAdapter({ - choices: ['new', 'custom', 'back', 'exit'], + choices: ['new-custom', 'back', 'exit'], textValue: '~/analytics-ktx', }); @@ -248,7 +244,7 @@ describe('setup project step', () => { expect(result.status).toBe('cancelled'); expect(result.projectDir).toBe(startDir); expect(prompts.select).toHaveBeenNthCalledWith( - 3, + 2, expect.objectContaining({ message: `Create KTX project at ${customProjectDir}?`, options: [ @@ -259,15 +255,15 @@ describe('setup project step', () => { }), ); expect(prompts.select).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ message: 'Which KTX project should setup use?' }), + 3, + expect.objectContaining({ message: 'Where should KTX create the project?' }), ); await expect(stat(join(customProjectDir, 'ktx.yaml'))).rejects.toThrow(); }); it('rejects an empty new folder path without creating a project in the process cwd', async () => { const startDir = join(tempDir, 'start'); - const prompts = makePromptAdapter({ choices: ['new', 'custom'], textValue: ' ' }); + const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: ' ' }); const initProject = vi.fn(async () => { throw new Error('initProject should not run for an empty path'); }); @@ -292,7 +288,7 @@ describe('setup project step', () => { const projectDir = join(startDir, 'analytics-ktx'); await mkdir(projectDir, { recursive: true }); await writeFile(join(projectDir, 'README.md'), 'Existing project notes\n', 'utf-8'); - const prompts = makePromptAdapter({ choices: ['new', 'custom', 'use-existing'], textValue: 'analytics-ktx' }); + const prompts = makePromptAdapter({ choices: ['new-custom', 'use-existing'], textValue: 'analytics-ktx' }); const result = await runKtxSetupProjectStep( { projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false }, @@ -303,7 +299,7 @@ describe('setup project step', () => { expect(result.status).toBe('ready'); expect(result.projectDir).toBe(projectDir); expect(prompts.select).toHaveBeenNthCalledWith( - 3, + 2, expect.objectContaining({ message: `That folder already exists and is not empty: ${projectDir}`, options: expect.arrayContaining([ diff --git a/packages/cli/src/setup-project.ts b/packages/cli/src/setup-project.ts index 094f3f3f..ff7af6bd 100644 --- a/packages/cli/src/setup-project.ts +++ b/packages/cli/src/setup-project.ts @@ -109,6 +109,55 @@ async function existingFolderState( } } +type ConfirmProjectDirResult = + | { status: 'confirmed'; confirmedCreation: boolean } + | { status: 'choose-another' } + | { status: 'back' } + | { status: 'cancelled' } + | { status: 'not-directory' }; + +async function confirmProjectDir( + selectedDir: string, + io: KtxCliIo, + prompts: KtxSetupProjectPromptAdapter, +): Promise { + const state = await existingFolderState(selectedDir); + + if (state === 'not-directory') { + io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`); + return { status: 'not-directory' }; + } + + if (state === 'non-empty-directory') { + const action = await prompts.select({ + message: `That folder already exists and is not empty: ${selectedDir}`, + options: [ + { value: 'use-existing', label: 'Yes, create KTX files there' }, + { value: 'choose-another', label: 'Choose another folder' }, + { value: 'back', label: 'Back' }, + ], + }); + if (action === 'choose-another') return { status: 'choose-another' }; + if (action === 'back') return { status: 'back' }; + if (action !== 'use-existing') return { status: 'cancelled' }; + return { status: 'confirmed', confirmedCreation: true }; + } + + io.stdout.write(`KTX will create:\n ${selectedDir}\n`); + const action = await prompts.select({ + message: `Create KTX project at ${selectedDir}?`, + options: [ + { value: 'create', label: 'Create project' }, + { value: 'choose-another', label: 'Choose another folder' }, + { value: 'back', label: 'Back' }, + ], + }); + if (action === 'choose-another') return { status: 'choose-another' }; + if (action === 'back') return { status: 'back' }; + if (action !== 'create') return { status: 'cancelled' }; + return { status: 'confirmed', confirmedCreation: true }; +} + async function normalizeSetupGitignore(projectDir: string): Promise { const gitignorePath = join(projectDir, '.ktx/.gitignore'); await mkdir(join(projectDir, '.ktx'), { recursive: true }); @@ -186,55 +235,12 @@ async function promptForNewProjectDir( return { status: 'cancelled', projectDir }; } - const state = await existingFolderState(selectedDir); - let confirmedCreation = false; - if (state === 'not-directory') { - io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`); - return { status: 'missing-input', projectDir }; - } - if (state === 'non-empty-directory') { - const existingAction = await prompts.select({ - message: `That folder already exists and is not empty: ${selectedDir}`, - options: [ - { value: 'use-existing', label: 'Yes, create KTX files there' }, - { value: 'choose-another', label: 'Choose another folder' }, - { value: 'back', label: 'Back' }, - ], - }); - if (existingAction === 'choose-another') { - continue; - } - if (existingAction === 'back') { - return { status: 'back', projectDir }; - } - if (existingAction !== 'use-existing') { - return { status: 'cancelled', projectDir }; - } - confirmedCreation = true; - } - - io.stdout.write(`KTX will create:\n ${selectedDir}\n`); - if (state !== 'non-empty-directory') { - const createAction = await prompts.select({ - message: `Create KTX project at ${selectedDir}?`, - options: [ - { value: 'create', label: 'Create project' }, - { value: 'choose-another', label: 'Choose another folder' }, - { value: 'back', label: 'Back' }, - ], - }); - if (createAction === 'choose-another') { - continue; - } - if (createAction === 'back') { - return { status: 'back', projectDir }; - } - if (createAction !== 'create') { - return { status: 'cancelled', projectDir }; - } - confirmedCreation = true; - } - return { status: 'selected', projectDir: selectedDir, confirmedCreation }; + const confirmed = await confirmProjectDir(selectedDir, io, prompts); + if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir }; + if (confirmed.status === 'choose-another') continue; + if (confirmed.status === 'back') return { status: 'back', projectDir }; + if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir }; + return { status: 'selected', projectDir: selectedDir, confirmedCreation: confirmed.confirmedCreation }; } } @@ -316,15 +322,17 @@ export async function runKtxSetupProjectStep( } const prompts = deps.prompts ?? createClackSetupProjectPromptAdapter(); + const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME); io.stdout.write( 'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n', ); while (true) { const choice = await prompts.select({ - message: 'Which KTX project should setup use?', + message: 'Where should KTX create the project?', options: [ - { value: 'current', label: 'Use current directory' }, - { value: 'new', label: 'Create a new project folder' }, + { value: 'current', label: 'Current directory' }, + { value: 'new-default', label: 'New subfolder (./ktx-project)' }, + { value: 'new-custom', label: 'Custom path' }, ...(args.allowBack ? [{ value: 'back', label: 'Back' }] : []), ...(args.allowBack ? [] : [{ value: 'exit', label: 'Exit' }]), ], @@ -339,27 +347,53 @@ export async function runKtxSetupProjectStep( return { status: 'cancelled', projectDir }; } - let selectedDir = projectDir; - let confirmedCreation = false; - if (choice === 'new') { - const selected = await promptForNewProjectDir(projectDir, homeDir, io, prompts); - if (selected.status === 'back') { - continue; - } - if (selected.status !== 'selected') { - return selected; - } - selectedDir = selected.projectDir; - confirmedCreation = selected.confirmedCreation; + if (choice === 'current') { + const project = await createProject(projectDir, deps); + printProjectSummary(io, projectDir); + return { status: 'ready', projectDir, project }; } - if (choice !== 'current' && choice !== 'new') { - prompts.cancel('Setup cancelled.'); - return { status: 'cancelled', projectDir }; + if (choice === 'new-default') { + const confirmed = await confirmProjectDir(defaultProjectDir, io, prompts); + if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue; + if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir }; + if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir }; + const project = await createProject(defaultProjectDir, deps); + printProjectSummary(io, defaultProjectDir); + return { + status: 'ready', + projectDir: defaultProjectDir, + project, + confirmedCreation: confirmed.confirmedCreation, + }; } - const project = await createProject(selectedDir, deps); - printProjectSummary(io, selectedDir); - return { status: 'ready', projectDir: selectedDir, project, confirmedCreation }; + if (choice === 'new-custom') { + io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`); + io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`); + const rawPath = await prompts.text({ + message: withTextInputNavigation('Project folder path'), + placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx', + }); + if (rawPath === undefined) continue; + const trimmed = rawPath.trim(); + if (trimmed.length === 0) { + io.stderr.write( + 'Enter a relative path like ./analytics-ktx, a home path like ~/analytics-ktx, or an absolute path.\n', + ); + return { status: 'missing-input', projectDir }; + } + const customDir = resolveFromProjectDir(projectDir, trimmed, homeDir); + const confirmed = await confirmProjectDir(customDir, io, prompts); + if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue; + if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir }; + if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir }; + const project = await createProject(customDir, deps); + printProjectSummary(io, customDir); + return { status: 'ready', projectDir: customDir, project, confirmedCreation: confirmed.confirmedCreation }; + } + + prompts.cancel('Setup cancelled.'); + return { status: 'cancelled', projectDir }; } } From d567ffec48ae00e7bcfb73c54eee02011b9309bb Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Tue, 12 May 2026 17:14:35 -0700 Subject: [PATCH 2/7] feat(cli): offer connection URL paste first in database setup Users most commonly paste a connection URL rather than entering fields individually, so surface that option first in the connection method prompt. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/setup-databases.test.ts | 41 ++++++++++++++++++++++-- packages/cli/src/setup-databases.ts | 2 +- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index 4c2abfcd..36b8df07 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -142,8 +142,8 @@ describe('setup databases step', () => { expect(prompts.select).toHaveBeenCalledWith({ message: 'How do you want to connect to PostgreSQL?', options: [ - { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'back', label: 'Back' }, ], }); @@ -154,6 +154,43 @@ describe('setup databases step', () => { ); }); + it('offers connection URL paste first for URL-capable primary sources', async () => { + const cases: Array<{ driver: KtxSetupDatabaseDriver; label: string }> = [ + { driver: 'postgres', label: 'PostgreSQL' }, + { driver: 'mysql', label: 'MySQL' }, + { driver: 'clickhouse', label: 'ClickHouse' }, + { driver: 'sqlserver', label: 'SQL Server' }, + ]; + + for (const testCase of cases) { + const prompts = makePromptAdapter({ + selectValues: ['back'], + }); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + databaseDrivers: [testCase.driver], + skipDatabases: false, + databaseSchemas: [], + }, + makeIo().io, + { prompts }, + ); + + expect(result.status).toBe('back'); + expect(prompts.select).toHaveBeenCalledWith({ + message: `How do you want to connect to ${testCase.label}?`, + options: [ + { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, + { value: 'back', label: 'Back' }, + ], + }); + } + }); + it('lets Back leave database setup when the driver came from flags', async () => { const prompts = makePromptAdapter({ selectValues: ['back'] }); @@ -488,8 +525,8 @@ describe('setup databases step', () => { expect(prompts.select).toHaveBeenNthCalledWith(1, { message: 'How do you want to connect to PostgreSQL?', options: [ - { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'back', label: 'Back' }, ], }); diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index bbb39836..e431baa0 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -615,8 +615,8 @@ async function buildUrlConnectionConfig(input: { const choice = await input.prompts.select({ message: `How do you want to connect to ${label}?`, options: [ - { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, { value: 'back', label: 'Back' }, ], }); From a2096dd847540f6be4a2eec1135912dba63c4060 Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Tue, 12 May 2026 17:14:56 -0700 Subject: [PATCH 3/7] feat(cli): hide table counts from primary source connection test output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table counts during connection testing are noisy and not actionable for users — the scan step already reports detailed schema information. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/setup-databases.test.ts | 3 ++- packages/cli/src/setup-databases.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index 36b8df07..697ee10a 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -955,10 +955,11 @@ describe('setup databases step', () => { [ '◇ Testing postgres-warehouse', '│ ✓ Connection test passed', - '│ Driver: PostgreSQL · Tables: 2', + '│ Driver: PostgreSQL', '│', ].join('\n'), ); + expect(io.stdout()).not.toContain('Tables: 2'); expect(io.stdout()).toContain( [ '◇ Scanning postgres-warehouse', diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index e431baa0..18ff7e74 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -1184,9 +1184,7 @@ async function validateAndScanConnection(input: { const testOutput = testIo.stdoutText(); const outputDriver = normalizeDriver(readOutputValue(testOutput, 'Driver')); const driverDisplay = outputDriver ? driverLabel(outputDriver) : (configuredDriverLabel ?? 'Unknown driver'); - const tableCount = Number(readOutputValue(testOutput, 'Tables') ?? NaN); - const testLines = ['✓ Connection test passed']; - testLines.push(`Driver: ${driverDisplay}${Number.isFinite(tableCount) ? ` · Tables: ${tableCount}` : ''}`); + const testLines = ['✓ Connection test passed', `Driver: ${driverDisplay}`]; writeSetupSection(input.io, `Testing ${input.connectionId}`, testLines); if (!(await maybeConfigureSchemaScope(input))) { From 52d1c903aea318782a4bae499badafaaa8872a5b Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Tue, 12 May 2026 17:17:18 -0700 Subject: [PATCH 4/7] fix(cli): remove redundant path resolution hints from project setup The placeholder text and confirmation step already communicate path formats clearly; the standalone hints added visual noise. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/setup-project.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/cli/src/setup-project.ts b/packages/cli/src/setup-project.ts index 4d18861c..18512b03 100644 --- a/packages/cli/src/setup-project.ts +++ b/packages/cli/src/setup-project.ts @@ -204,8 +204,6 @@ async function promptForNewProjectDir( const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME); while (true) { - io.stdout.write(`│ Relative paths are resolved from:\n│ ${projectDir}\n`); - io.stdout.write(`│ Home paths are resolved from:\n│ ${homeDir}\n`); const destinationChoice = await prompts.select({ message: 'Where should KTX create the project?', options: [ @@ -376,8 +374,6 @@ export async function runKtxSetupProjectStep( } if (choice === 'new-custom') { - io.stdout.write(`│ Relative paths are resolved from:\n│ ${projectDir}\n`); - io.stdout.write(`│ Home paths are resolved from:\n│ ${homeDir}\n`); const rawPath = await prompts.text({ message: withTextInputNavigation('Project folder path'), placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx', From 262276dcd73a96c08beffb336d46d3da23d34ba3 Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Tue, 12 May 2026 17:54:18 -0700 Subject: [PATCH 5/7] feat(cli): add intro step and project dir to demo tour Show the target project directory in the demo banner and add an introductory screen before the first setup card so users understand where demo artifacts will land. Also simplify stdin key detection by comparing raw byte values instead of string conversions. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/setup-demo-tour.test.ts | 17 ++++++------ packages/cli/src/setup-demo-tour.ts | 34 +++++++++++++++--------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/setup-demo-tour.test.ts b/packages/cli/src/setup-demo-tour.test.ts index d3f7e3f4..a8b63974 100644 --- a/packages/cli/src/setup-demo-tour.test.ts +++ b/packages/cli/src/setup-demo-tour.test.ts @@ -209,7 +209,7 @@ describe('runDemoTour', () => { }, ); expect(result).toBe(0); - // Navigation called once for databases step, then exits + // Navigation called once for intro, then exits on back expect(navigation).toHaveBeenCalledTimes(1); }); @@ -218,10 +218,11 @@ describe('runDemoTour', () => { let callCount = 0; const navigation = vi.fn().mockImplementation(() => { callCount++; - // First call (databases): forward - // Second call (sources): back - // Third call (databases again): back (exit) - if (callCount === 1) return Promise.resolve('forward'); + // First call (intro): forward + // Second call (databases): forward + // Third call (sources): back + // Fourth call (databases again): back (exit) + if (callCount <= 2) return Promise.resolve('forward'); return Promise.resolve('back'); }); @@ -235,7 +236,7 @@ describe('runDemoTour', () => { }, ); expect(result).toBe(0); - expect(navigation).toHaveBeenCalledTimes(3); + expect(navigation).toHaveBeenCalledTimes(4); }); it('handles agent step returning back', async () => { @@ -243,10 +244,10 @@ describe('runDemoTour', () => { let navCount = 0; const navigation = vi.fn().mockImplementation(() => { navCount++; - // Forward through databases, sources, context + // Forward through intro, databases, sources, context // Then back from context (after agents returns back) // Then back from sources, then back from databases (exit) - if (navCount <= 3) return Promise.resolve('forward'); + if (navCount <= 4) return Promise.resolve('forward'); return Promise.resolve('back'); }); diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts index f3d71f70..fe211e67 100644 --- a/packages/cli/src/setup-demo-tour.ts +++ b/packages/cli/src/setup-demo-tour.ts @@ -62,12 +62,15 @@ function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTarge // Pure rendering functions // --------------------------------------------------------------------------- -export function renderDemoBanner(): string { +export function renderDemoBanner(projectDir?: string): string { const lines = [ '', `┌ ${cyan('Demo mode')} — data has been pre-processed and KTX context is already built.`, '│ This walkthrough illustrates the setup steps. Selections are pre-filled and read-only.', ]; + if (projectDir) { + lines.push(`│ Project directory: ${dim(projectDir)}`); + } return lines.join('\n'); } @@ -144,16 +147,15 @@ export async function waitForDemoNavigation( }; const onData = (data: Buffer) => { - const char = data.toString(); - if (char === '\r' || char === '\n') { - cleanup(); - resolve('forward'); - } else if (char === '\x1b') { - cleanup(); - resolve('back'); - } else if (char === '\x03') { + if (data[0] === 0x03) { cleanup(); reject(new KtxSetupExitError()); + } else if (data[0] === 0x0d || data[0] === 0x0a) { + cleanup(); + resolve('forward'); + } else if (data[0] === 0x1b) { + cleanup(); + resolve('back'); } }; @@ -171,8 +173,9 @@ export async function renderDemoCard( io: KtxCliIo, stdin?: NodeJS.ReadStream, waitNav: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'> = waitForDemoNavigation, + projectDir?: string, ): Promise<'forward' | 'back'> { - io.stdout.write(renderDemoBanner() + '\n\n'); + io.stdout.write(renderDemoBanner(projectDir) + '\n\n'); io.stdout.write(renderDemoCardContent(title, selections) + '\n'); return waitNav(stdin); } @@ -337,6 +340,11 @@ export async function runDemoTour( const projectDir = defaultDemoProjectDir(); await ensureProject({ projectDir, force: false }); + io.stdout.write(renderDemoBanner(projectDir) + '\n'); + io.stdout.write(`\n│ ${dim('Press Enter to continue, Escape to go back')}\n└\n`); + const introDirection = await waitNav(); + if (introDirection === 'back') return 0; + let stepIndex = 0; while (stepIndex < DEMO_STEPS.length) { @@ -344,11 +352,11 @@ export async function runDemoTour( let direction: 'forward' | 'back'; if (step === 'databases') { - direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav); + direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav, projectDir); } else if (step === 'sources') { - direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav); + direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav, projectDir); } else if (step === 'context') { - io.stdout.write(renderDemoBanner() + '\n\n'); + io.stdout.write(renderDemoBanner(projectDir) + '\n\n'); if (deps.skipReplayAnimation) { direction = await waitNav(); } else { From 8ceb3bc7b9eb04c2ce510f55caa5354e7041f683 Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Tue, 12 May 2026 18:23:03 -0700 Subject: [PATCH 6/7] Confirm skipped optional setup selections --- packages/cli/src/setup-agents.ts | 23 +++++++++++++++++------ packages/cli/src/setup-databases.ts | 23 +++++++++++++++++------ packages/cli/src/setup-sources.ts | 23 +++++++++++++++++------ 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 6a9721b9..36ff659e 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { cancel, isCancel, multiselect, select } from '@clack/prompts'; +import { cancel, confirm, isCancel, multiselect, select } from '@clack/prompts'; import { loadKtxProject, markKtxSetupStateStepComplete, @@ -277,12 +277,23 @@ function createPromptAdapter(): KtxSetupAgentsPromptAdapter { return String(value); }, async multiselect(options) { - const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); - if (isCancel(value)) { - cancel('Setup cancelled.'); - return ['back']; + 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; } - return [...value] as string[]; }, cancel(message) { cancel(message); diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 18ff7e74..caac2841 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -1,5 +1,5 @@ import { writeFile } from 'node:fs/promises'; -import { cancel, isCancel, multiselect, password, select, text } from '@clack/prompts'; +import { cancel, confirm, isCancel, multiselect, password, select, text } from '@clack/prompts'; import type { HistoricSqlDialect } from '@ktx/context/ingest'; import { type KtxProjectConnectionConfig, @@ -203,12 +203,23 @@ function missingConnectionDetailsPrompt( function createPromptAdapter(): KtxSetupDatabasesPromptAdapter { return { async multiselect(options) { - const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); - if (isCancel(value)) { - cancel('Setup cancelled.'); - return ['back']; + 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; } - return [...value] as string[]; }, async select(options) { const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options))); diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index dc010b0a..695fc1c1 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -2,7 +2,7 @@ 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, isCancel, log, multiselect, password, select, text } from '@clack/prompts'; +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 { @@ -136,12 +136,23 @@ const PRIMARY_SOURCE_DRIVERS = new Set([ function createPromptAdapter(): KtxSetupSourcesPromptAdapter { return { async multiselect(options) { - const value = await withSetupInterruptConfirmation(() => multiselect(withMenuOptionsSpacing(options))); - if (isCancel(value)) { - cancel('Setup cancelled.'); - return ['back']; + 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; } - return [...value] as string[]; }, async select(options) { const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(options))); From 2ede86263de3347b86c81a0541075bab0dbb9bef Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Tue, 12 May 2026 18:23:04 -0700 Subject: [PATCH 7/7] Align agent setup completion test with state file --- packages/cli/src/setup-agents.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index 9a984352..3f771420 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { readKtxSetupState } from '@ktx/context/project'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { formatInstallSummary, @@ -89,7 +90,7 @@ describe('setup agents', () => { projectDir: tempDir, installs: [{ target: 'universal', scope: 'project', mode: 'cli' }], }); - expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('agents'); + expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] }); expect(io.stderr()).toBe(''); });