diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 5129c585..35ec6009 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -215,8 +215,8 @@ The wizard walks you through everything **ktx** needs in one pass: SQLite, PostgreSQL, MySQL, SQL Server, BigQuery, and Snowflake. 5. **Context sources** - optionally adds dbt, MetricFlow, LookML, Looker, Metabase, or Notion. You can skip and add them later. -6. **Build** - runs the first ingest so semantic sources and wiki pages - are ready for agents. +6. **Build** - offers to run the first ingest so semantic sources and wiki + pages are ready for agents. If you skip it, build later with `ktx ingest`. 7. **Agent integration** - installs project-local rules for Claude Code, Codex, Cursor, OpenCode, or universal `.agents`. @@ -247,6 +247,18 @@ progress under `.ktx/setup/` and resumes from the remaining work. > resuming setup, connecting an agent, checking status, or exploring a > pre-built demo project. +When the wizard finishes, it states where you stand and the single next action: + +- **Context built** - **ktx** confirms it is ready for agents and points you to + open your coding agent and ask a data question. +- **Build skipped** - **ktx** tells you setup is complete and that the only step + left is to build context with `ktx ingest`. + +Re-running `ktx setup` on an already-configured project goes straight to the +remaining step - building context or connecting an agent - instead of +re-asking every question. Once everything is ready, it confirms you are set +rather than reopening the configuration menu. + ## Verify When setup finishes, check readiness: @@ -268,6 +280,9 @@ Agent integration ready: yes (codex:project) For a structured check inside scripts, use `ktx status --json`. +If you skipped the build, `ktx context built` shows `no`. Build it with +`ktx ingest` - there is no need to re-run `ktx setup`. + When setup finishes building context, its final context check looks like: ```text diff --git a/packages/cli/src/next-steps.ts b/packages/cli/src/next-steps.ts index 80a1b441..b6726e3c 100644 --- a/packages/cli/src/next-steps.ts +++ b/packages/cli/src/next-steps.ts @@ -70,8 +70,7 @@ export function formatSetupNextStepLines(state: KtxSetupNextStepState, indent = if (!state.contextReady) { return [ - `${indent}Build KTX context next.`, - `${indent}Run ingest to build database schema context before context-source ingest.`, + `${indent}Setup is complete. The only step left is to build context for your agents.`, ...commandLines(KTX_CONTEXT_BUILD_COMMANDS, indent), ]; } diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index d6ef2639..721c09bd 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -441,12 +441,10 @@ function writeMissingCapabilities(missing: string[], io: KtxCliIo): void { io.stderr.write('\nFix this in setup before building context.\n'); } -function writeSkippedContext(projectDir: string, io: KtxCliIo): void { - io.stdout.write('\nKTX is configured, but context has not been built yet.\n\n'); - io.stdout.write('Agents were not connected because KTX has not prepared searchable context for them.\n\n'); - io.stdout.write(`Resume setup:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`); - io.stdout.write(`Build context:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`); - io.stdout.write(`Check status:\n ktx status --project-dir ${resolve(projectDir)}\n`); +function writeSkippedContext(io: KtxCliIo): void { + // The setup completion screen owns "what to do next" (it points at `ktx ingest`), + // so keep this to a short acknowledgement rather than a competing command list. + io.stdout.write('\nLeaving context unbuilt for now.\n'); } function writeSuccess( @@ -695,7 +693,7 @@ export async function runKtxSetupContextStep( return { status: 'back', projectDir: args.projectDir }; } if (choice === 'skip') { - writeSkippedContext(args.projectDir, io); + writeSkippedContext(io); return { status: 'skipped', projectDir: args.projectDir }; } } diff --git a/packages/cli/src/setup-ready-menu.ts b/packages/cli/src/setup-ready-menu.ts index f1f736e4..de0f5a45 100644 --- a/packages/cli/src/setup-ready-menu.ts +++ b/packages/cli/src/setup-ready-menu.ts @@ -14,6 +14,12 @@ export type KtxSetupReadyAction = | 'agents' | 'exit'; +/** + * Where a project stands once its `ktx.yaml` exists. Single source of truth for the + * end-of-setup interception: each state maps to exactly one obvious next action. + */ +export type KtxSetupCompletion = 'incomplete' | 'needs-context' | 'needs-agents' | 'ready'; + interface KtxSetupReadyMenuPromptAdapter { select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; cancel(message: string): void; @@ -23,7 +29,11 @@ export interface KtxSetupReadyMenuDeps { prompts?: KtxSetupReadyMenuPromptAdapter; } -export function isKtxPreAgentSetupReady(status: KtxSetupStatus): boolean { +export function setupHasContextTargets(status: KtxSetupStatus): boolean { + return status.databases.length > 0 || status.sources.length > 0; +} + +function setupConfigReady(status: KtxSetupStatus): boolean { return ( status.project.ready && status.llm.ready && @@ -31,25 +41,58 @@ export function isKtxPreAgentSetupReady(status: KtxSetupStatus): boolean { status.databases.every((database) => database.ready) && status.sources.every((source) => source.ready) && status.runtime.ready && - status.context.ready + setupHasContextTargets(status) ); } -export function isKtxSetupReady(status: KtxSetupStatus): boolean { - return isKtxPreAgentSetupReady(status) && status.agents.some((agent) => agent.ready); +export function classifyKtxSetupCompletion(status: KtxSetupStatus): KtxSetupCompletion { + if (!setupConfigReady(status)) { + return 'incomplete'; + } + if (!status.context.ready) { + return 'needs-context'; + } + if (!status.agents.some((agent) => agent.ready)) { + return 'needs-agents'; + } + return 'ready'; } function createPromptAdapter(): KtxSetupReadyMenuPromptAdapter { return createKtxSetupPromptAdapter({ selectCancelValue: 'exit' }); } +/** + * Shown when a returning user re-runs `ktx setup` on a fully-ready project. Leads with + * "you're done" (the readiness note is printed by the caller first) and keeps the + * section editor one explicit step away rather than defaulting into it. + */ +export async function runKtxSetupReadyMenu( + status: KtxSetupStatus, + deps: KtxSetupReadyMenuDeps = {}, +): Promise<{ action: KtxSetupReadyAction }> { + const prompts = deps.prompts ?? createPromptAdapter(); + const choice = await prompts.select({ + message: 'Anything else?', + options: [ + { value: 'done', label: "Done — I'll start using ktx" }, + { value: 'change', label: 'Change a setting' }, + ], + }); + if (choice !== 'change') { + return { action: 'exit' }; + } + return runKtxSetupReadyChangeMenu(status, { prompts }); +} + +/** @internal Reached only through {@link runKtxSetupReadyMenu}; exported for unit tests. */ export async function runKtxSetupReadyChangeMenu( status: KtxSetupStatus, deps: KtxSetupReadyMenuDeps = {}, ): Promise<{ action: KtxSetupReadyAction }> { const prompts = deps.prompts ?? createPromptAdapter(); const action = (await prompts.select({ - message: `KTX is already set up for ${status.project.name ?? status.project.path}. What would you like to change?`, + message: 'What would you like to change?', options: [ { value: 'models', label: 'Models' }, { value: 'embeddings', label: 'Embeddings' }, diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index f8fc2064..7d4fdb0e 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -6,7 +6,7 @@ import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; import { readKtxSetupState } from './context/project/setup-config.js'; import { getKtxCliPackageInfo, type KtxCliIo } from './cli-runtime.js'; -import { formatSetupNextStepLines } from './next-steps.js'; +import { formatNextStepLines, formatSetupNextStepLines } from './next-steps.js'; import { runtimeInstallPolicyFromFlags } from './managed-python-command.js'; import { readManagedPythonRuntimeStatus } from './managed-python-runtime.js'; import { resolveProjectRuntimeRequirements } from './runtime-requirements.js'; @@ -33,10 +33,10 @@ import { } from './setup-models.js'; import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js'; import { - isKtxPreAgentSetupReady, - isKtxSetupReady, + classifyKtxSetupCompletion, type KtxSetupReadyMenuDeps, - runKtxSetupReadyChangeMenu, + runKtxSetupReadyMenu, + setupHasContextTargets, } from './setup-ready-menu.js'; import { type KtxSetupSourcesDeps, type KtxSetupSourceType, runKtxSetupSourcesStep } from './setup-sources.js'; import { @@ -529,10 +529,6 @@ function setupStatusReady(status: KtxSetupStatus): boolean { ); } -function setupHasContextTargets(status: KtxSetupStatus): boolean { - return status.databases.length > 0 || status.sources.length > 0; -} - function setupContextReady(status: KtxSetupStatus): boolean { return status.context.ready; } @@ -630,12 +626,19 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup let readyAction: string | undefined; if (args.inputMode !== 'disabled' && !agentsRequested) { - if (isKtxSetupReady(currentStatus)) { - readyAction = (await runKtxSetupReadyChangeMenu(currentStatus, deps.readyMenuDeps)).action; - if (readyAction === 'exit') return 0; - } else if (isKtxPreAgentSetupReady(currentStatus)) { + const completion = classifyKtxSetupCompletion(currentStatus); + if (completion === 'ready') { + setupUi.note(formatNextStepLines().join('\n'), 'ktx is ready', io); + const choice = (await runKtxSetupReadyMenu(currentStatus, deps.readyMenuDeps)).action; + if (choice === 'exit') return 0; + readyAction = choice; + } else if (completion === 'needs-context') { + // Config is done; skip the re-walk and land straight on the build prompt. + readyAction = 'context'; + } else if (completion === 'needs-agents') { readyAction = 'agents'; } + // 'incomplete' → readyAction stays undefined → run the full setup walk. } const runOnly = readyAction; @@ -872,7 +875,9 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup } if (step === 'context' && stepResult.status !== 'ready') { if (shouldRunAgents && args.skipAgents !== true) { - return 0; + // Context isn't built, so skip agent install — but still reach the + // completion screen, which states readiness and points at `ktx ingest`. + break setupLoop; } } diff --git a/packages/cli/test/next-steps.test.ts b/packages/cli/test/next-steps.test.ts index c700de9e..eed0f3bf 100644 --- a/packages/cli/test/next-steps.test.ts +++ b/packages/cli/test/next-steps.test.ts @@ -65,8 +65,7 @@ describe('KTX demo next steps', () => { agentIntegrationReady: true, }).join('\n'); - expect(rendered).toContain('Build KTX context next.'); - expect(rendered).toContain('Run ingest to build database schema context before context-source ingest.'); + expect(rendered).toContain('Setup is complete. The only step left is to build context for your agents.'); expect(rendered).toContain('ktx ingest'); expect(rendered).not.toContain('resume'); expect(rendered).not.toContain('scan'); @@ -87,6 +86,6 @@ describe('KTX demo next steps', () => { expect(rendered).toContain('ktx status --json'); expect(rendered).not.toContain('ktx agent'); expect(rendered).not.toContain('ktx serve --mcp stdio --user-id local'); - expect(rendered).not.toContain('Build KTX context next.'); + expect(rendered).not.toContain('Setup is complete.'); }); }); diff --git a/packages/cli/test/setup-ready-menu.test.ts b/packages/cli/test/setup-ready-menu.test.ts index 82c92a1c..39c62a32 100644 --- a/packages/cli/test/setup-ready-menu.test.ts +++ b/packages/cli/test/setup-ready-menu.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { isKtxPreAgentSetupReady, isKtxSetupReady, runKtxSetupReadyChangeMenu } from '../src/setup-ready-menu.js'; +import { + classifyKtxSetupCompletion, + runKtxSetupReadyChangeMenu, + runKtxSetupReadyMenu, +} from '../src/setup-ready-menu.js'; import type { KtxSetupStatus } from '../src/setup.js'; const readyStatus: KtxSetupStatus = { @@ -13,32 +17,58 @@ const readyStatus: KtxSetupStatus = { agents: [{ target: 'codex', scope: 'project', ready: true }], }; -describe('setup ready menu', () => { - it('recognizes a ready setup only when required sections are ready', () => { - expect(isKtxSetupReady(readyStatus)).toBe(true); - expect(isKtxSetupReady({ ...readyStatus, embeddings: { ready: false } })).toBe(false); - expect(isKtxSetupReady({ ...readyStatus, runtime: { required: true, ready: false, features: ['core'] } })).toBe(false); - expect(isKtxSetupReady({ ...readyStatus, context: { ready: false, status: 'not_started' } })).toBe(false); - expect(isKtxSetupReady({ ...readyStatus, agents: [] })).toBe(false); +describe('classifyKtxSetupCompletion', () => { + it('reports ready only when config, context, and agents are all ready', () => { + expect(classifyKtxSetupCompletion(readyStatus)).toBe('ready'); }); - it('recognizes pre-agent readiness without requiring agents', () => { - expect(isKtxPreAgentSetupReady(readyStatus)).toBe(true); - expect(isKtxPreAgentSetupReady({ ...readyStatus, agents: [] })).toBe(true); - expect(isKtxPreAgentSetupReady({ ...readyStatus, embeddings: { ready: false } })).toBe(false); - expect(isKtxPreAgentSetupReady({ ...readyStatus, runtime: { required: true, ready: false, features: ['core'] } })).toBe( - false, - ); - expect(isKtxPreAgentSetupReady({ ...readyStatus, context: { ready: false, status: 'not_started' } })).toBe(false); + it('reports needs-agents when config and context are ready but no agent is installed', () => { + expect(classifyKtxSetupCompletion({ ...readyStatus, agents: [] })).toBe('needs-agents'); }); - it('maps ready-project menu choices to setup sections', async () => { - const prompts = { select: vi.fn(async () => 'agents'), cancel: vi.fn() }; + it('reports needs-context when config is ready but context is not built', () => { + expect( + classifyKtxSetupCompletion({ ...readyStatus, context: { ready: false, status: 'not_started' } }), + ).toBe('needs-context'); + }); - await expect(runKtxSetupReadyChangeMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'agents' }); + it('reports incomplete when a required config section is not ready', () => { + expect(classifyKtxSetupCompletion({ ...readyStatus, embeddings: { ready: false } })).toBe('incomplete'); + expect( + classifyKtxSetupCompletion({ ...readyStatus, runtime: { required: true, ready: false, features: ['core'] } }), + ).toBe('incomplete'); + }); + it('reports incomplete when no context targets are configured', () => { + expect(classifyKtxSetupCompletion({ ...readyStatus, databases: [], sources: [] })).toBe('incomplete'); + }); +}); + +describe('runKtxSetupReadyMenu', () => { + it('exits when the user is done', async () => { + const prompts = { select: vi.fn(async () => 'done'), cancel: vi.fn() }; + + await expect(runKtxSetupReadyMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'exit' }); + + expect(prompts.select).toHaveBeenCalledTimes(1); expect(prompts.select).toHaveBeenCalledWith({ - message: 'KTX is already set up for /tmp/revenue. What would you like to change?', + message: 'Anything else?', + options: [ + { value: 'done', label: "Done — I'll start using ktx" }, + { value: 'change', label: 'Change a setting' }, + ], + }); + }); + + it('opens the section menu when the user chooses to change a setting', async () => { + const select = vi.fn().mockResolvedValueOnce('change').mockResolvedValueOnce('models'); + const prompts = { select, cancel: vi.fn() }; + + await expect(runKtxSetupReadyMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'models' }); + + expect(select).toHaveBeenCalledTimes(2); + expect(select).toHaveBeenLastCalledWith({ + message: 'What would you like to change?', options: [ { value: 'models', label: 'Models' }, { value: 'embeddings', label: 'Embeddings' }, @@ -51,3 +81,39 @@ describe('setup ready menu', () => { }); }); }); + +describe('runKtxSetupReadyChangeMenu', () => { + it('maps ready-project menu choices to setup sections', async () => { + const prompts = { select: vi.fn(async () => 'agents'), cancel: vi.fn() }; + + await expect(runKtxSetupReadyChangeMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'agents' }); + + expect(prompts.select).toHaveBeenCalledWith({ + message: 'What would you like to change?', + options: [ + { value: 'models', label: 'Models' }, + { value: 'embeddings', label: 'Embeddings' }, + { value: 'databases', label: 'Databases' }, + { value: 'sources', label: 'Context sources' }, + { value: 'context', label: 'Rebuild KTX context' }, + { value: 'agents', label: 'Agent integration' }, + { value: 'exit', label: 'Exit' }, + ], + }); + }); + + it('includes the runtime option only when the runtime is required', async () => { + const prompts = { select: vi.fn(async () => 'runtime'), cancel: vi.fn() }; + + await runKtxSetupReadyChangeMenu( + { ...readyStatus, runtime: { required: true, ready: true, features: ['core'] } }, + { prompts }, + ); + + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([{ value: 'runtime', label: 'Runtime' }]), + }), + ); + }); +}); diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index da51e9af..e4eca44d 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -2205,8 +2205,11 @@ describe('setup status', () => { join(tempDir, 'ktx.yaml'), [ 'setup:', - ' database_connection_ids: []', - 'connections: {}', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', 'llm:', ' provider:', ' backend: anthropic', @@ -2222,7 +2225,7 @@ describe('setup status', () => { 'utf-8', ); await writeKtxSetupState(tempDir, { - completed_steps: ['project', 'llm', 'embeddings', 'sources', 'runtime', 'context', 'agents'], + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'runtime', 'context', 'agents'], }); await writeFile( join(tempDir, '.ktx/agents/install-manifest.json'), @@ -2275,7 +2278,12 @@ describe('setup status', () => { }, io.io, { - readyMenuDeps: { prompts: { select: vi.fn(async () => 'agents'), cancel: vi.fn() } }, + readyMenuDeps: { + prompts: { + select: vi.fn().mockResolvedValueOnce('change').mockResolvedValueOnce('agents'), + cancel: vi.fn(), + }, + }, model: async (args) => { expect(args.skipLlm).toBe(true); return { status: 'skipped', projectDir: tempDir }; @@ -2325,8 +2333,11 @@ describe('setup status', () => { join(tempDir, 'ktx.yaml'), [ 'setup:', - ' database_connection_ids: []', - 'connections: {}', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', 'llm:', ' provider:', ' backend: anthropic', @@ -2342,7 +2353,7 @@ describe('setup status', () => { 'utf-8', ); await writeKtxSetupState(tempDir, { - completed_steps: ['project', 'llm', 'embeddings', 'sources', 'context'], + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'context'], }); await writeKtxSetupContextState(tempDir, { runId: 'setup-context-local-ready', @@ -2415,6 +2426,171 @@ describe('setup status', () => { expect(calls).toEqual(['agents']); }); + it('routes a returning user to the context build when config is ready but context is not built', async () => { + const calls: string[] = []; + const io = makeIo(); + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'setup:', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + 'llm:', + ' provider:', + ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-6', + 'ingest:', + ' embeddings:', + ' backend: openai', + ' model: text-embedding-3-small', + ' dimensions: 1536', + '', + ].join('\n'), + 'utf-8', + ); + await writeKtxSetupState(tempDir, { + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'runtime'], + }); + + const readyMenuSelect = vi.fn(); + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'auto', + agents: false, + inputMode: 'auto', + yes: false, + cliVersion: '0.2.0', + skipLlm: false, + skipEmbeddings: false, + skipDatabases: false, + skipSources: false, + skipAgents: false, + databaseSchemas: [], + }, + io.io, + { + readyMenuDeps: { prompts: { select: readyMenuSelect, cancel: vi.fn() } }, + model: async (args) => { + expect(args.skipLlm).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + embeddings: async (args) => { + expect(args.skipEmbeddings).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + databases: async (args) => { + expect(args.skipDatabases).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + sources: async (args) => { + expect(args.skipSources).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + runtime: async () => { + calls.push('runtime'); + return runtimeReady(tempDir); + }, + context: async (args) => { + calls.push('context'); + expect(args.forcePrompt).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + agents: async () => { + calls.push('agents'); + return { status: 'ready', projectDir: tempDir, installs: [] }; + }, + }, + ), + ).resolves.toBe(0); + + // Config is done, so the change-everything menu is not shown; setup routes straight + // to the build prompt and never re-walks config or installs agents. + expect(readyMenuSelect).not.toHaveBeenCalled(); + expect(calls).toContain('context'); + expect(calls).not.toContain('agents'); + const output = io.stdout(); + expect(output).toContain('Setup is complete. The only step left is to build context'); + expect(output).toContain('ktx ingest'); + }); + + it('reaches the completion screen instead of a bare shell when the context build is skipped', async () => { + const calls: string[] = []; + const io = makeIo(); + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'setup:', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + 'llm:', + ' provider:', + ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-6', + 'ingest:', + ' embeddings:', + ' backend: openai', + ' model: text-embedding-3-small', + ' dimensions: 1536', + '', + ].join('\n'), + 'utf-8', + ); + await writeKtxSetupState(tempDir, { + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'runtime'], + }); + + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'auto', + agents: false, + inputMode: 'disabled', + yes: true, + cliVersion: '0.2.0', + skipLlm: true, + skipEmbeddings: true, + skipDatabases: true, + skipSources: true, + skipAgents: false, + databaseSchemas: [], + }, + io.io, + { + model: async () => ({ status: 'skipped', projectDir: tempDir }), + embeddings: async () => ({ status: 'skipped', projectDir: tempDir }), + databases: async () => ({ status: 'skipped', projectDir: tempDir }), + sources: async () => ({ status: 'skipped', projectDir: tempDir }), + runtime: async () => runtimeReady(tempDir), + context: async () => ({ status: 'skipped', projectDir: tempDir }), + agents: async () => { + calls.push('agents'); + return { status: 'ready', projectDir: tempDir, installs: [] }; + }, + }, + ), + ).resolves.toBe(0); + + // A skipped build must not install agents nor drop to a bare shell; the end screen + // states readiness and points at `ktx ingest`. + expect(calls).not.toContain('agents'); + const output = io.stdout(); + expect(output).toContain('Setup is complete. The only step left is to build context'); + expect(output).toContain('ktx ingest'); + }); + it('runs only project resolution and agent setup in --agents mode', async () => { const io = makeIo(); const runtime = vi.fn(async () => runtimeReady(tempDir));