feat(cli): guide next action at end of ktx setup, not reruns (#256)

Re-running setup was the dominant action for installs that completed setup but never ingested. Classify completion (incomplete | needs-context | needs-agents | ready) and drive one obvious next action per state: route a config-complete project straight to the build, point unbuilt-context users at `ktx ingest` instead of re-running setup or dropping to a bare shell, and confirm readiness for fully-set-up projects rather than reopening the edit menu.
This commit is contained in:
Andrey Avtomonov 2026-06-03 01:00:21 +02:00 committed by GitHub
parent cb6a67c2d7
commit 45aa95d2cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 360 additions and 59 deletions

View file

@ -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

View file

@ -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),
];
}

View file

@ -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 };
}
}

View file

@ -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<string>;
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' },

View file

@ -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;
}
}

View file

@ -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.');
});
});

View file

@ -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' }]),
}),
);
});
});

View file

@ -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));