feat(cli): add runDemoTour orchestrator and wire into setup entry menu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-11 15:56:44 -07:00
parent 3677193027
commit 8cb6324655
3 changed files with 202 additions and 10 deletions

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { KtxSetupAgentsResult } from './setup-agents.js';
import {
buildDemoReplayTimeline,
DEMO_REPLAY_TARGETS,
@ -6,6 +7,7 @@ import {
renderDemoBanner,
renderDemoCardContent,
renderDemoCompletionSummary,
runDemoTour,
} from './setup-demo-tour.js';
/** Strip ANSI escape sequences for plain-text assertions. */
@ -149,3 +151,116 @@ describe('DEMO_REPLAY_TARGETS', () => {
}
});
});
describe('runDemoTour', () => {
function createMockIo() {
const chunks: string[] = [];
return {
io: {
stdout: { isTTY: true, columns: 80, write: (chunk: string) => { chunks.push(chunk); } },
stderr: { write: () => {} },
},
chunks,
};
}
it('returns 0 on successful tour with agent installed', async () => {
const { io, chunks } = createMockIo();
const mockAgents = vi.fn().mockResolvedValue({
status: 'ready',
projectDir: '/tmp/test',
installs: [{ target: 'claude-code', scope: 'project', mode: 'both' }],
} satisfies KtxSetupAgentsResult);
const navigation = vi.fn().mockResolvedValue('forward');
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
agents: mockAgents,
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
expect(mockAgents).toHaveBeenCalled();
// Should have rendered completion summary
const allOutput = chunks.join('');
expect(allOutput).toContain('agent is connected');
});
it('handles back navigation from first step by exiting', async () => {
const { io } = createMockIo();
const navigation = vi.fn().mockResolvedValue('back');
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
// Navigation called once for databases step, then exits
expect(navigation).toHaveBeenCalledTimes(1);
});
it('goes back from sources to databases', async () => {
const { io } = createMockIo();
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');
return Promise.resolve('back');
});
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
expect(navigation).toHaveBeenCalledTimes(3);
});
it('handles agent step returning back', async () => {
const { io } = createMockIo();
let navCount = 0;
const navigation = vi.fn().mockImplementation(() => {
navCount++;
// Forward through 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');
return Promise.resolve('back');
});
const mockAgents = vi.fn().mockResolvedValue({
status: 'back',
projectDir: '/tmp/test',
} satisfies KtxSetupAgentsResult);
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
agents: mockAgents,
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
});
});

View file

@ -4,7 +4,10 @@ import type {
ContextBuildViewState,
} from './context-build-view.js';
import { createRepainter, renderContextBuildView } from './context-build-view.js';
import { defaultDemoProjectDir, ensureSeededDemoProject } from './demo-assets.js';
import type { KtxPublicIngestPlanTarget } from './public-ingest.js';
import type { KtxSetupAgentsResult } from './setup-agents.js';
import { runKtxSetupAgentsStep } from './setup-agents.js';
import { KtxSetupExitError } from './setup-interrupt.js';
// ---------------------------------------------------------------------------
@ -300,3 +303,82 @@ export async function runDemoContextReplay(
io.stdout.write(renderDemoContextCompletionSummary() + '\n');
return waitForDemoNavigation(stdin);
}
// ---------------------------------------------------------------------------
// Demo tour orchestrator
// ---------------------------------------------------------------------------
type DemoStep = 'databases' | 'sources' | 'context' | 'agents';
const DEMO_STEPS: DemoStep[] = ['databases', 'sources', 'context', 'agents'];
export interface DemoTourDeps {
agents?: (args: Parameters<typeof runKtxSetupAgentsStep>[0], io: KtxCliIo) => Promise<KtxSetupAgentsResult>;
waitForNavigation?: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'>;
ensureProject?: typeof ensureSeededDemoProject;
skipReplayAnimation?: boolean;
}
export async function runDemoTour(
args: { inputMode: 'auto' | 'disabled' },
io: KtxCliIo,
deps: DemoTourDeps = {},
): Promise<number> {
const waitNav = deps.waitForNavigation ?? waitForDemoNavigation;
const ensureProject = deps.ensureProject ?? ensureSeededDemoProject;
const projectDir = defaultDemoProjectDir();
await ensureProject({ projectDir, force: false });
let stepIndex = 0;
while (stepIndex < DEMO_STEPS.length) {
const step = DEMO_STEPS[stepIndex]!;
let direction: 'forward' | 'back';
if (step === 'databases') {
direction = await renderDemoCard('Database connection', ['PostgreSQL (demo warehouse)'], io, undefined, waitNav);
} else if (step === 'sources') {
direction = await renderDemoCard('Context sources', ['dbt', 'Metabase', 'Notion'], io, undefined, waitNav);
} else if (step === 'context') {
io.stdout.write(renderDemoBanner() + '\n\n');
if (deps.skipReplayAnimation) {
direction = await waitNav();
} else {
direction = await runDemoContextReplay(io);
}
} else {
// agents step — real interactive
io.stdout.write(renderDemoAgentTransition() + '\n');
const agentsRunner = deps.agents ?? runKtxSetupAgentsStep;
const agentsResult = await agentsRunner(
{
projectDir,
inputMode: args.inputMode,
yes: false,
agents: true,
scope: 'project',
mode: 'both',
skipAgents: false,
},
io,
);
const agentInstalled = agentsResult.status === 'ready';
if (agentsResult.status === 'back') {
direction = 'back';
} else {
io.stdout.write(renderDemoCompletionSummary(projectDir, agentInstalled) + '\n');
return 0;
}
}
if (direction === 'back') {
if (stepIndex === 0) return 0;
stepIndex -= 1;
} else {
stepIndex += 1;
}
}
return 0;
}

View file

@ -4,7 +4,6 @@ import { cancel, isCancel, select } from '@clack/prompts';
import { loadKtxProject } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import type { KtxDemoArgs } from './demo.js';
import { defaultDemoProjectDir } from './demo-assets.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
import {
@ -220,15 +219,11 @@ async function runKtxSetupDemoFromEntryMenu(
io: KtxCliIo,
deps: KtxSetupDeps,
): Promise<number> {
const runner = deps.demo ?? (await import('./demo.js')).runKtxDemo;
return await runner(
{
command: 'seeded',
projectDir: defaultDemoProjectDir(),
outputMode: 'viz',
inputMode: args.inputMode,
},
const { runDemoTour } = await import('./setup-demo-tour.js');
return await runDemoTour(
{ inputMode: args.inputMode },
io,
{ agents: deps.agents },
);
}