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

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