mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
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:
parent
3677193027
commit
8cb6324655
3 changed files with 202 additions and 10 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue