fix(cli): remove ktx setup subcommands

This commit is contained in:
Andrey Avtomonov 2026-05-13 00:24:35 +02:00
parent 80f298d652
commit 7db91caca6
47 changed files with 171 additions and 5010 deletions

View file

@ -71,16 +71,6 @@ KTX context built: yes
Agent integration ready: yes (claude-code:project)
```
Run the packaged demo without installing globally:
```bash
npx @kaelio/ktx setup demo --no-input
npx @kaelio/ktx setup demo inspect
```
The default demo uses packaged sample data and prebuilt context. It does not
require API keys, network access, or an LLM provider.
Generate SQL from a semantic-layer source:
```bash

View file

@ -11,25 +11,6 @@ Interactive wizard that walks you through configuring LLM credentials, embedding
ktx setup [options]
```
## Subcommands
| Subcommand | Description |
|-----------|-------------|
| `setup demo` | Run the packaged KTX demo from setup |
| `setup demo init` | Initialize the packaged demo project |
| `setup demo reset` | Reset the packaged demo project |
| `setup demo replay` | Replay the packaged demo memory-flow |
| `setup demo scan` | Run the packaged demo scan |
| `setup demo inspect` | Inspect packaged demo outputs |
| `setup demo doctor` | Check packaged demo readiness |
| `setup demo ingest` | Run packaged demo ingest |
| `setup context build` | Build agent-ready KTX context for setup |
| `setup context watch [runId]` | Watch a setup-managed context build |
| `setup context status [runId]` | Print setup-managed context build status |
| `setup context stop [runId]` | Request a pause for a setup-managed context build |
| `setup remove` | Remove setup-managed local integrations |
| `setup status` | Show setup readiness for the resolved KTX project |
## Options
### General
@ -119,17 +100,6 @@ ktx setup [options]
| `--skip-initial-source-ingest` | Validate source setup without building source context during setup | `false` |
| `--skip-sources` | Mark optional source setup complete with no sources | `false` |
### Subcommand Options
| Flag | Subcommand | Description | Default |
|------|-----------|-------------|---------|
| `--json` | `status`, `context status` | Print JSON output | `false` |
| `--no-input` | `context build`, `context watch` | Disable interactive terminal input | — |
| `--force` | `context stop` | Request the pause without interactive confirmation | `false` |
| `--agents` | `remove` | Remove setup-managed agent integration files | `false` |
| `--mode <mode>` | `demo` | Demo mode: `seeded`, `replay`, or `full` | `seeded` |
| `--plain` | `demo` | Print plain text output | `false` |
## Examples
```bash
@ -161,21 +131,13 @@ ktx setup --source dbt --source-path ./my-dbt-project
ktx setup --skip-sources --skip-agents
# Check setup readiness
ktx setup status
# Build context after setup
ktx setup context build
# Watch a running context build
ktx setup context watch
# Run the packaged demo
ktx setup demo
ktx status
```
## Output
Interactive setup renders prompts and progress messages. `ktx setup status` is the best command for agents because it summarizes readiness in one response.
Interactive setup renders prompts and progress messages. Use `ktx status` to
check setup and context readiness after setup exits.
```text
KTX project: /home/user/analytics

View file

@ -156,8 +156,8 @@ Run: setup-context-local-abc123
Project: /home/user/analytics
Detach: press d to leave this running.
Resume: ktx setup context watch setup-context-local-abc123
Status: ktx setup context status setup-context-local-abc123
Resume: ktx setup --project-dir /home/user/analytics
Status: ktx status --project-dir /home/user/analytics
```
When the build completes, KTX verifies that agent-ready context was produced:
@ -241,7 +241,7 @@ Agent integration ready: yes (claude-code:project)
| OpenAI embedding check fails | `OPENAI_API_KEY` is missing when OpenAI embeddings are selected | Export `OPENAI_API_KEY`, or rerun setup and choose local sentence-transformers embeddings |
| Local embeddings hang or fail | The managed Python runtime cannot start or the local model runtime is unavailable | Install `uv`, run `ktx dev runtime doctor`, then run `ktx dev runtime install --feature local-embeddings --yes` and rerun setup |
| Database connection test fails | Credentials, network access, warehouse, database, or schema value is wrong | Test the same URL with the database's native client, then rerun `ktx connection add ... --force` or rerun setup |
| `KTX context built: no` in `ktx status` | Setup saved configuration but did not build context | Run `ktx setup context build` or rerun `ktx setup` and choose to build context now |
| `KTX context built: no` in `ktx status` | Setup saved configuration but did not build context | Run `ktx setup` and choose to build context now |
| Agent integration is incomplete | Setup skipped the agents step or the target was not installed | Run `ktx setup --agents --target codex --project` using the target you need |
## Next steps

View file

@ -13,8 +13,8 @@ describe('agent semantic-layer search readiness guidance', () => {
code: 'agent_sl_search_missing_project',
message: 'Semantic-layer search needs an initialized KTX project at /tmp/ktx-search.',
nextSteps: [
'ktx demo',
'ktx setup --project-dir /tmp/ktx-search',
'ktx status --project-dir /tmp/ktx-search',
'ktx ingest <connection>',
'ktx agent sl list --json --query "gross revenue" --project-dir /tmp/ktx-search',
],

View file

@ -21,8 +21,8 @@ function projectSearchCommand(projectDir: string, query: string | undefined): st
function baseNextSteps(projectDir: string, query: string | undefined): string[] {
return [
'ktx demo',
`ktx setup --project-dir ${projectDir}`,
`ktx status --project-dir ${projectDir}`,
'ktx ingest <connection>',
projectSearchCommand(projectDir, query),
];

View file

@ -326,8 +326,8 @@ describe('runKtxAgent', () => {
code: 'agent_sl_search_missing_project',
message: `Semantic-layer search needs an initialized KTX project at ${tempDir}.`,
nextSteps: [
'ktx demo',
`ktx setup --project-dir ${tempDir}`,
`ktx status --project-dir ${tempDir}`,
'ktx ingest <connection>',
`ktx agent sl list --json --query "gross revenue" --project-dir ${tempDir}`,
],
@ -353,8 +353,8 @@ describe('runKtxAgent', () => {
code: 'agent_sl_search_no_connections',
message: `Semantic-layer search found no configured connections in ${tempDir}.`,
nextSteps: [
'ktx demo',
`ktx setup --project-dir ${tempDir}`,
`ktx status --project-dir ${tempDir}`,
'ktx ingest <connection>',
`ktx agent sl list --json --query "revenue" --project-dir ${tempDir}`,
],

View file

@ -4,7 +4,6 @@ import type { KtxConnectionMetabaseSetupArgs } from './commands/connection-metab
import type { KtxConnectionNotionArgs } from './commands/connection-notion.js';
import type { KtxAgentArgs } from './agent.js';
import type { KtxConnectionArgs } from './connection.js';
import type { KtxDemoArgs } from './demo.js';
import type { KtxDoctorArgs } from './doctor.js';
import type { KtxIngestArgs } from './ingest.js';
import type { KtxKnowledgeArgs } from './knowledge.js';
@ -36,7 +35,6 @@ export interface KtxCliDeps {
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
connectionNotion?: (args: KtxConnectionNotionArgs, io: KtxCliIo) => Promise<number>;
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;
demo?: (args: KtxDemoArgs, io: KtxCliIo) => Promise<number>;
doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise<number>;
ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise<number>;

View file

@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveDemoCommandOptions } from './demo-commands.js';
describe('resolveDemoCommandOptions', () => {
it('lets parent --no-input override a child default from optsWithGlobals', () => {
const rootCommand = {
opts: () => ({}),
};
const setupCommand = {
parent: rootCommand,
opts: () => ({ input: false }),
getOptionValueSource: (name: string) => (name === 'input' ? 'cli' : undefined),
};
const demoCommand = {
parent: setupCommand,
opts: () => ({ input: true, mode: 'seeded' }),
optsWithGlobals: () => ({ input: true, mode: 'seeded' }),
getOptionValueSource: (name: string) => (name === 'input' ? 'default' : name === 'mode' ? 'default' : undefined),
};
expect(resolveDemoCommandOptions<{ input: boolean; mode: string }>(demoCommand)).toEqual({
input: false,
mode: 'seeded',
});
});
});

View file

@ -1,273 +0,0 @@
import { type Command, Option } from '@commander-js/extra-typings';
import {
type CommandWithGlobalOptions,
type KtxCliCommandContext,
resolveCommandProjectDirOverride,
} from '../cli-program.js';
import {
type KtxDemoArgs,
type KtxDemoInputMode,
type KtxDemoMode,
type KtxDemoOutputMode,
} from '../demo.js';
import { defaultDemoProjectDir } from '../demo-assets.js';
import { resolveProjectDir } from '../project-dir.js';
import { profileMark } from '../startup-profile.js';
profileMark('module:commands/demo-commands');
interface DemoOptions {
plain?: boolean;
json?: boolean;
input?: boolean;
projectDir?: string;
}
function demoOutputMode(options: { plain?: boolean; json?: boolean }): KtxDemoOutputMode {
if (options.json === true) {
return 'json';
}
if (options.plain === true) {
return 'plain';
}
return 'viz';
}
function demoDoctorOutputMode(options: { json?: boolean }): 'plain' | 'json' {
return options.json === true ? 'json' : 'plain';
}
function demoInspectOutputMode(options: { plain?: boolean; json?: boolean }): KtxDemoOutputMode {
if (options.json === true) {
return 'json';
}
return 'plain';
}
function demoInputMode(options: { input?: boolean }): { inputMode?: KtxDemoInputMode } {
return options.input === false ? { inputMode: 'disabled' } : {};
}
function demoProjectDir(options: { projectDir?: string }, command: CommandWithGlobalOptions): string {
return resolveProjectDir(
options.projectDir ?? resolveCommandProjectDirOverride(command),
defaultDemoProjectDir(),
);
}
type CommandOptionSourceReader = {
getOptionValueSource?: (name: string) => string | undefined;
parent?: unknown;
};
function inheritedOptionSource(command: CommandOptionSourceReader, key: string): string | undefined {
let current = command.parent as (CommandOptionSourceReader & { opts?: () => Record<string, unknown> }) | undefined;
while (current) {
const source = current.getOptionValueSource?.(key);
if (source !== undefined) {
return source;
}
current = current.parent as (CommandOptionSourceReader & { opts?: () => Record<string, unknown> }) | undefined;
}
return undefined;
}
function definedOptions(
options: Record<string, unknown>,
inherited: Record<string, unknown> = {},
command?: CommandOptionSourceReader,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(options).filter(([key, value]) => {
if (value === undefined) return false;
if (key === 'input' && value === true && inherited.input === false) return false;
if (
key === 'mode' &&
command?.getOptionValueSource?.(key) === 'default' &&
inherited[key] !== undefined &&
inherited[key] !== value &&
inheritedOptionSource(command, key) === 'cli'
) {
return false;
}
return true;
}),
);
}
export function resolveDemoCommandOptions<T>(command: { opts: () => T; optsWithGlobals?: () => T; parent?: unknown }): T {
const chain: Array<{ opts?: () => Record<string, unknown>; parent?: unknown }> = [];
let current = command.parent as { opts?: () => Record<string, unknown>; parent?: unknown } | undefined;
while (current) {
chain.unshift(current);
current = current.parent as { opts?: () => Record<string, unknown>; parent?: unknown } | undefined;
}
const inherited = Object.assign({}, ...chain.map((parent) => definedOptions(parent.opts?.() ?? {})));
if (command.optsWithGlobals) {
const withGlobals = {
...inherited,
...definedOptions(command.optsWithGlobals() as Record<string, unknown>, inherited, command),
};
return {
...withGlobals,
...definedOptions(command.opts() as Record<string, unknown>, withGlobals, command),
} as T;
}
return { ...inherited, ...definedOptions(command.opts() as Record<string, unknown>, inherited, command) } as T;
}
async function runDemoArgs(context: KtxCliCommandContext, args: KtxDemoArgs): Promise<void> {
const runner = context.deps.demo ?? (await import('../demo.js')).runKtxDemo;
context.setExitCode(await runner(args, context.io));
}
export function registerDemoCommands(
program: Command,
context: KtxCliCommandContext,
options: { description?: string } = {},
): void {
const demo = program
.command('demo')
.description(options.description ?? 'Run the pre-seeded KTX demo or a full LLM-backed demo')
.addOption(
new Option('--mode <mode>', 'Demo mode: seeded (default), replay, or full')
.choices(['seeded', 'replay', 'full'])
.default('seeded'),
)
.option('--project-dir <path>', 'Demo project directory')
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
.option('--no-input', 'Disable interactive terminal input')
.showHelpAfterError()
.action(async (options: { mode: 'seeded' | 'replay' | 'full' } & DemoOptions, command) => {
const resolvedOptions = resolveDemoCommandOptions<typeof options>(command);
await runDemoArgs(context, {
command: resolvedOptions.mode,
projectDir: demoProjectDir(resolvedOptions, command),
outputMode: demoOutputMode(resolvedOptions),
...demoInputMode(resolvedOptions),
});
});
demo
.command('init')
.description('Initialize the packaged demo project')
.option('--project-dir <path>', 'Demo project directory')
.option('--force', 'Recreate an existing demo project', false)
.option('--no-input', 'Disable interactive terminal input')
.action(async (_options, command: { opts: () => { projectDir?: string; force?: boolean; input?: boolean } }) => {
const options = resolveDemoCommandOptions(command);
await runDemoArgs(context, {
command: 'init',
projectDir: demoProjectDir(options, command),
force: options.force === true,
...demoInputMode(options),
});
});
demo
.command('reset')
.description('Reset the packaged demo project')
.option('--project-dir <path>', 'Demo project directory')
.option('--force', 'Recreate the demo project without prompting', false)
.option('--no-input', 'Disable interactive terminal input')
.action(async (_options, command: { opts: () => { projectDir?: string; force?: boolean; input?: boolean } }) => {
const options = resolveDemoCommandOptions(command);
await runDemoArgs(context, {
command: 'reset',
projectDir: demoProjectDir(options, command),
force: options.force === true,
...demoInputMode(options),
});
});
demo
.command('replay')
.description('Replay the packaged demo memory-flow')
.option('--project-dir <path>', 'Demo project directory')
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
.option('--no-input', 'Disable interactive terminal input')
.action(async (_options, command: { opts: () => DemoOptions }) => {
const options = resolveDemoCommandOptions(command);
await runDemoArgs(context, {
command: 'replay',
projectDir: demoProjectDir(options, command),
outputMode: demoOutputMode(options),
...demoInputMode(options),
});
});
demo
.command('scan')
.description('Run the packaged demo scan')
.option('--project-dir <path>', 'Demo project directory')
.option('--no-input', 'Disable interactive terminal input')
.action(async (_options, command: { opts: () => { projectDir?: string; input?: boolean } }) => {
const options = resolveDemoCommandOptions(command);
await runDemoArgs(context, {
command: 'scan',
projectDir: demoProjectDir(options, command),
...demoInputMode(options),
});
});
demo
.command('inspect')
.description('Inspect packaged demo outputs')
.option('--project-dir <path>', 'Demo project directory')
.addOption(new Option('--plain', 'Print plain text output').conflicts('json'))
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
.option('--no-input', 'Disable interactive terminal input')
.action(async (_options, command: { opts: () => DemoOptions }) => {
const options = resolveDemoCommandOptions(command);
await runDemoArgs(context, {
command: 'inspect',
projectDir: demoProjectDir(options, command),
outputMode: demoInspectOutputMode(options),
...demoInputMode(options),
});
});
demo
.command('doctor')
.description('Check packaged demo readiness')
.option('--project-dir <path>', 'Demo project directory')
.addOption(new Option('--plain', 'Print plain text output').conflicts('json'))
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
.option('--no-input', 'Disable interactive terminal input')
.action(async (_options, command: { opts: () => DemoOptions }) => {
const options = resolveDemoCommandOptions(command);
await runDemoArgs(context, {
command: 'doctor',
projectDir: demoProjectDir(options, command),
outputMode: demoDoctorOutputMode(options),
...demoInputMode(options),
});
});
demo
.command('ingest')
.description('Run packaged demo ingest')
.addOption(
new Option('--mode <mode>', 'Demo ingest mode: full or seeded')
.choices(['full', 'seeded'])
.default('full'),
)
.option('--project-dir <path>', 'Demo project directory')
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
.option('--no-input', 'Disable interactive terminal input')
.action(async (_options, command: { opts: () => { mode: KtxDemoMode } & DemoOptions }) => {
const options = resolveDemoCommandOptions(command);
await runDemoArgs(context, {
command: 'ingest',
mode: options.mode,
projectDir: demoProjectDir(options, command),
outputMode: demoOutputMode(options),
...demoInputMode(options),
});
});
}

View file

@ -21,7 +21,7 @@ async function runDoctorArgs(context: KtxCliCommandContext, args: KtxDoctorArgs)
export function registerDoctorCommands(program: Command, context: KtxCliCommandContext): void {
const doctor = program
.command('doctor')
.description('Check KTX setup, project, and demo readiness')
.description('Check KTX setup and project readiness')
.option('--json', 'Print JSON output', false)
.option('--no-input', 'Disable interactive terminal input')
.action(async (options: { json?: boolean; input?: boolean }, command) => {

View file

@ -3,7 +3,6 @@ import type { KtxCliCommandContext } from '../cli-program.js';
import { resolveCommandProjectDir } from '../cli-program.js';
import type { KtxSetupDatabaseDriver } from '../setup-databases.js';
import type { KtxSetupSourceType } from '../setup-sources.js';
import { registerDemoCommands } from './demo-commands.js';
async function runSetupArgs(
context: KtxCliCommandContext,
@ -414,98 +413,4 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
showEntryMenu: shouldShowSetupEntryMenu(options, command),
});
});
registerDemoCommands(setup, context, { description: 'Run the packaged KTX demo from setup' });
const setupContext = setup.command('context').description('Build, inspect, and recover setup-managed KTX context');
function setupContextInputMode(command: {
optsWithGlobals?: () => unknown;
opts?: () => unknown;
}): 'auto' | 'disabled' {
const options = command.optsWithGlobals?.() as { input?: boolean } | undefined;
return options?.input === false ? 'disabled' : 'auto';
}
setupContext
.command('build')
.description('Build agent-ready KTX context for setup')
.option('--no-input', 'Disable interactive terminal input')
.action(async (options: { input?: boolean }, command) => {
await runSetupArgs(context, {
command: 'context-build',
projectDir: resolveCommandProjectDir(command),
inputMode: options.input === false ? 'disabled' : setupContextInputMode(command),
});
});
setupContext
.command('watch')
.description('Watch a setup-managed context build')
.argument('[runId]', 'Setup context build run id')
.option('--no-input', 'Disable interactive terminal input')
.action(async (runId: string | undefined, options: { input?: boolean }, command) => {
await runSetupArgs(context, {
command: 'context-watch',
projectDir: resolveCommandProjectDir(command),
...(runId ? { runId } : {}),
inputMode: options.input === false ? 'disabled' : setupContextInputMode(command),
});
});
setupContext
.command('status')
.description('Print setup-managed context build status')
.argument('[runId]', 'Setup context build run id')
.option('--json', 'Print JSON output', false)
.action(async (runId: string | undefined, options: { json?: boolean }, command) => {
await runSetupArgs(context, {
command: 'context-status',
projectDir: resolveCommandProjectDir(command),
...(runId ? { runId } : {}),
json: options.json === true,
});
});
setupContext
.command('stop')
.description('Request a pause for a setup-managed context build')
.argument('[runId]', 'Setup context build run id')
.option('--force', 'Request the pause without an interactive confirmation', false)
.action(async (runId: string | undefined, _options: { force?: boolean }, command) => {
await runSetupArgs(context, {
command: 'context-stop',
projectDir: resolveCommandProjectDir(command),
...(runId ? { runId } : {}),
});
});
setup
.command('remove')
.description('Remove setup-managed local integrations')
.option('--agents', 'Remove setup-managed agent integration files', false)
.action(async (options: { agents?: boolean }, command) => {
const parentOptions = command.parent?.opts() as { agents?: boolean } | undefined;
if (options.agents !== true && parentOptions?.agents !== true) {
context.io.stderr.write('Choose what to remove: --agents.\n');
context.setExitCode(1);
return;
}
await runSetupArgs(context, {
command: 'remove-agents',
projectDir: resolveCommandProjectDir(command),
});
});
setup
.command('status')
.description('Show setup readiness for the resolved KTX project')
.option('--json', 'Print JSON output', false)
.action(async (options: { json?: boolean }, command) => {
await runSetupArgs(context, {
command: 'status',
projectDir: resolveCommandProjectDir(command),
json: options.json === true,
});
});
}

View file

@ -508,7 +508,7 @@ describe('runContextBuild', () => {
expect(mockExit).toHaveBeenCalledWith(0);
expect(io.stdout()).toContain('Context build continuing in the background.');
expect(io.stdout()).toContain('Resume: ktx setup --project-dir /tmp/project');
expect(io.stdout()).toContain('Status: ktx setup context status --project-dir /tmp/project');
expect(io.stdout()).toContain('Status: ktx status --project-dir /tmp/project');
mockExit.mockRestore();
});

View file

@ -412,7 +412,7 @@ function spawnBackgroundBuild(projectDir: string): { logPath: string } | null {
const child = spawn(
process.execPath,
[entryScript, 'setup', 'context', 'build', '--project-dir', resolvedDir, '--no-input'],
[entryScript, 'setup', '--project-dir', resolvedDir, '--no-input'],
{ detached: true, stdio: ['ignore', logFd, logFd] },
);
child.unref();
@ -590,7 +590,7 @@ export async function runContextBuild(
io.stdout.write('\n\nContext build continuing in the background.\n');
if (bg) io.stdout.write(`Log: ${bg.logPath}\n`);
io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`);
io.stdout.write(`Status: ktx setup context status --project-dir ${resolve(args.projectDir)}\n`);
io.stdout.write(`Status: ktx status --project-dir ${resolve(args.projectDir)}\n`);
exiting = true;
process.exit(0);
},

View file

@ -1,4 +1,4 @@
import { access, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { access, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -6,16 +6,11 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
DEMO_ADAPTER,
DEMO_CONNECTION_ID,
DEMO_FULL_JOB_ID,
DEMO_REPLAY_FILE,
defaultDemoProjectDir,
ensureDemoProject,
inspectDemoProjectState,
loadPackagedDemoReplay,
loadProjectDemoReplay,
resetDemoProject,
ensureSeededDemoProject,
} from './demo-assets.js';
import { writeDemoReplay } from './demo-replay-store.js';
const packagedDemoSource = 'packaged-orbit-demo';
@ -44,7 +39,6 @@ describe('demo assets', () => {
expect(DEMO_CONNECTION_ID).toBe('orbit_demo');
expect(DEMO_ADAPTER).toBe('live-database');
expect(DEMO_REPLAY_FILE).toBe('replay.memory-flow.v1.json');
expect(DEMO_FULL_JOB_ID).toBe('demo-full-ingest');
});
it('ships the seeded demo bundle required by the May 6 PRD', async () => {
@ -131,137 +125,12 @@ describe('demo assets', () => {
await expect(ensureDemoProject({ projectDir, force: true })).resolves.toMatchObject({ projectDir });
});
it('loads packaged and copied demo replays', async () => {
const packaged = await loadPackagedDemoReplay();
expect(packaged.runId).toBe('demo-seeded-orbit');
expect(packaged.connectionId).toBe('orbit_demo');
expect(packaged.metadata?.mode).toBe('seeded');
it('copies the seeded project assets used by the setup wizard tour', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
await ensureDemoProject({ projectDir, force: false });
const copied = await loadProjectDemoReplay(projectDir);
expect(copied).toEqual(packaged);
});
it('loads the latest local replay before the packaged replay', async () => {
await ensureDemoProject({ projectDir, force: false });
await writeDemoReplay(
projectDir,
{
metadata: {
schemaVersion: 1,
mode: 'full',
origin: 'captured',
timing: 'captured',
capturedAt: '2026-05-01T10:00:03.000Z',
sourceReportId: null,
sourceReportPath: 'raw-sources/orbit_demo/live-database/sync/scan-report.json',
fallbackReason: null,
},
runId: 'demo-full-run',
connectionId: 'orbit_demo',
adapter: 'live-database',
status: 'done',
sourceDir: null,
syncId: 'sync',
reportPath: 'raw-sources/orbit_demo/live-database/sync/scan-report.json',
errors: [],
events: [{ type: 'report_created', runId: 'scan-run' }],
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
},
{ label: 'full' },
);
await expect(loadProjectDemoReplay(projectDir)).resolves.toMatchObject({
runId: 'demo-full-run',
metadata: { mode: 'full', origin: 'captured' },
});
});
it('reports missing, ready, and corrupted demo project state', async () => {
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
status: 'missing',
projectDir,
missing: ['ktx.yaml', 'demo.db', 'state.sqlite', 'replays/replay.memory-flow.v1.json'],
});
await ensureDemoProject({ projectDir, force: false });
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
status: 'ready',
projectDir,
missing: [],
});
await rm(join(projectDir, 'demo.db'), { force: true });
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
status: 'corrupt',
projectDir,
missing: ['demo.db'],
});
});
it('requires explicit force for demo reset and recreates packaged assets', async () => {
await ensureDemoProject({ projectDir, force: false });
await rm(join(projectDir, 'demo.db'), { force: true });
await expect(resetDemoProject({ projectDir, force: false })).rejects.toThrow(
`ktx setup demo reset is destructive; pass --force to recreate ${projectDir}`,
);
await expect(resetDemoProject({ projectDir, force: true })).resolves.toMatchObject({ projectDir });
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
await expect(inspectDemoProjectState(projectDir)).resolves.toMatchObject({ status: 'ready' });
});
it('preserves a user-edited ktx.yaml across reset --force', async () => {
await ensureDemoProject({ projectDir, force: false });
const customConfig = [
'project: ktx-demo-orbit',
'connections:',
` ${DEMO_CONNECTION_ID}:`,
' driver: sqlite',
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
' readonly: true',
'storage:',
' state: sqlite',
' search: sqlite-fts5',
' git:',
' auto_commit: true',
' author: ktx <ktx@example.com>',
'llm:',
' provider:',
' backend: vertex',
' vertex:',
' project: example-gcp-project',
' location: us-east5',
' models:',
' default: claude-sonnet-4-6',
'ingest:',
' adapters:',
` - ${DEMO_ADAPTER}`,
' embeddings:',
' backend: none',
' dimensions: 8',
' workUnits:',
' stepBudget: 40',
' maxConcurrency: 1',
' failureMode: continue',
'',
].join('\n');
await writeFile(join(projectDir, 'ktx.yaml'), customConfig, 'utf-8');
await resetDemoProject({ projectDir, force: true });
const preserved = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(preserved).toBe(customConfig);
expect(preserved).toContain('backend: vertex');
expect(preserved).not.toContain('backend: anthropic');
await expect(inspectDemoProjectState(projectDir)).resolves.toMatchObject({ status: 'ready' });
});
it('still writes the default ktx.yaml on reset when none exists', async () => {
await resetDemoProject({ projectDir, force: true });
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(config).toContain('backend: anthropic');
await expect(access(join(projectDir, 'semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'knowledge', 'global', 'orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'links', 'provenance.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'reports', 'seeded-demo-report.json'))).resolves.toBeUndefined();
});
});

View file

@ -1,11 +1,9 @@
import { constants as fsConstants } from 'node:fs';
import { access, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { access, copyFile, cp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
import type { MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
import { loadDemoReplayFile, loadLatestDemoReplay } from './demo-replay-store.js';
interface DemoProjectResult {
projectDir: string;
@ -19,25 +17,9 @@ interface EnsureDemoProjectOptions {
force: boolean;
}
type DemoProjectStateStatus = 'missing' | 'ready' | 'corrupt';
interface DemoProjectState {
status: DemoProjectStateStatus;
projectDir: string;
missing: string[];
}
export const DEMO_CONNECTION_ID = 'orbit_demo';
export const DEMO_ADAPTER = 'live-database';
export const DEMO_REPLAY_FILE = 'replay.memory-flow.v1.json';
export const DEMO_FULL_JOB_ID = 'demo-full-ingest';
const REQUIRED_BASE_PROJECT_PATHS = [
'ktx.yaml',
'demo.db',
'state.sqlite',
join('replays', DEMO_REPLAY_FILE),
] as const;
const REQUIRED_PACKAGED_BASE_ASSET_PATHS = ['demo.db', 'manifest.json', DEMO_REPLAY_FILE] as const;
@ -68,49 +50,6 @@ export function defaultDemoProjectDir(): string {
return join(tmpdir(), `ktx-demo-${suffix}`);
}
export async function inspectDemoProjectState(projectDir: string): Promise<DemoProjectState> {
const root = resolve(projectDir);
const missing: string[] = [];
for (const relativePath of REQUIRED_BASE_PROJECT_PATHS) {
if (!(await exists(join(root, relativePath)))) {
missing.push(relativePath);
}
}
if (missing.length === REQUIRED_BASE_PROJECT_PATHS.length) {
return { status: 'missing', projectDir: root, missing };
}
if (missing.length > 0) {
return { status: 'corrupt', projectDir: root, missing };
}
return { status: 'ready', projectDir: root, missing: [] };
}
export async function resetDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
const projectDir = resolve(options.projectDir);
if (!options.force) {
throw new Error(`ktx setup demo reset is destructive; pass --force to recreate ${projectDir}`);
}
const preservedConfig = await readExistingConfig(join(projectDir, 'ktx.yaml'));
const result = await ensureDemoProject({ projectDir, force: true });
if (preservedConfig !== null) {
await writeFile(result.configPath, preservedConfig, 'utf-8');
}
return result;
}
async function readExistingConfig(configPath: string): Promise<string | null> {
try {
return await readFile(configPath, 'utf-8');
} catch {
return null;
}
}
function demoConfig(databasePath: string): string {
return [
'project: ktx-demo-orbit',
@ -243,34 +182,3 @@ export async function ensureSeededDemoProject(options: EnsureDemoProjectOptions)
await copySeededAssetDirectories(result.projectDir);
return result;
}
export async function loadPackagedDemoReplay(): Promise<MemoryFlowReplayInput> {
const replay = await loadDemoReplayFile(join(assetDir(), DEMO_REPLAY_FILE));
return {
...replay,
metadata: {
schemaVersion: 1,
mode: replay.metadata?.mode ?? 'seeded',
origin: 'packaged',
timing: replay.metadata?.timing ?? 'prebuilt',
capturedAt: replay.metadata?.capturedAt ?? null,
sourceReportId: replay.metadata?.sourceReportId ?? 'demo-seeded-report',
sourceReportPath: replay.metadata?.sourceReportPath ?? `reports/seeded-demo-report.json`,
fallbackReason: null,
},
};
}
export async function loadProjectDemoReplay(projectDir: string): Promise<MemoryFlowReplayInput> {
const latest = await loadLatestDemoReplay(projectDir);
if (latest) {
return latest;
}
const replayPath = join(resolve(projectDir), 'replays', DEMO_REPLAY_FILE);
if (!(await exists(replayPath))) {
await mkdir(dirname(replayPath), { recursive: true });
await copyPackagedReplay(resolve(projectDir));
}
return loadPackagedDemoReplay();
}

View file

@ -1,201 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { IngestReportSnapshot, LocalIngestResult, RunLocalIngestOptions } from '@ktx/context/ingest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
import {
assertFullDemoCredentials,
buildFullDemoReplay,
formatFullDemoSummary,
fullDemoCredentialStatus,
runDemoFull,
} from './demo-full.js';
function fakeFullReport(): IngestReportSnapshot {
return {
id: 'report-full',
runId: 'run-full',
jobId: DEMO_FULL_JOB_ID,
connectionId: DEMO_CONNECTION_ID,
sourceKey: DEMO_ADAPTER,
createdAt: '2026-05-01T00:00:00.000Z',
body: {
syncId: 'sync-full',
diffSummary: { added: 7, modified: 0, deleted: 0, unchanged: 0 },
commitSha: null,
workUnits: [
{
unitKey: 'accounts',
rawFiles: ['accounts.schema.json'],
status: 'success',
actions: [
{ target: 'wiki', type: 'created', key: 'knowledge/accounts.md', detail: 'account lifecycle context' },
{ target: 'sl', type: 'created', key: 'orbit_demo.accounts', detail: 'accounts semantic source' },
],
touchedSlSources: [{ connectionId: 'orbit_demo', sourceName: 'orbit_demo.accounts' }],
},
],
failedWorkUnits: [],
reconciliationSkipped: false,
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
evictionInputs: [],
unresolvedCards: [],
supersededBy: null,
overrideOf: null,
provenanceRows: [
{
rawPath: 'accounts.schema.json',
artifactKind: 'wiki',
artifactKey: 'knowledge/accounts.md',
actionType: 'wiki_written',
},
{
rawPath: 'accounts.schema.json',
artifactKind: 'sl',
artifactKey: 'orbit_demo.accounts',
actionType: 'source_created',
},
],
toolTranscripts: [],
},
};
}
describe('full demo helpers', () => {
let tempDir: string;
let projectDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-full-'));
projectDir = join(tempDir, 'demo');
await ensureDemoProject({ projectDir, force: false });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('fails full mode with exact Anthropic env guidance when the key is missing', async () => {
const project = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
expect(() => assertFullDemoCredentials(project, {})).toThrow(
'ktx setup demo --mode full needs ANTHROPIC_API_KEY. Export ANTHROPIC_API_KEY and rerun `ktx setup demo --mode full --no-input`, or run `ktx setup demo --mode seeded --no-input` without credentials.',
);
});
it('respects an existing gateway provider project for full mode', async () => {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: ktx-demo-orbit',
'connections:',
' orbit_demo:',
' driver: sqlite',
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
'llm:',
' provider:',
' backend: gateway',
' models:',
' default: anthropic/claude-sonnet-4-6',
'ingest:',
' adapters:',
' - live-database',
' embeddings:',
' backend: none',
'',
].join('\n'),
'utf-8',
);
const project = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
expect(() => assertFullDemoCredentials(project, {})).not.toThrow();
expect(fullDemoCredentialStatus(project, {})).toEqual({ status: 'ready' });
});
it('reports full-demo credential status without throwing', async () => {
const project = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
expect(fullDemoCredentialStatus(project, {})).toEqual({ status: 'missing-anthropic-key' });
expect(fullDemoCredentialStatus(project, { ANTHROPIC_API_KEY: 'sk-ant-test' })).toEqual({ status: 'ready' }); // pragma: allowlist secret
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: ktx-demo-orbit',
'connections:',
' orbit_demo:',
' driver: sqlite',
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
'ingest:',
' adapters:',
' - live-database',
'',
].join('\n'),
'utf-8',
);
const disabledProject = await import('@ktx/context/project').then((mod) => mod.loadKtxProject({ projectDir }));
expect(fullDemoCredentialStatus(disabledProject, {})).toEqual({ status: 'unsupported-provider', provider: 'none' });
});
it('runs scan first and then full ingest with the canonical demo connection', async () => {
const report = fakeFullReport();
const runLocalScan = vi.fn().mockResolvedValue({
report: {
runId: 'scan-run',
connectionId: DEMO_CONNECTION_ID,
driver: 'sqlite',
mode: 'structural',
syncId: 'sync-scan',
diffSummary: { tablesAdded: 7, tablesModified: 0, tablesDeleted: 0, tablesUnchanged: 0 },
artifactPaths: { rawSourcesDir: 'raw-sources/orbit_demo/live-database/sync-scan', manifestShards: [], reportPath: 'scan-report.json' },
},
});
const runLocalIngest = vi.fn(async (options: RunLocalIngestOptions): Promise<LocalIngestResult> => {
expect(options.adapter).toBe(DEMO_ADAPTER);
expect(options.connectionId).toBe(DEMO_CONNECTION_ID);
expect(options.jobId).toBe(DEMO_FULL_JOB_ID);
expect(options.memoryFlow?.snapshot()).toMatchObject({ runId: DEMO_FULL_JOB_ID, status: 'running' });
options.memoryFlow?.emit({ type: 'source_acquired', adapter: DEMO_ADAPTER, trigger: 'demo_full', fileCount: 7 });
return { result: { ok: true } as never, report };
});
const snapshots: unknown[] = [];
const result = await runDemoFull({
projectDir,
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
runLocalScan,
runLocalIngest,
onMemoryFlowChange: (snapshot) => snapshots.push(snapshot),
});
expect(runLocalScan).toHaveBeenCalledTimes(1);
expect(runLocalIngest).toHaveBeenCalledTimes(1);
expect(result.report).toBe(report);
expect(result.replay.runId).toBe('run-full');
expect(snapshots).toHaveLength(1);
});
it('builds replay and plain summary from the full report', () => {
const report = fakeFullReport();
const replay = buildFullDemoReplay(report);
const summary = formatFullDemoSummary(report);
expect(replay).toMatchObject({
runId: 'run-full',
connectionId: DEMO_CONNECTION_ID,
adapter: DEMO_ADAPTER,
status: 'done',
});
expect(summary).toContain('Full demo ingest: done');
expect(summary).toContain('Saved memory: 1 wiki, 1 semantic layer');
expect(summary).toContain('Provenance rows: 2');
expect(summary).toContain('Next: ktx setup demo inspect');
expect(summary).toContain('Shows the files, semantic-layer sources, and memory KTX just produced.');
expect(summary).toContain('Next: ktx setup demo replay');
expect(summary).toContain('Replays the same visual story without calling the LLM again.');
expect(summary).not.toContain('--viz');
});
});

View file

@ -1,213 +0,0 @@
import { resolveKtxConfigReference } from '@ktx/context/core';
import {
createMemoryFlowLiveBuffer,
ingestReportToMemoryFlowReplay,
runLocalIngest,
type IngestReportSnapshot,
type LocalIngestResult,
type MemoryFlowReplayInput,
type RunLocalIngestOptions,
} from '@ktx/context/ingest';
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import { runLocalScan, type LocalScanRunResult } from '@ktx/context/scan';
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
import { runDemoScan } from './demo-scan.js';
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
import { formatNextStepLines } from './next-steps.js';
interface DemoFullOptions {
projectDir: string;
env?: NodeJS.ProcessEnv;
runLocalScan?: typeof runLocalScan;
runLocalIngest?: typeof runLocalIngest;
onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void;
}
export interface DemoFullResult {
project: KtxLocalProject;
scan: LocalScanRunResult;
ingest: LocalIngestResult;
report: IngestReportSnapshot;
replay: MemoryFlowReplayInput;
}
type FullDemoCredentialStatus =
| { status: 'ready' }
| { status: 'missing-anthropic-key' }
| { status: 'unsupported-provider'; provider: string };
async function ensureDemoProjectForReuse(projectDir: string): Promise<void> {
await ensureDemoProject({ projectDir, force: false }).catch((error) => {
if (error instanceof Error && error.message.includes('Demo project already exists')) {
return;
}
throw error;
});
}
function savedCounts(report: IngestReportSnapshot): { wikiCount: number; slCount: number } {
const actions = report.body.workUnits.flatMap((workUnit) => workUnit.actions);
return {
wikiCount: actions.filter((action) => action.target === 'wiki').length,
slCount: actions.filter((action) => action.target === 'sl').length,
};
}
export function fullDemoCredentialStatus(
project: KtxLocalProject,
env: NodeJS.ProcessEnv = process.env,
): FullDemoCredentialStatus {
const llm = project.config.llm;
if (llm.provider.backend === 'none') {
return { status: 'unsupported-provider', provider: llm.provider.backend };
}
if (llm.provider.backend === 'anthropic' && !resolveKtxConfigReference(llm.provider.anthropic?.api_key, env)) {
return { status: 'missing-anthropic-key' };
}
return { status: 'ready' };
}
export function assertFullDemoCredentials(project: KtxLocalProject, env: NodeJS.ProcessEnv = process.env): void {
const llm = project.config.llm;
const status = fullDemoCredentialStatus(project, env);
if (status.status === 'ready') {
return;
}
if (status.status === 'unsupported-provider') {
throw new Error(
'ktx setup demo --mode full requires llm.provider.backend: anthropic, vertex, or gateway. Run `ktx setup demo init --force --no-input` to recreate the demo config, or run `ktx setup demo --mode seeded --no-input` without credentials.',
);
}
if (llm.provider.backend === 'anthropic') {
throw new Error(
'ktx setup demo --mode full needs ANTHROPIC_API_KEY. Export ANTHROPIC_API_KEY and rerun `ktx setup demo --mode full --no-input`, or run `ktx setup demo --mode seeded --no-input` without credentials.',
);
}
}
export function buildFullDemoReplay(report: IngestReportSnapshot): MemoryFlowReplayInput {
return ingestReportToMemoryFlowReplay(report, { provenanceRowCount: report.body.provenanceRows.length });
}
function initialFullReplay(projectDir: string): MemoryFlowReplayInput {
return {
runId: DEMO_FULL_JOB_ID,
connectionId: DEMO_CONNECTION_ID,
adapter: DEMO_ADAPTER,
status: 'running',
sourceDir: `${projectDir}/raw-sources/${DEMO_CONNECTION_ID}/${DEMO_ADAPTER}`,
syncId: 'pending',
errors: [],
events: [],
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
};
}
export async function runDemoFull(options: DemoFullOptions): Promise<DemoFullResult> {
await ensureDemoProjectForReuse(options.projectDir);
const project = await loadKtxProject({ projectDir: options.projectDir });
assertFullDemoCredentials(project, options.env);
const { result: scan } = await runDemoScan({
projectDir: project.projectDir,
jobId: 'demo-full-scan',
...(options.runLocalScan ? { runLocalScan: options.runLocalScan } : {}),
});
const memoryFlow = options.onMemoryFlowChange
? createMemoryFlowLiveBuffer(initialFullReplay(project.projectDir), { onChange: options.onMemoryFlowChange })
: undefined;
const executeLocalIngest = options.runLocalIngest ?? runLocalIngest;
const ingest = await executeLocalIngest({
project,
adapters: createKtxCliLocalIngestAdapters(project),
adapter: DEMO_ADAPTER,
connectionId: DEMO_CONNECTION_ID,
trigger: 'manual_resync',
jobId: DEMO_FULL_JOB_ID,
...(memoryFlow ? { memoryFlow } : {}),
} satisfies RunLocalIngestOptions);
return {
project,
scan,
ingest,
report: ingest.report,
replay: buildFullDemoReplay(ingest.report),
};
}
export function formatFullDemoSummary(report: IngestReportSnapshot): string {
const counts = savedCounts(report);
return [
'Full demo ingest: done',
`Report: ${report.id}`,
`Run: ${report.runId}`,
`Job: ${report.jobId}`,
`Sync: ${report.body.syncId}`,
`Saved memory: ${counts.wikiCount} wiki, ${counts.slCount} semantic layer`,
`Provenance rows: ${report.body.provenanceRows.length}`,
'Next: ktx setup demo inspect',
' Shows the files, semantic-layer sources, and memory KTX just produced.',
'Next: ktx setup demo replay',
' Replays the same visual story without calling the LLM again.',
'',
].join('\n');
}
const ADAPTER_PREFIXES = ['live_database_', 'metabase_', 'looker_', 'lookml_', 'metricflow_', 'notion_', 'historic_sql_', 'dbt_descriptions_'];
function humanizeUnitKeyForReport(unitKey: string): string {
let key = unitKey.replace(/-/g, '_');
for (const prefix of ADAPTER_PREFIXES) {
if (key.startsWith(prefix)) { key = key.slice(prefix.length); break; }
}
return key.replace(/_/g, ' ');
}
export function formatCleanDemoSummary(report: IngestReportSnapshot, projectDir: string): string {
const counts = savedCounts(report);
const workUnits = report.body.workUnits;
const conflictCount = report.body.conflictsResolved.length;
const areasAnalyzed = workUnits.filter((wu) => wu.actions.length > 0).length;
const lines: string[] = ['', '★ KTX finished ingesting your data', ''];
if (areasAnalyzed > 0) {
lines.push(` ✓ Analyzed ${areasAnalyzed} business area${areasAnalyzed === 1 ? '' : 's'}`);
}
if (!report.body.reconciliationSkipped) {
lines.push(` ✓ Reconciled — ${conflictCount > 0 ? `${conflictCount} conflict${conflictCount === 1 ? '' : 's'} resolved` : 'no conflicts'}`);
}
lines.push('');
if (counts.slCount > 0 || counts.wikiCount > 0) {
lines.push(' KTX created:');
if (counts.slCount > 0) lines.push(` 📊 ${counts.slCount} query definition${counts.slCount === 1 ? '' : 's'} — so agents can write accurate SQL for your data`);
if (counts.wikiCount > 0) lines.push(` 📝 ${counts.wikiCount} knowledge page${counts.wikiCount === 1 ? '' : 's'} — so agents understand your business context`);
lines.push('');
}
const memoryFlow = report.body.memoryFlow;
if (memoryFlow) {
for (const detail of memoryFlow.details.actions) {
if (!detail.summary) continue;
const icon = detail.target === 'sl' ? '📊' : '📝';
lines.push(` ${icon} ${detail.summary}`);
}
}
lines.push('');
lines.push(' What to do next:');
lines.push(...formatNextStepLines());
lines.push('');
lines.push(` Your KTX project files are at: ${projectDir}`);
lines.push('');
return lines.join('\n');
}

View file

@ -1,127 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureDemoProject } from './demo-assets.js';
import {
chooseDemoProjectForInteractiveRun,
createTestDemoPromptAdapter,
resolveFullCredentialDecision,
} from './demo-interaction.js';
function io(isTTY: boolean) {
return {
stdin: { isTTY },
stdout: { isTTY, write: vi.fn() },
stderr: { write: vi.fn() },
};
}
describe('demo interaction decisions', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-interaction-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('reuses a valid project without prompting in no-input mode', async () => {
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
chooseDemoProjectForInteractiveRun({
projectDir: tempDir,
inputMode: 'disabled',
io: io(false),
prompts: createTestDemoPromptAdapter({ choices: [] }),
}),
).resolves.toEqual({ action: 'use', projectDir: tempDir, reset: false });
});
it('fails corrupted projects in no-input mode with reset guidance', async () => {
await ensureDemoProject({ projectDir: tempDir, force: false });
await rm(join(tempDir, 'demo.db'), { force: true });
await expect(
chooseDemoProjectForInteractiveRun({
projectDir: tempDir,
inputMode: 'disabled',
io: io(false),
prompts: createTestDemoPromptAdapter({ choices: [] }),
}),
).rejects.toThrow(
`Demo project is not ready at ${tempDir}: missing demo.db. Run ktx setup demo reset --project-dir ${tempDir} --force --no-input`,
);
});
it('lets interactive users reset a corrupted project', async () => {
await ensureDemoProject({ projectDir: tempDir, force: false });
await rm(join(tempDir, 'demo.db'), { force: true });
await expect(
chooseDemoProjectForInteractiveRun({
projectDir: tempDir,
io: io(true),
prompts: createTestDemoPromptAdapter({ choices: ['reset'], confirms: [true] }),
}),
).resolves.toEqual({ action: 'use', projectDir: tempDir, reset: true });
});
it('lets interactive users choose another project directory', async () => {
await ensureDemoProject({ projectDir: tempDir, force: false });
const otherDir = join(tempDir, 'other-demo');
await expect(
chooseDemoProjectForInteractiveRun({
projectDir: tempDir,
io: io(true),
prompts: createTestDemoPromptAdapter({ choices: ['other'], texts: [otherDir] }),
}),
).resolves.toEqual({ action: 'use', projectDir: otherDir, reset: false });
});
it('uses a pasted Anthropic key only for the returned process env', async () => {
// pragma: allowlist secret
const prompts = createTestDemoPromptAdapter({ choices: ['process_key'], passwords: ['sk-ant-process'] });
await expect(
resolveFullCredentialDecision({
needsAnthropicKey: true,
inputMode: 'auto',
io: io(true),
env: {},
prompts,
}),
).resolves.toEqual({
action: 'full',
env: { ANTHROPIC_API_KEY: 'sk-ant-process' }, // pragma: allowlist secret
});
});
it('lets interactive users explicitly choose seeded mode when the key is missing', async () => {
await expect(
resolveFullCredentialDecision({
needsAnthropicKey: true,
inputMode: 'auto',
io: io(true),
env: {},
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
}),
).resolves.toEqual({ action: 'run-mode', mode: 'seeded' });
});
it('does not prompt when input is disabled', async () => {
await expect(
resolveFullCredentialDecision({
needsAnthropicKey: true,
inputMode: 'disabled',
io: io(false),
env: {},
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
}),
).resolves.toEqual({ action: 'full', env: {} });
});
});

View file

@ -1,202 +0,0 @@
import { cancel, confirm, isCancel, password, select, text } from '@clack/prompts';
import type { Option as ClackOption } from '@clack/prompts';
import { resolve } from 'node:path';
import { inspectDemoProjectState } from './demo-assets.js';
import type { KtxDemoInputMode } from './demo.js';
import { withMenuOptionsSpacing } from './prompt-navigation.js';
type DemoPromptOption<T extends string> = ClackOption<T>;
export interface DemoPromptAdapter {
select<T extends string>(options: { message: string; options: Array<DemoPromptOption<T>> }): Promise<T>;
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
password(options: { message: string }): Promise<string>;
text(options: { message: string; placeholder?: string }): Promise<string>;
cancel(message: string): void;
}
interface DemoInteractiveIo {
stdin?: { isTTY?: boolean };
stdout: { isTTY?: boolean };
}
type DemoProjectDecision =
| { action: 'use'; projectDir: string; reset: boolean }
| { action: 'cancel' };
type FullCredentialDecision =
| { action: 'full'; env: NodeJS.ProcessEnv }
| { action: 'run-mode'; mode: 'seeded' | 'replay' }
| { action: 'cancel' };
function isInteractive(inputMode: KtxDemoInputMode | undefined, io: DemoInteractiveIo): boolean {
return inputMode !== 'disabled' && io.stdin?.isTTY === true && io.stdout.isTTY === true;
}
function cloneEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { ...env };
}
function ensureNotCancelled<T>(value: T | symbol, prompts: Pick<DemoPromptAdapter, 'cancel'>): T {
if (isCancel(value)) {
prompts.cancel('Demo cancelled.');
throw new Error('Demo cancelled.');
}
return value as T;
}
export function createClackDemoPromptAdapter(): DemoPromptAdapter {
return {
async select<T extends string>(options: { message: string; options: Array<DemoPromptOption<T>> }): Promise<T> {
return ensureNotCancelled(await select(withMenuOptionsSpacing(options)), this);
},
async confirm(options: { message: string; initialValue?: boolean }): Promise<boolean> {
return ensureNotCancelled(await confirm(options), this);
},
async password(options: { message: string }): Promise<string> {
return ensureNotCancelled(await password(options), this);
},
async text(options: { message: string; placeholder?: string }): Promise<string> {
return ensureNotCancelled(await text(options), this);
},
cancel(message: string): void {
cancel(message);
},
};
}
export function createTestDemoPromptAdapter(options: {
choices?: string[];
confirms?: boolean[];
passwords?: string[];
texts?: string[];
}): DemoPromptAdapter {
const choices = [...(options.choices ?? [])];
const confirms = [...(options.confirms ?? [])];
const passwords = [...(options.passwords ?? [])];
const texts = [...(options.texts ?? [])];
return {
async select<T extends string>(): Promise<T> {
return choices.shift() as T;
},
async confirm(): Promise<boolean> {
return confirms.shift() ?? false;
},
async password(): Promise<string> {
return passwords.shift() ?? '';
},
async text(): Promise<string> {
return texts.shift() ?? '';
},
cancel(): void {
return;
},
};
}
export async function chooseDemoProjectForInteractiveRun(options: {
projectDir: string;
inputMode?: KtxDemoInputMode;
io: DemoInteractiveIo;
prompts?: DemoPromptAdapter;
}): Promise<DemoProjectDecision> {
const prompts = options.prompts ?? createClackDemoPromptAdapter();
const projectDir = resolve(options.projectDir);
const state = await inspectDemoProjectState(projectDir);
if (!isInteractive(options.inputMode, options.io)) {
if (state.status === 'corrupt') {
throw new Error(
`Demo project is not ready at ${projectDir}: missing ${state.missing.join(', ')}. Run ktx setup demo reset --project-dir ${projectDir} --force --no-input`,
);
}
return { action: 'use', projectDir, reset: false };
}
if (state.status === 'missing') {
return { action: 'use', projectDir, reset: false };
}
const choices =
state.status === 'ready'
? [
{ value: 'reuse', label: 'Reuse existing demo project' },
{ value: 'reset', label: 'Reset demo project' },
{ value: 'other', label: 'Choose another directory' },
{ value: 'cancel', label: 'Cancel' },
]
: [
{ value: 'reset', label: 'Reset corrupted demo project', hint: `Missing ${state.missing.join(', ')}` },
{ value: 'other', label: 'Choose another directory' },
{ value: 'cancel', label: 'Cancel' },
];
const choice = await prompts.select({
message: state.status === 'ready' ? `Demo project exists at ${projectDir}` : `Demo project is not ready at ${projectDir}`,
options: choices,
});
if (choice === 'cancel') {
prompts.cancel('Demo cancelled.');
return { action: 'cancel' };
}
if (choice === 'other') {
const nextProjectDir = await prompts.text({
message: 'Demo project directory',
placeholder: projectDir,
});
return { action: 'use', projectDir: resolve(nextProjectDir), reset: false };
}
if (choice === 'reset') {
const confirmed = await prompts.confirm({
message: `Recreate ${projectDir}? Existing demo artifacts under that directory will be removed.`,
initialValue: false,
});
return confirmed ? { action: 'use', projectDir, reset: true } : { action: 'cancel' };
}
return { action: 'use', projectDir, reset: false };
}
export async function resolveFullCredentialDecision(options: {
needsAnthropicKey: boolean;
inputMode?: KtxDemoInputMode;
io: DemoInteractiveIo;
env: NodeJS.ProcessEnv;
prompts?: DemoPromptAdapter;
}): Promise<FullCredentialDecision> {
const env = cloneEnv(options.env);
if (!options.needsAnthropicKey || env.ANTHROPIC_API_KEY) {
return { action: 'full', env };
}
if (!isInteractive(options.inputMode, options.io)) {
return { action: 'full', env };
}
const prompts = options.prompts ?? createClackDemoPromptAdapter();
const choice = await prompts.select({
message: 'Anthropic credentials are missing for the full demo',
options: [
{ value: 'process_key', label: 'Enter key for this process only' },
{ value: 'seeded', label: 'Run pre-seeded demo without LLM' },
{ value: 'replay', label: 'Run packaged replay' },
{ value: 'cancel', label: 'Cancel' },
],
});
if (choice === 'cancel') {
prompts.cancel('Demo cancelled.');
return { action: 'cancel' };
}
if (choice === 'seeded' || choice === 'replay') {
return { action: 'run-mode', mode: choice };
}
const key = await prompts.password({ message: 'ANTHROPIC_API_KEY' });
return { action: 'full', env: { ...env, ANTHROPIC_API_KEY: key } };
}

View file

@ -1,228 +0,0 @@
import type { MemoryFlowEvent, MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
import { describe, expect, it } from 'vitest';
import { createPlainProgressEmitter, formatMemoryFlowEventLine } from './demo-progress.js';
function snapshot(events: MemoryFlowEvent[]): MemoryFlowReplayInput {
return {
runId: 'run-1',
connectionId: 'orbit_demo',
adapter: 'live-database',
status: 'running',
sourceDir: null,
syncId: 'sync-1',
errors: [],
events,
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
};
}
describe('formatMemoryFlowEventLine', () => {
it('formats source_acquired in plain English with adapter and file count', () => {
expect(
formatMemoryFlowEventLine({
type: 'source_acquired',
adapter: 'live-database',
trigger: 'manual_resync',
fileCount: 7,
}),
).toBe('[connect] Connected live-database - 7 database files (manual_resync)');
});
it('formats diff_computed as a comma-separated breakdown', () => {
expect(
formatMemoryFlowEventLine({
type: 'diff_computed',
added: 3,
modified: 1,
deleted: 0,
unchanged: 4,
}),
).toBe('[diff] Tables: +3 new, ~1 changed, =4 unchanged');
});
it('formats diff_computed as "no changes" when every counter is zero', () => {
expect(
formatMemoryFlowEventLine({
type: 'diff_computed',
added: 0,
modified: 0,
deleted: 0,
unchanged: 0,
}),
).toBe('[diff] Tables: no changes');
});
it('formats chunks_planned without removals as a single readable sentence', () => {
expect(
formatMemoryFlowEventLine({
type: 'chunks_planned',
chunkCount: 7,
workUnitCount: 5,
evictionCount: 0,
}),
).toBe('[plan] Grouped 5 tables into 7 business areas');
});
it('formats chunks_planned with removals when evictions are non-zero', () => {
expect(
formatMemoryFlowEventLine({
type: 'chunks_planned',
chunkCount: 7,
workUnitCount: 5,
evictionCount: 2,
}),
).toBe('[plan] Grouped 5 tables into 7 business areas (2 removals)');
});
it('formats work_unit_started in human terms', () => {
expect(
formatMemoryFlowEventLine({
type: 'work_unit_started',
unitKey: 'revenue-policy',
skills: ['sl_expert', 'wiki_writer'],
stepBudget: 40,
}),
).toBe('[analyze] Reviewing "revenue-policy" - budget 40 agent steps');
});
it('suppresses noisy work_unit_step events', () => {
expect(
formatMemoryFlowEventLine({
type: 'work_unit_step',
unitKey: 'revenue-policy',
stepIndex: 3,
stepBudget: 40,
}),
).toBeNull();
});
it('formats candidate_action with friendly target and arrow', () => {
expect(
formatMemoryFlowEventLine({
type: 'candidate_action',
unitKey: 'revenue-policy',
target: 'sl',
action: 'created',
key: 'warehouse.revenue',
}),
).toBe('[draft] revenue-policy -> semantic-layer: created warehouse.revenue');
});
it('formats work_unit_finished with status-aware tag', () => {
expect(
formatMemoryFlowEventLine({
type: 'work_unit_finished',
unitKey: 'revenue-policy',
status: 'success',
}),
).toBe('[done] revenue-policy reviewed');
expect(
formatMemoryFlowEventLine({
type: 'work_unit_finished',
unitKey: 'revenue-policy',
status: 'failed',
reason: 'budget exhausted',
}),
).toBe('[fail] revenue-policy needs attention - budget exhausted');
});
it('formats reconciliation_finished with friendly counter wording', () => {
expect(
formatMemoryFlowEventLine({
type: 'reconciliation_finished',
conflictCount: 0,
fallbackCount: 0,
}),
).toBe('[validate] Reconciled drafts - no conflicts, nothing flagged for review');
expect(
formatMemoryFlowEventLine({
type: 'reconciliation_finished',
conflictCount: 2,
fallbackCount: 1,
}),
).toBe('[validate] Reconciled drafts - 2 conflicts, 1 item flagged for review');
});
it('formats saved with optional shortened commit sha and pluralized memory count', () => {
expect(
formatMemoryFlowEventLine({
type: 'saved',
commitSha: 'abc1234567890', // pragma: allowlist secret
wikiCount: 2,
slCount: 5,
}),
).toBe('[memory] Saved 7 memories (2 wiki, 5 semantic-layer) - commit abc1234');
expect(
formatMemoryFlowEventLine({
type: 'saved',
commitSha: null,
wikiCount: 0,
slCount: 1,
}),
).toBe('[memory] Saved 1 memory (0 wiki, 1 semantic-layer)');
});
it('formats report_created with run id', () => {
expect(
formatMemoryFlowEventLine({
type: 'report_created',
runId: 'run-xyz',
}),
).toBe('[report] Run report ready: run-xyz');
});
});
describe('createPlainProgressEmitter', () => {
it('writes one line per new event and never re-emits prior events', () => {
const written: string[] = [];
const io = {
stdout: { write: (chunk: string) => written.push(chunk), isTTY: false },
stderr: { write: () => undefined },
};
const emit = createPlainProgressEmitter(io);
emit(
snapshot([
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 },
{ type: 'diff_computed', added: 0, modified: 0, deleted: 0, unchanged: 7 },
]),
);
emit(
snapshot([
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 },
{ type: 'diff_computed', added: 0, modified: 0, deleted: 0, unchanged: 7 },
{ type: 'work_unit_started', unitKey: 'revenue-policy', skills: ['sl_expert'], stepBudget: 40 },
]),
);
expect(written).toEqual([
'[connect] Connected live-database - 7 database files (manual_resync)\n',
'[diff] Tables: =7 unchanged\n',
'[analyze] Reviewing "revenue-policy" - budget 40 agent steps\n',
]);
});
it('skips suppressed events without advancing visible output', () => {
const written: string[] = [];
const io = {
stdout: { write: (chunk: string) => written.push(chunk), isTTY: false },
stderr: { write: () => undefined },
};
const emit = createPlainProgressEmitter(io);
emit(
snapshot([
{ type: 'work_unit_step', unitKey: 'a', stepIndex: 1, stepBudget: 40 },
{ type: 'work_unit_step', unitKey: 'a', stepIndex: 2, stepBudget: 40 },
{ type: 'work_unit_finished', unitKey: 'a', status: 'success' },
]),
);
expect(written).toEqual(['[done] a reviewed\n']);
});
});

View file

@ -1,77 +0,0 @@
import type { MemoryFlowEvent, MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
import type { KtxDemoIo } from './demo.js';
function plural(n: number, one: string, many = `${one}s`): string {
return `${n} ${n === 1 ? one : many}`;
}
function formatDiff(added: number, modified: number, deleted: number, unchanged: number): string {
const parts: string[] = [];
if (added > 0) parts.push(`+${added} new`);
if (modified > 0) parts.push(`~${modified} changed`);
if (deleted > 0) parts.push(`-${deleted} removed`);
if (unchanged > 0) parts.push(`=${unchanged} unchanged`);
return parts.length > 0 ? parts.join(', ') : 'no changes';
}
export function formatMemoryFlowEventLine(event: MemoryFlowEvent): string | null {
switch (event.type) {
case 'source_acquired':
return `[connect] Connected ${event.adapter} - ${plural(event.fileCount, 'database file')} (${event.trigger})`;
case 'scope_detected':
return event.fingerprint
? `[scope] Scope locked: ${event.fingerprint}`
: '[scope] Reviewing the whole warehouse (no scope filter)';
case 'raw_snapshot_written':
return `[snapshot] Captured snapshot ${event.syncId} - ${plural(event.rawFileCount, 'file')}`;
case 'diff_computed':
return `[diff] Tables: ${formatDiff(event.added, event.modified, event.deleted, event.unchanged)}`;
case 'chunks_planned':
return event.evictionCount > 0
? `[plan] Grouped ${plural(event.workUnitCount, 'table')} into ${plural(event.chunkCount, 'business area')} (${plural(event.evictionCount, 'removal')})`
: `[plan] Grouped ${plural(event.workUnitCount, 'table')} into ${plural(event.chunkCount, 'business area')}`;
case 'stage_skipped':
return `[skip] ${event.stage} skipped: ${event.reason}`;
case 'work_unit_started':
return `[analyze] Reviewing "${event.unitKey}" - budget ${plural(event.stepBudget, 'agent step')}`;
case 'work_unit_step':
return null;
case 'candidate_action': {
const target = event.target === 'sl' ? 'semantic-layer' : 'wiki';
return `[draft] ${event.unitKey} -> ${target}: ${event.action} ${event.key}`;
}
case 'work_unit_finished':
if (event.status === 'success') {
return `[done] ${event.unitKey} reviewed`;
}
return `[fail] ${event.unitKey} needs attention${event.reason ? ` - ${event.reason}` : ''}`;
case 'reconciliation_finished': {
const conflicts = event.conflictCount === 0 ? 'no conflicts' : plural(event.conflictCount, 'conflict');
const fallbacks = event.fallbackCount === 0 ? 'nothing flagged for review' : `${plural(event.fallbackCount, 'item')} flagged for review`;
return `[validate] Reconciled drafts - ${conflicts}, ${fallbacks}`;
}
case 'saved': {
const total = event.wikiCount + event.slCount;
const commit = event.commitSha ? ` - commit ${event.commitSha.slice(0, 7)}` : '';
return `[memory] Saved ${plural(total, 'memory', 'memories')} (${event.wikiCount} wiki, ${event.slCount} semantic-layer)${commit}`;
}
case 'provenance_recorded':
return `[trace] Recorded provenance for ${plural(event.rowCount, 'row')}`;
case 'report_created':
return `[report] Run report ready: ${event.runId}`;
}
}
export function createPlainProgressEmitter(io: KtxDemoIo): (snapshot: MemoryFlowReplayInput) => void {
let printed = 0;
return (snapshot) => {
while (printed < snapshot.events.length) {
const event = snapshot.events[printed++];
if (!event) continue;
const line = formatMemoryFlowEventLine(event);
if (line !== null) {
io.stdout.write(`${line}\n`);
}
}
};
}

View file

@ -1,60 +0,0 @@
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { type MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
import { describe, expect, it } from 'vitest';
import { DEMO_LATEST_REPLAY_FILE, loadLatestDemoReplay, writeDemoReplay } from './demo-replay-store.js';
function replay(overrides: Partial<MemoryFlowReplayInput> = {}): MemoryFlowReplayInput {
return {
metadata: {
schemaVersion: 1,
mode: 'full',
origin: 'captured',
timing: 'captured',
capturedAt: '2026-05-01T10:00:03.000Z',
sourceReportId: 'report-1',
sourceReportPath: 'report-1',
fallbackReason: null,
},
runId: 'run-1',
connectionId: 'orbit_demo',
adapter: 'live-database',
status: 'done',
sourceDir: null,
syncId: 'sync-1',
reportId: 'report-1',
reportPath: 'report-1',
errors: [],
events: [{ type: 'report_created', runId: 'run-1', reportPath: 'report-1', emittedAt: '2026-05-01T10:00:03.000Z' }],
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
...overrides,
};
}
describe('demo replay store', () => {
it('writes a versioned replay file and updates latest', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'ktx-demo-replay-store-'));
const saved = await writeDemoReplay(projectDir, replay(), { label: 'full' });
expect(saved.replayPath).toMatch(/replays[/\\]full-run-1.memory-flow.v1.json$/);
expect(saved.latestReplayPath).toBe(join(projectDir, 'replays', DEMO_LATEST_REPLAY_FILE));
expect(await loadLatestDemoReplay(projectDir)).toMatchObject({
runId: 'run-1',
metadata: { mode: 'full', origin: 'captured', timing: 'captured' },
});
const wrapper = JSON.parse(await readFile(saved.latestReplayPath, 'utf-8')) as {
memoryFlowReplaySchemaVersion?: number;
};
expect(wrapper.memoryFlowReplaySchemaVersion).toBe(1);
});
it('returns null when no latest local replay exists', async () => {
const projectDir = await mkdtemp(join(tmpdir(), 'ktx-demo-replay-store-empty-'));
await expect(loadLatestDemoReplay(projectDir)).resolves.toBeNull();
});
});

View file

@ -1,68 +0,0 @@
import { constants as fsConstants } from 'node:fs';
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { parseMemoryFlowReplayInput, type MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
interface StoredMemoryFlowReplayFile {
memoryFlowReplaySchemaVersion: 1;
replay: unknown;
}
interface SavedDemoReplay {
replayPath: string;
latestReplayPath: string;
}
export const DEMO_LATEST_REPLAY_FILE = 'latest.memory-flow.v1.json';
async function exists(path: string): Promise<boolean> {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
function safeReplayName(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'replay';
}
function demoReplayFileName(input: MemoryFlowReplayInput, label: string): string {
return `${safeReplayName(label)}-${safeReplayName(input.runId)}.memory-flow.v1.json`;
}
function wrapReplay(input: MemoryFlowReplayInput): StoredMemoryFlowReplayFile {
return { memoryFlowReplaySchemaVersion: 1, replay: input };
}
export async function loadDemoReplayFile(path: string): Promise<MemoryFlowReplayInput> {
const parsed = JSON.parse(await readFile(path, 'utf-8')) as StoredMemoryFlowReplayFile;
if (parsed.memoryFlowReplaySchemaVersion !== 1) {
throw new Error(`Unsupported demo replay schema version in ${path}`);
}
return parseMemoryFlowReplayInput(parsed.replay);
}
export async function loadLatestDemoReplay(projectDir: string): Promise<MemoryFlowReplayInput | null> {
const latestPath = join(resolve(projectDir), 'replays', DEMO_LATEST_REPLAY_FILE);
if (!(await exists(latestPath))) {
return null;
}
return loadDemoReplayFile(latestPath);
}
export async function writeDemoReplay(
projectDir: string,
input: MemoryFlowReplayInput,
options: { label: 'full' | 'deterministic' | 'seeded' },
): Promise<SavedDemoReplay> {
const replayDir = join(resolve(projectDir), 'replays');
await mkdir(replayDir, { recursive: true });
const replayPath = join(replayDir, demoReplayFileName(input, options.label));
const latestReplayPath = join(replayDir, DEMO_LATEST_REPLAY_FILE);
const body = `${JSON.stringify(wrapReplay(input), null, 2)}\n`;
await writeFile(replayPath, body, 'utf-8');
await copyFile(replayPath, latestReplayPath);
return { replayPath, latestReplayPath };
}

View file

@ -1,31 +0,0 @@
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { findLatestDemoScanReport, runDemoScan } from './demo-scan.js';
describe('demo scan helpers', () => {
const projectDir = join(tmpdir(), `ktx-demo-scan-${process.pid}`);
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('runs the packaged SQLite demo scan and finds the latest scan report', async () => {
const { result } = await runDemoScan({
projectDir,
jobId: 'demo-scan-test',
now: () => new Date('2026-05-06T10:00:00.000Z'),
});
expect(result.report).toMatchObject({
connectionId: 'orbit_demo',
driver: 'sqlite',
runId: 'demo-scan-test',
mode: 'structural',
dryRun: false,
});
expect(result.report.artifactPaths.reportPath).toContain('raw-sources/orbit_demo/live-database/');
await expect(findLatestDemoScanReport(projectDir)).resolves.toMatchObject({ runId: 'demo-scan-test' });
});
});

View file

@ -1,223 +0,0 @@
import { getLocalIngestStatus, type IngestReportSnapshot, type MemoryFlowReplayInput } from '@ktx/context/ingest';
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import { runLocalScan, type KtxScanReport, type LocalScanRunResult } from '@ktx/context/scan';
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
import { loadLatestDemoReplay } from './demo-replay-store.js';
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
interface DemoScanOptions {
projectDir: string;
jobId?: string;
now?: () => Date;
runLocalScan?: typeof runLocalScan;
}
interface DemoScanResult {
project: KtxLocalProject;
result: LocalScanRunResult;
}
interface DemoInspectSummary {
projectDir: string;
scanReport: KtxScanReport | null;
fullReport: IngestReportSnapshot | null;
semanticLayerFileCount: number;
knowledgeFileCount: number;
replayFileCount: number;
latestReplay: MemoryFlowReplayInput | null;
}
interface DemoInspectDeps {
findFullReport?: (project: KtxLocalProject) => Promise<IngestReportSnapshot | null>;
}
async function ensureDemoProjectForReuse(projectDir: string): Promise<void> {
await ensureDemoProject({ projectDir, force: false }).catch((error) => {
if (error instanceof Error && error.message.includes('Demo project already exists')) {
return;
}
throw error;
});
}
async function loadReadyDemoProject(projectDir: string): Promise<KtxLocalProject> {
try {
return await loadKtxProject({ projectDir });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Demo project is not ready at ${projectDir}: ${reason}. Run ktx setup demo init --project-dir ${projectDir} --force --no-input to recreate it.`,
);
}
}
function reportDiff(report: KtxScanReport): string {
return `+${report.diffSummary.tablesAdded}/~${report.diffSummary.tablesModified}/-${report.diffSummary.tablesDeleted}/=${report.diffSummary.tablesUnchanged}`;
}
function jsonReport(raw: string, path: string): KtxScanReport {
try {
return JSON.parse(raw) as KtxScanReport;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid demo scan report at ${path}: ${reason}`);
}
}
async function countFiles(project: KtxLocalProject, root: string, predicate: (path: string) => boolean): Promise<number> {
const { files } = await project.fileStore.listFiles(root, true);
return files.filter(predicate).length;
}
async function findFullDemoReport(project: KtxLocalProject): Promise<IngestReportSnapshot | null> {
return getLocalIngestStatus(project, DEMO_FULL_JOB_ID);
}
function savedCounts(report: IngestReportSnapshot): { wikiCount: number; slCount: number } {
const actions = report.body.workUnits.flatMap((workUnit) => workUnit.actions);
return {
wikiCount: actions.filter((action) => action.target === 'wiki').length,
slCount: actions.filter((action) => action.target === 'sl').length,
};
}
export async function runDemoScan(options: DemoScanOptions): Promise<DemoScanResult> {
await ensureDemoProjectForReuse(options.projectDir);
const project = await loadReadyDemoProject(options.projectDir);
const executeScan = options.runLocalScan ?? runLocalScan;
const result = await executeScan({
project,
connectionId: DEMO_CONNECTION_ID,
mode: 'structural',
trigger: 'cli',
jobId: options.jobId ?? 'demo-scan',
now: options.now,
adapters: createKtxCliLocalIngestAdapters(project),
});
return { project, result };
}
export async function findLatestDemoScanReport(projectDir: string): Promise<KtxScanReport | null> {
const project = await loadReadyDemoProject(projectDir);
const root = `raw-sources/${DEMO_CONNECTION_ID}/${DEMO_ADAPTER}`;
const { files } = await project.fileStore.listFiles(root, true);
const latest = files
.filter((path) => path.endsWith('/scan-report.json'))
.sort()
.at(-1);
if (!latest) {
return null;
}
const reportPath = `${root}/${latest}`;
const report = await project.fileStore.readFile(reportPath);
return jsonReport(report.content, reportPath);
}
export async function inspectDemoProject(
projectDir: string,
projectOverride?: KtxLocalProject,
deps: DemoInspectDeps = {},
): Promise<DemoInspectSummary> {
const project = projectOverride ?? (await loadReadyDemoProject(projectDir));
const scanReport = await findLatestDemoScanReport(project.projectDir);
const fullReport = await (deps.findFullReport ?? findFullDemoReport)(project);
const semanticLayerFileCount = await countFiles(
project,
`semantic-layer/${DEMO_CONNECTION_ID}`,
(path) => path.endsWith('.yaml') || path.endsWith('.yml'),
);
const knowledgeFileCount = await countFiles(project, 'knowledge', (path) => path.endsWith('.md'));
const replayFileCount = await countFiles(project, 'replays', (path) => path.endsWith('.json'));
const latestReplay = await loadLatestDemoReplay(project.projectDir);
return {
projectDir: project.projectDir,
scanReport,
fullReport,
semanticLayerFileCount,
knowledgeFileCount,
replayFileCount,
latestReplay,
};
}
export function formatDemoScanSummary(report: KtxScanReport): string {
return [
'Demo scan: done',
`Connection: ${report.connectionId}`,
`Driver: ${report.driver}`,
`Mode: ${report.mode}`,
`Tables: ${reportDiff(report)}`,
`Semantic-layer artifacts: ${report.artifactPaths.manifestShards.length}`,
`Report: ${report.artifactPaths.reportPath ?? 'none'}`,
'Next: ktx setup demo inspect',
' Shows the files and semantic-layer draft created from the database scan.',
'',
].join('\n');
}
function replayLine(replay: MemoryFlowReplayInput | null): string {
if (!replay?.metadata) {
return 'Latest replay: packaged demo replay';
}
return `Latest replay: ${replay.metadata.mode} (${replay.metadata.origin}, ${replay.metadata.timing})`;
}
export function formatDemoInspect(summary: DemoInspectSummary): string {
const report = summary.scanReport;
const fullReport = summary.fullReport;
const fullCounts = fullReport ? savedCounts(fullReport) : null;
const scanLines = report
? [
'Scan artifacts: yes',
`Connection: ${report.connectionId}`,
`Driver: ${report.driver}`,
`Tables: ${reportDiff(report)}`,
`Report: ${report.artifactPaths.reportPath ?? 'none'}`,
]
: ['Scan artifacts: none'];
const memoryLines = fullReport
? [
'Memory synthesis: ran',
`Full report: ${fullReport.id}`,
`Full run: ${fullReport.runId}`,
`Saved memory: ${fullCounts?.wikiCount ?? 0} wiki, ${fullCounts?.slCount ?? 0} semantic layer`,
`Provenance rows: ${fullReport.body.provenanceRows.length}`,
]
: [report ? 'Memory synthesis: full mode not run' : 'Memory synthesis: not run'];
const next = fullReport
? [
`Next: ktx ingest watch ${fullReport.runId} --project-dir ${summary.projectDir}`,
' Opens the captured run timeline and lets you inspect what happened.',
'Next: ktx setup demo replay',
' Replays the same visual story without calling the LLM again.',
]
: report
? [
'Next: ktx setup demo --mode full',
' Runs the full AI-backed pass with your LLM provider.',
'Next: ktx setup demo replay',
' Replays the packaged visual story without calling the LLM.',
]
: [
'Next: ktx setup demo --no-input',
' Runs the pre-seeded demo without calling the LLM.',
'Next: ktx setup demo --mode full',
' Runs the full AI-backed pass with your LLM provider.',
];
return [
`Demo project: ${summary.projectDir}`,
...scanLines,
`Semantic-layer files: ${summary.semanticLayerFileCount}`,
`Knowledge files: ${summary.knowledgeFileCount}`,
`Replay files: ${summary.replayFileCount}`,
replayLine(summary.latestReplay),
...memoryLines,
...next,
'',
].join('\n');
}

View file

@ -1,120 +0,0 @@
import { access, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { runDemoSeeded } from './demo-seeded.js';
import { formatSeededInspect, inspectSeededProject } from './demo-seeded-inspect.js';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
describe('seeded demo inspect contract', () => {
const projectDir = join(tmpdir(), `ktx-demo-seeded-inspect-${process.pid}`);
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('reports the PRD source inventory, generated outputs, status, metadata, and next commands', async () => {
await runDemoSeeded({ projectDir });
const inspect = await inspectSeededProject(projectDir);
expect(inspect).toMatchObject({
projectDir,
mode: 'seeded',
status: { status: 'ready', missing: [] },
modeMetadata: {
mode: 'seeded',
source: 'packaged demo project',
generatedContext: 'prebuilt from bundled assets',
llmCalls: 'none',
origin: 'packaged',
timing: 'prebuilt',
sourceReportId: 'demo-seeded-report',
sourceReportPath: 'reports/seeded-demo-report.json',
},
sourceBundle: {
warehouse: {
label: 'Warehouse',
path: 'demo.db',
tableCount: 8,
totalRows: 11234,
rowCounts: {
accounts: 210,
arr_movements: 720,
contracts: 320,
invoices: 3000,
plans: 4,
purchase_requests: 5200,
support_tickets: 520,
users: 1260,
},
},
dbt: { label: 'dbt', path: 'raw-sources/dbt', modelCount: 3, sourceTableCount: 8 },
bi: { label: 'BI', path: 'raw-sources/bi', exploreCount: 5, dashboardCount: 2 },
notion: { label: 'Notion', path: 'raw-sources/notion', pageCount: 8 },
},
generatedOutputs: {
semanticLayer: { path: 'semantic-layer', manifestSourceCount: 46, fileCount: 46 },
knowledge: { path: 'knowledge/global', manifestPageCount: 28, fileCount: 28 },
links: { path: 'links/provenance.json', manifestLinkCount: 23, linkCount: 23 },
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
replays: { primaryPath: 'replays/replay.memory-flow.v1.json', latestPath: 'replays/latest.memory-flow.v1.json' },
},
nextCommands: KTX_NEXT_STEP_DIRECT_COMMANDS,
});
expect(inspect.generatedOutputs.replays.fileCount).toBeGreaterThanOrEqual(3);
await expect(access(join(projectDir, inspect.generatedOutputs.reports.primaryPath))).resolves.toBeUndefined();
await expect(access(join(projectDir, inspect.generatedOutputs.replays.primaryPath))).resolves.toBeUndefined();
await expect(access(join(projectDir, inspect.generatedOutputs.replays.latestPath))).resolves.toBeUndefined();
});
it('formats seeded inspect from the normalized contract', async () => {
await runDemoSeeded({ projectDir });
const output = formatSeededInspect(await inspectSeededProject(projectDir));
expect(output).toContain(`Demo project: ${projectDir}`);
expect(output).toContain('Status: ready');
expect(output).toContain('Mode: seeded (pre-seeded demo project)');
expect(output).toContain('Source: packaged demo project');
expect(output).toContain('Generated context: prebuilt from bundled assets');
expect(output).toContain('LLM calls: none');
expect(output).toContain('Warehouse: 8 tables, 11,234 rows');
expect(output).toContain('Rows: accounts 210, arr_movements 720, contracts 320, invoices 3000');
expect(output).toContain('dbt: 3 models, 8 source tables');
expect(output).toContain('BI: 5 explores, 2 dashboards');
expect(output).toContain('Notion: 8 pages');
expect(output).toContain('Semantic-layer sources: 46 manifest, 46 files');
expect(output).toContain('Knowledge pages: 28 manifest, 28 files');
expect(output).toContain('Evidence links: 23 manifest, 23 links');
expect(output).toContain('Report: reports/seeded-demo-report.json');
expect(output).toContain('Replay: replays/replay.memory-flow.v1.json');
expect(output).toContain('Latest replay: seeded (packaged, prebuilt)');
expect(output).toContain(' $ ktx agent tools --json');
expect(output).toContain(' $ ktx agent context --json');
expect(output).not.toContain('ktx serve --mcp stdio --user-id local');
expect(output).not.toContain('ktx ask');
expect(output).not.toContain('deterministic mode');
});
it('reports missing seeded paths without reading stale counts as ready', async () => {
await runDemoSeeded({ projectDir });
await rm(join(projectDir, 'links', 'provenance.json'));
const inspect = await inspectSeededProject(projectDir);
expect(inspect.status).toEqual({ status: 'corrupt', missing: ['links/provenance.json'] });
expect(formatSeededInspect(inspect)).toContain('Status: corrupt');
expect(formatSeededInspect(inspect)).toContain('Missing: links/provenance.json');
});
it('keeps provenance link counts tied to the project file', async () => {
await runDemoSeeded({ projectDir });
const inspect = await inspectSeededProject(projectDir);
const raw = await readFile(join(projectDir, 'links', 'provenance.json'), 'utf-8');
const links = JSON.parse(raw) as unknown[];
expect(inspect.generatedOutputs.links.linkCount).toBe(links.length);
expect(inspect.generatedOutputs.links.linkCount).toBe(23);
});
});

View file

@ -1,296 +0,0 @@
import { constants as fsConstants } from 'node:fs';
import { access, readFile, readdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
import { loadPackagedDemoReplay } from './demo-assets.js';
import { DEMO_LATEST_REPLAY_FILE, loadLatestDemoReplay } from './demo-replay-store.js';
import { KTX_NEXT_STEP_COMMAND_WIDTH, KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
type SeededInspectReadiness = 'missing' | 'ready' | 'corrupt';
export interface DemoSeededManifest {
demoAssetSchemaVersion: number;
name: string;
displayName: string;
mode: string;
source?: string;
sources: {
warehouse: { label: string; path?: string; tables: number; rowCounts: Record<string, number> };
dbt: { label: string; path?: string; models: number; sourceTables: number };
bi: { label: string; path?: string; explores: number; dashboards: number };
notion: { label: string; path?: string; pages: number };
};
generated: {
semanticLayer: { path?: string; sourceCount: number };
knowledge: { path?: string; pageCount: number };
links: { path?: string; linkCount: number };
};
}
export interface SeededInspectSummary {
projectDir: string;
mode: 'seeded';
manifest: DemoSeededManifest;
status: { status: SeededInspectReadiness; missing: string[] };
sourceBundle: {
warehouse: {
label: string;
path: string;
tableCount: number;
rowCounts: Record<string, number>;
totalRows: number;
};
dbt: { label: string; path: string; modelCount: number; sourceTableCount: number };
bi: { label: string; path: string; exploreCount: number; dashboardCount: number };
notion: { label: string; path: string; pageCount: number };
};
generatedOutputs: {
semanticLayer: { path: string; manifestSourceCount: number; fileCount: number };
knowledge: { path: string; manifestPageCount: number; fileCount: number };
links: { path: string; manifestLinkCount: number; linkCount: number };
reports: { primaryPath: string; fileCount: number };
replays: { primaryPath: string; latestPath: string; fileCount: number };
};
modeMetadata: {
mode: 'seeded';
source: 'packaged demo project';
generatedContext: 'prebuilt from bundled assets';
llmCalls: 'none';
origin: string;
timing: string;
sourceReportId: string | null;
sourceReportPath: string | null;
};
nextCommands: Array<{ command: string; description: string }>;
latestReplay: MemoryFlowReplayInput | null;
}
const REQUIRED_SEEDED_PROJECT_PATHS = [
'ktx.yaml',
'demo.db',
'state.sqlite',
'manifest.json',
join('replays', 'replay.memory-flow.v1.json'),
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
join('knowledge', 'global', 'orbit-company-overview.md'),
join('links', 'provenance.json'),
join('reports', 'seeded-demo-report.json'),
] as const;
async function exists(path: string): Promise<boolean> {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
async function loadSeededManifest(projectDir: string): Promise<DemoSeededManifest> {
const raw = await readFile(join(projectDir, 'manifest.json'), 'utf-8');
return JSON.parse(raw) as DemoSeededManifest;
}
async function listFilesInDir(dir: string, ext?: string): Promise<string[]> {
try {
const entries = await readdir(dir, { recursive: true });
return entries
.filter((entry): entry is string => typeof entry === 'string')
.filter((entry) => !ext || entry.endsWith(ext))
.sort();
} catch {
return [];
}
}
async function inspectSeededProjectStatus(projectDir: string): Promise<{ status: SeededInspectReadiness; missing: string[] }> {
const missing: string[] = [];
for (const relativePath of REQUIRED_SEEDED_PROJECT_PATHS) {
if (!(await exists(join(projectDir, relativePath)))) {
missing.push(relativePath);
}
}
if (missing.length === REQUIRED_SEEDED_PROJECT_PATHS.length) {
return { status: 'missing', missing };
}
if (missing.length > 0) {
return { status: 'corrupt', missing };
}
return { status: 'ready', missing: [] };
}
async function loadLinksCount(projectDir: string): Promise<number> {
try {
const raw = await readFile(join(projectDir, 'links', 'provenance.json'), 'utf-8');
const links = JSON.parse(raw) as unknown[];
return links.length;
} catch {
return 0;
}
}
async function loadSeededReplay(projectDir: string): Promise<MemoryFlowReplayInput | null> {
const latest = await loadLatestDemoReplay(projectDir);
if (latest) {
return latest;
}
try {
return await loadPackagedDemoReplay();
} catch {
return null;
}
}
function sourceBundleFromManifest(manifest: DemoSeededManifest): SeededInspectSummary['sourceBundle'] {
const warehouse = manifest.sources.warehouse;
const rowCounts = Object.fromEntries(Object.entries(warehouse.rowCounts).sort(([a], [b]) => a.localeCompare(b)));
const totalRows = Object.values(rowCounts).reduce((total, count) => total + count, 0);
return {
warehouse: {
label: warehouse.label,
path: warehouse.path ?? 'demo.db',
tableCount: warehouse.tables,
rowCounts,
totalRows,
},
dbt: {
label: manifest.sources.dbt.label,
path: manifest.sources.dbt.path ?? 'raw-sources/dbt',
modelCount: manifest.sources.dbt.models,
sourceTableCount: manifest.sources.dbt.sourceTables,
},
bi: {
label: manifest.sources.bi.label,
path: manifest.sources.bi.path ?? 'raw-sources/bi',
exploreCount: manifest.sources.bi.explores,
dashboardCount: manifest.sources.bi.dashboards,
},
notion: {
label: manifest.sources.notion.label,
path: manifest.sources.notion.path ?? 'raw-sources/notion',
pageCount: manifest.sources.notion.pages,
},
};
}
function nextCommands(): SeededInspectSummary['nextCommands'] {
return [...KTX_NEXT_STEP_DIRECT_COMMANDS];
}
function modeMetadataFromReplay(replay: MemoryFlowReplayInput | null): SeededInspectSummary['modeMetadata'] {
return {
mode: 'seeded',
source: 'packaged demo project',
generatedContext: 'prebuilt from bundled assets',
llmCalls: 'none',
origin: replay?.metadata?.origin ?? 'packaged',
timing: replay?.metadata?.timing ?? 'prebuilt',
sourceReportId: replay?.metadata?.sourceReportId ?? 'demo-seeded-report',
sourceReportPath: replay?.metadata?.sourceReportPath ?? 'reports/seeded-demo-report.json',
};
}
export async function inspectSeededProject(projectDir: string): Promise<SeededInspectSummary> {
const root = resolve(projectDir);
const manifest = await loadSeededManifest(root);
const latestReplay = await loadSeededReplay(root);
const semanticLayerPath = manifest.generated.semanticLayer.path ?? 'semantic-layer/orbit_demo';
const knowledgePath = manifest.generated.knowledge.path ?? 'knowledge/global';
const linksPath = join(manifest.generated.links.path ?? 'links', 'provenance.json');
const reportFiles = await listFilesInDir(join(root, 'reports'), '.json');
const replayFiles = await listFilesInDir(join(root, 'replays'), '.json');
return {
projectDir: root,
mode: 'seeded',
manifest,
status: await inspectSeededProjectStatus(root),
sourceBundle: sourceBundleFromManifest(manifest),
generatedOutputs: {
semanticLayer: {
path: semanticLayerPath,
manifestSourceCount: manifest.generated.semanticLayer.sourceCount,
fileCount: (await listFilesInDir(join(root, semanticLayerPath), '.yaml')).length,
},
knowledge: {
path: knowledgePath,
manifestPageCount: manifest.generated.knowledge.pageCount,
fileCount: (await listFilesInDir(join(root, knowledgePath), '.md')).length,
},
links: {
path: linksPath,
manifestLinkCount: manifest.generated.links.linkCount,
linkCount: await loadLinksCount(root),
},
reports: {
primaryPath: reportFiles[0] ? join('reports', reportFiles[0]) : 'reports/seeded-demo-report.json',
fileCount: reportFiles.length,
},
replays: {
primaryPath: join('replays', 'replay.memory-flow.v1.json'),
latestPath: join('replays', DEMO_LATEST_REPLAY_FILE),
fileCount: replayFiles.length,
},
},
modeMetadata: modeMetadataFromReplay(latestReplay),
nextCommands: nextCommands(),
latestReplay,
};
}
function rowCountPreview(rowCounts: Record<string, number>): string {
return Object.entries(rowCounts)
.map(([name, count]) => `${name} ${count}`)
.join(', ');
}
function replayLine(summary: SeededInspectSummary): string {
const metadata = summary.latestReplay?.metadata ?? summary.modeMetadata;
return `Latest replay: ${metadata.mode} (${metadata.origin}, ${metadata.timing})`;
}
export function formatSeededInspect(summary: SeededInspectSummary): string {
const source = summary.sourceBundle;
const generated = summary.generatedOutputs;
const lines = [`Demo project: ${summary.projectDir}`, `Status: ${summary.status.status}`];
if (summary.status.missing.length > 0) {
lines.push(`Missing: ${summary.status.missing.join(', ')}`);
}
lines.push(
`Mode: seeded (pre-seeded demo project)`,
`Source: ${summary.modeMetadata.source}`,
`Generated context: ${summary.modeMetadata.generatedContext}`,
`LLM calls: ${summary.modeMetadata.llmCalls}`,
'',
'Source bundle:',
` Warehouse: ${source.warehouse.tableCount} tables, ${source.warehouse.totalRows.toLocaleString()} rows`,
` Rows: ${rowCountPreview(source.warehouse.rowCounts)}`,
` dbt: ${source.dbt.modelCount} models, ${source.dbt.sourceTableCount} source tables`,
` BI: ${source.bi.exploreCount} explores, ${source.bi.dashboardCount} dashboards`,
` Notion: ${source.notion.pageCount} pages`,
'',
'Generated context:',
` Semantic-layer sources: ${generated.semanticLayer.manifestSourceCount} manifest, ${generated.semanticLayer.fileCount} files`,
` Knowledge pages: ${generated.knowledge.manifestPageCount} manifest, ${generated.knowledge.fileCount} files`,
` Evidence links: ${generated.links.manifestLinkCount} manifest, ${generated.links.linkCount} links`,
'',
`Report: ${generated.reports.primaryPath}`,
`Replay: ${generated.replays.primaryPath}`,
replayLine(summary),
'',
'What to do next:',
);
for (const command of summary.nextCommands) {
lines.push(` $ ${command.command.padEnd(KTX_NEXT_STEP_COMMAND_WIDTH)} ${command.description}`);
}
lines.push('', `Your KTX project files are at: ${summary.projectDir}`, '');
return lines.join('\n');
}

View file

@ -1,115 +0,0 @@
import { access, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ensureSeededDemoProject } from './demo-assets.js';
import { runDemoSeeded } from './demo-seeded.js';
describe('demo seeded mode', () => {
const projectDir = join(tmpdir(), `ktx-demo-seeded-${process.pid}`);
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('hydrates a complete seeded project with all asset directories', async () => {
const result = await ensureSeededDemoProject({ projectDir, force: false });
expect(result.projectDir).toBe(projectDir);
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'ktx.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'manifest.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'links/provenance.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'reports/seeded-demo-report.json'))).resolves.toBeUndefined();
});
it('does not load or call any LLM provider in seeded mode', async () => {
const result = await runDemoSeeded({ projectDir });
expect(result.replay.metadata?.mode).toBe('seeded');
expect(result.replay.metadata?.timing).toBe('prebuilt');
expect(result.inspect.mode).toBe('seeded');
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(config).toContain('api_key: env:ANTHROPIC_API_KEY');
expect(config).not.toContain('sk-ant-');
});
it('creates the project under /tmp by default', async () => {
const result = await runDemoSeeded({ projectDir });
expect(result.projectDir).toBe(projectDir);
});
it('replay metadata identifies mode honestly', async () => {
const result = await runDemoSeeded({ projectDir });
expect(result.replay.metadata).toMatchObject({
mode: 'seeded',
origin: 'packaged',
timing: 'prebuilt',
});
expect(result.replay.runId).toBe('demo-seeded-orbit');
});
it('packaged seeded replay is honest and shows every source family', async () => {
const result = await runDemoSeeded({ projectDir });
const sourceEvents = result.replay.events.filter((event) => event.type === 'source_acquired');
const adapters = sourceEvents.map((event) => event.adapter).sort();
expect(result.replay.metadata).toMatchObject({
mode: 'seeded',
origin: 'packaged',
timing: 'prebuilt',
sourceReportPath: 'reports/seeded-demo-report.json',
});
expect(adapters).toEqual(['dbt_descriptions', 'live-database', 'looker', 'notion']);
expect(result.replay.events).not.toContainEqual(
expect.objectContaining({ type: 'stage_skipped', reason: expect.stringContaining('deterministic') }),
);
expect(JSON.stringify(result.replay)).not.toContain('LLM ran');
});
it('seeded animation shows all demo source families', async () => {
const result = await runDemoSeeded({ projectDir });
const adapters = result.replay.events
.filter((e) => e.type === 'source_acquired')
.map((e) => (e as { adapter: string }).adapter);
expect(adapters).toContain('live-database');
expect(adapters).toContain('dbt_descriptions');
expect(adapters).toContain('looker');
expect(adapters).toContain('notion');
});
it('SL YAML validates correctly', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
const slYaml = await readFile(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'), 'utf-8');
expect(slYaml).toContain('name: mart_arr_daily');
expect(slYaml).toContain('grain:');
expect(slYaml).toContain('columns:');
expect(slYaml).toContain('measures:');
expect(slYaml).toContain('joins:');
});
it('wiki pages have valid frontmatter', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
const wiki = await readFile(join(projectDir, 'knowledge/global/orbit-company-overview.md'), 'utf-8');
expect(wiki).toContain('---');
expect(wiki).toContain('summary:');
expect(wiki).toContain('tags:');
expect(wiki).toContain('refs:');
expect(wiki).toContain('usage_mode: auto');
});
it('links are searchable through provenance file', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
const raw = await readFile(join(projectDir, 'links/provenance.json'), 'utf-8');
const links = JSON.parse(raw) as Array<{ id: string; artifactKind: string }>;
expect(links.length).toBe(23);
expect(links.some((l) => l.artifactKind === 'wiki')).toBe(true);
expect(links.some((l) => l.artifactKind === 'sl')).toBe(true);
});
});

View file

@ -1,41 +0,0 @@
import type { MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
import {
ensureSeededDemoProject,
loadPackagedDemoReplay,
} from './demo-assets.js';
import { writeDemoReplay } from './demo-replay-store.js';
import { inspectSeededProject, type SeededInspectSummary } from './demo-seeded-inspect.js';
export {
formatSeededInspect,
inspectSeededProject,
type DemoSeededManifest,
type SeededInspectSummary,
} from './demo-seeded-inspect.js';
export interface DemoSeededResult {
projectDir: string;
replay: MemoryFlowReplayInput;
inspect: SeededInspectSummary;
}
export async function runDemoSeeded(options: {
projectDir: string;
}): Promise<DemoSeededResult> {
const result = await ensureSeededDemoProject({ projectDir: options.projectDir, force: false });
const replay = await loadPackagedDemoReplay();
const replayWithDir: MemoryFlowReplayInput = {
...replay,
sourceDir: result.projectDir,
};
await writeDemoReplay(result.projectDir, replayWithDir, { label: 'seeded' });
const inspect = await inspectSeededProject(result.projectDir);
return {
projectDir: result.projectDir,
replay: replayWithDir,
inspect,
};
}

View file

@ -1,766 +0,0 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { IngestReportSnapshot, MemoryFlowReplayInput } from '@ktx/context/ingest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxDemo } from './demo.js';
import { DEMO_FULL_JOB_ID, defaultDemoProjectDir, ensureDemoProject } from './demo-assets.js';
import type { DemoFullResult } from './demo-full.js';
import { createTestDemoPromptAdapter } from './demo-interaction.js';
import type { renderMemoryFlowTui } from './memory-flow-tui.js';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
import { resetVizFallbackWarningsForTest } from './viz-fallback.js';
const SEEDED_DEMO_SEMANTIC_SOURCE_COUNT = 46;
const SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT = 28;
function makeIo(options: { isTTY?: boolean; columns?: number; rawMode?: boolean } = {}) {
let stdout = '';
let stderr = '';
return {
io: {
stdin: {
isTTY: options.isTTY ?? false,
...(options.rawMode === false ? {} : { setRawMode: vi.fn() }),
},
stdout: {
isTTY: options.isTTY ?? false,
columns: options.columns ?? 140,
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function fakeFullResult(projectDir: string): DemoFullResult {
const report: IngestReportSnapshot = {
id: 'report-full',
runId: 'run-full',
jobId: DEMO_FULL_JOB_ID,
connectionId: 'orbit_demo',
sourceKey: 'live-database',
createdAt: '2026-05-01T00:00:00.000Z',
body: {
syncId: 'sync-full',
diffSummary: { added: 7, modified: 0, deleted: 0, unchanged: 0 },
commitSha: null,
workUnits: [
{
unitKey: 'accounts',
rawFiles: ['accounts.schema.json'],
status: 'success',
actions: [
{ target: 'wiki', type: 'created', key: 'knowledge/accounts.md', detail: 'account lifecycle context' },
{ target: 'sl', type: 'created', key: 'orbit_demo.accounts', detail: 'accounts semantic source' },
],
touchedSlSources: [{ connectionId: 'orbit_demo', sourceName: 'orbit_demo.accounts' }],
},
],
failedWorkUnits: [],
reconciliationSkipped: false,
conflictsResolved: [],
evictionsApplied: [],
unmappedFallbacks: [],
evictionInputs: [],
unresolvedCards: [],
supersededBy: null,
overrideOf: null,
provenanceRows: [
{
rawPath: 'accounts.schema.json',
artifactKind: 'wiki',
artifactKey: 'knowledge/accounts.md',
actionType: 'wiki_written',
},
],
toolTranscripts: [],
},
};
return {
project: { projectDir } as never,
scan: { report: { runId: 'scan-run' } } as never,
ingest: { result: { ok: true }, report } as never,
report,
replay: {
runId: 'run-full',
connectionId: 'orbit_demo',
adapter: 'live-database',
status: 'done',
sourceDir: `${projectDir}/raw-sources/orbit_demo/live-database/sync-full`,
syncId: 'sync-full',
errors: [],
events: [
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 },
{ type: 'saved', commitSha: null, wikiCount: 1, slCount: 1 },
{ type: 'provenance_recorded', rowCount: 1 },
{ type: 'report_created', runId: 'run-full', reportPath: 'report-full' },
],
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
},
};
}
describe('runKtxDemo', () => {
let tempDir: string;
beforeEach(async () => {
resetVizFallbackWarningsForTest();
tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-command-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('initializes the demo project', async () => {
const io = makeIo();
await expect(
runKtxDemo({ command: 'init', projectDir: tempDir, force: false, inputMode: 'disabled' }, io.io),
).resolves.toBe(0);
expect(io.stdout()).toContain(`Demo project: ${tempDir}`);
expect(io.stdout()).toContain('Config:');
expect(io.stdout()).toContain('Replay:');
expect(io.stderr()).toBe('');
});
it('renders the packaged replay in no-input viz mode', async () => {
const io = makeIo({ isTTY: true });
await expect(
runKtxDemo(
{ command: 'replay', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' },
io.io,
{ env: { ...process.env, TERM: 'xterm-256color' } },
),
).resolves.toBe(0);
expect(io.stdout()).toContain('KTX memory flow Warehouse + dbt + BI + Docs done');
expect(io.stdout()).toContain('Saved 16 memories');
expect(io.stderr()).toBe('');
});
it('routes interactive packaged replay viz through the stored TUI renderer', async () => {
const io = makeIo({ isTTY: true });
const renderStoredMemoryFlow = vi.fn<typeof renderMemoryFlowTui>(async () => true);
await expect(
runKtxDemo(
{ command: 'replay', projectDir: tempDir, outputMode: 'viz' },
io.io,
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
),
).resolves.toBe(0);
expect(renderStoredMemoryFlow).toHaveBeenCalledTimes(1);
expect(renderStoredMemoryFlow.mock.calls[0]?.[0]).toMatchObject({
runId: 'demo-seeded-orbit',
connectionId: 'orbit_demo',
adapter: 'live-database',
});
expect(renderStoredMemoryFlow.mock.calls[0]?.[2]).toEqual({ speedMultiplier: 0.125 });
expect(io.stdout()).toContain('KTX finished ingesting your data');
expect(io.stderr()).toBe('');
});
it('routes interactive seeded demo viz through the stored TUI renderer at eighth speed', async () => {
const io = makeIo({ isTTY: true });
const renderStoredMemoryFlow = vi.fn<typeof renderMemoryFlowTui>(async () => true);
await expect(
runKtxDemo(
{ command: 'seeded', projectDir: tempDir, outputMode: 'viz' },
io.io,
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
),
).resolves.toBe(0);
expect(renderStoredMemoryFlow).toHaveBeenCalledTimes(1);
expect(renderStoredMemoryFlow.mock.calls[0]?.[2]).toEqual({ speedMultiplier: 0.125 });
expect(io.stdout()).toContain('KTX finished ingesting your data');
expect(io.stderr()).toBe('');
});
it('falls back to plain replay output when interactive replay viz lacks stdin raw mode', async () => {
const io = makeIo({ isTTY: true, rawMode: false });
const renderStoredMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => true);
await expect(
runKtxDemo(
{ command: 'replay', projectDir: tempDir, outputMode: 'viz' },
io.io,
{ env: { ...process.env, TERM: 'xterm-256color' }, renderStoredMemoryFlow },
),
).resolves.toBe(0);
expect(renderStoredMemoryFlow).not.toHaveBeenCalled();
expect(io.stdout()).toContain('Memory-flow summary: done');
expect(io.stdout()).toContain('Connection: orbit_demo');
expect(io.stdout()).toContain('ktx sl list');
expect(io.stdout()).toContain('ktx wiki list');
expect(io.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
expect(io.stdout()).not.toContain('KTX memory flow');
expect(io.stderr()).toContain(
'Visualization requested but stdin raw mode is unavailable; printing plain output.',
);
});
it('degrades default visual demo replay to a plain memory-flow summary when stdout is redirected', async () => {
const testIo = makeIo({ isTTY: false });
await expect(
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' }, testIo.io),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('Memory-flow summary: done');
expect(testIo.stdout()).toContain('Connection: orbit_demo');
expect(testIo.stdout()).toContain('ktx sl list');
expect(testIo.stdout()).toContain('ktx wiki list');
expect(testIo.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
expect(testIo.stdout()).not.toContain('KTX memory flow');
expect(testIo.stderr()).toContain(
'Visualization requested but stdout is not an interactive terminal; printing plain output.',
);
});
it('prints JSON replay output when requested', async () => {
const io = makeIo();
await expect(
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, io.io),
).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toMatchObject({ runId: 'demo-seeded-orbit', connectionId: 'orbit_demo' });
expect(io.stderr()).toBe('');
});
it('runs the packaged SQLite demo scan', async () => {
const io = makeIo();
await expect(runKtxDemo({ command: 'scan', projectDir: tempDir, inputMode: 'disabled' }, io.io)).resolves.toBe(0);
expect(io.stdout()).toContain('Demo scan: done');
expect(io.stdout()).toContain('Connection: orbit_demo');
expect(io.stdout()).toContain('Driver: sqlite');
expect(io.stdout()).toContain('Report: raw-sources/orbit_demo/live-database/');
expect(io.stderr()).toBe('');
});
it('runs seeded mode with pre-seeded assets and inspect summary', async () => {
const io = makeIo({ isTTY: true });
await expect(
runKtxDemo(
{ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
io.io,
{ env: { ...process.env, TERM: 'xterm-256color' } },
),
).resolves.toBe(0);
expect(io.stdout()).toContain('Mode: seeded');
expect(io.stdout()).toContain('LLM calls: none');
expect(io.stdout()).toContain('Semantic-layer sources:');
expect(io.stdout()).toContain('Knowledge pages:');
expect(io.stderr()).toBe('');
});
it('uses seeded mode as the default demo and creates a temp project when no project-dir is supplied', async () => {
const io = makeIo();
await expect(
runKtxDemo(
{ command: 'seeded', projectDir: defaultDemoProjectDir(), outputMode: 'plain', inputMode: 'disabled' },
io.io,
),
).resolves.toBe(0);
expect(io.stdout()).toContain('Mode: seeded');
expect(io.stdout()).toContain('Source: packaged demo project');
expect(io.stdout()).toContain('Generated context: prebuilt from bundled assets');
expect(io.stdout()).toContain('LLM calls: none');
expect(io.stdout()).toContain('Your KTX project files are at:');
expect(io.stdout()).toContain(join(tmpdir(), 'ktx-demo-'));
expect(io.stdout()).not.toContain('ktx serve --mcp stdio');
expect(io.stdout()).not.toContain(['ktx', 'mcp'].join(' '));
expect(io.stdout()).not.toContain('deterministic');
});
it('degrades default visual seeded demo to plain output when TERM is dumb', async () => {
const testIo = makeIo({ isTTY: true, columns: 120 });
await expect(
runKtxDemo(
{ command: 'seeded', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' },
testIo.io,
{ env: { ...process.env, TERM: 'dumb' } },
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('Mode: seeded');
expect(testIo.stdout()).toContain('LLM calls: none');
expect(testIo.stderr()).toContain(
'Visualization requested but TERM=dumb does not support the visual renderer; printing plain output.',
);
});
it('prints demo inspect as plain text and JSON', async () => {
const seededIo = makeIo();
await expect(
runKtxDemo({ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, seededIo.io),
).resolves.toBe(0);
const plainIo = makeIo();
await expect(
runKtxDemo({ command: 'inspect', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, plainIo.io),
).resolves.toBe(0);
expect(plainIo.stdout()).toContain('Mode: seeded');
expect(plainIo.stdout()).toContain('Semantic-layer sources:');
const jsonIo = makeIo();
await expect(
runKtxDemo({ command: 'inspect', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, jsonIo.io),
).resolves.toBe(0);
const parsed = JSON.parse(jsonIo.stdout());
expect(parsed).toMatchObject({
projectDir: tempDir,
mode: 'seeded',
status: { status: 'ready', missing: [] },
sourceBundle: {
warehouse: { tableCount: 8, totalRows: 11234 },
dbt: { modelCount: 3, sourceTableCount: 8 },
bi: { exploreCount: 5, dashboardCount: 2 },
notion: { pageCount: 8 },
},
generatedOutputs: {
semanticLayer: {
manifestSourceCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
fileCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
},
knowledge: {
manifestPageCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
fileCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
},
links: { manifestLinkCount: 23, linkCount: 23 },
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
},
modeMetadata: {
mode: 'seeded',
source: 'packaged demo project',
generatedContext: 'prebuilt from bundled assets',
llmCalls: 'none',
},
nextCommands: KTX_NEXT_STEP_DIRECT_COMMANDS,
});
expect(parsed.generatedOutputs.replays.fileCount).toBeGreaterThanOrEqual(3);
expect(jsonIo.stderr()).toBe('');
});
it('routes top-level full mode and prints memory-flow plus final summary', async () => {
const testIo = makeIo({ isTTY: true });
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz', inputMode: 'disabled' }, testIo.io, {
env: {},
runFullDemo,
}),
).resolves.toBe(0);
expect(runFullDemo).toHaveBeenCalledWith(
expect.objectContaining({
projectDir: tempDir,
env: {},
onMemoryFlowChange: expect.any(Function),
}),
);
expect(testIo.stdout()).toContain('KTX memory flow orbit_demo/live-database done');
expect(testIo.stdout()).toContain('Full demo ingest: done');
expect(testIo.stdout()).toContain('Next: ktx setup demo inspect');
expect(testIo.stdout()).toContain('Shows the files, semantic-layer sources, and memory KTX just produced.');
});
it('streams live memory-flow snapshots for full demo viz and then prints final summary', async () => {
const testIo = makeIo({ isTTY: true, columns: 120 });
const liveSession = {
update: vi.fn(),
close: vi.fn(),
isClosed: vi.fn(() => false),
};
const startLiveMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => liveSession);
const runFullDemo = vi.fn(
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
options.onMemoryFlowChange?.({
...fakeFullResult(tempDir).replay,
status: 'running',
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 }],
});
return fakeFullResult(tempDir);
},
);
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz' }, testIo.io, {
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
prompts: createTestDemoPromptAdapter({ choices: ['reuse'] }),
runFullDemo,
startLiveMemoryFlow,
}),
).resolves.toBe(0);
expect(startLiveMemoryFlow).toHaveBeenCalledTimes(1);
expect(liveSession.update).toHaveBeenCalledTimes(1);
expect(liveSession.close).toHaveBeenCalledTimes(1);
expect(testIo.stdout()).not.toContain('Memory-flow summary: done');
expect(testIo.stdout()).toContain('KTX finished ingesting your data');
expect(testIo.stdout()).toContain('ktx sl list');
expect(testIo.stdout()).toContain('ktx wiki list');
expect(testIo.stdout()).not.toContain('ktx serve --mcp stdio --user-id local');
expect(testIo.stdout()).not.toContain(['ktx', 'ask'].join(' '));
expect(testIo.stdout()).not.toContain(['ktx', 'mcp'].join(' '));
});
it('uses plain progress for full demo viz when stdin raw mode is unavailable', async () => {
const testIo = makeIo({ isTTY: true, rawMode: false, columns: 120 });
const liveSession = {
update: vi.fn(),
close: vi.fn(),
isClosed: vi.fn(() => false),
};
const startLiveMemoryFlow = vi.fn(async (_input: MemoryFlowReplayInput, _io: unknown) => liveSession);
const runFullDemo = vi.fn(
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
options.onMemoryFlowChange?.({
...fakeFullResult(tempDir).replay,
status: 'running',
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_full', fileCount: 7 }],
});
return fakeFullResult(tempDir);
},
);
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo({ command: 'full', projectDir: tempDir, outputMode: 'viz' }, testIo.io, {
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
prompts: createTestDemoPromptAdapter({ choices: ['reuse'] }),
runFullDemo,
startLiveMemoryFlow,
}),
).resolves.toBe(0);
expect(startLiveMemoryFlow).not.toHaveBeenCalled();
expect(runFullDemo).toHaveBeenCalledWith(
expect.objectContaining({
onMemoryFlowChange: expect.any(Function),
}),
);
expect(testIo.stdout()).toContain('[connect] Connected live-database - 7 database files (demo_full)');
expect(testIo.stdout()).toContain('Full demo ingest: done');
expect(testIo.stdout()).not.toContain('KTX memory flow');
expect(testIo.stderr()).toContain(
'Visualization requested but stdin raw mode is unavailable; printing plain output.',
);
});
it('streams plain-text progress lines for full demo when no live TUI is active', async () => {
const testIo = makeIo();
const runFullDemo = vi.fn(
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
const baseSnapshot = fakeFullResult(tempDir).replay;
options.onMemoryFlowChange?.({
...baseSnapshot,
status: 'running',
events: [{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 }],
});
options.onMemoryFlowChange?.({
...baseSnapshot,
status: 'running',
events: [
{ type: 'source_acquired', adapter: 'live-database', trigger: 'manual_resync', fileCount: 7 },
{ type: 'diff_computed', added: 0, modified: 0, deleted: 0, unchanged: 7 },
],
});
return fakeFullResult(tempDir);
},
);
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo(
{ command: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{ env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, runFullDemo }, // pragma: allowlist secret
),
).resolves.toBe(0);
const stdout = testIo.stdout();
expect(stdout).toContain('[connect] Connected live-database - 7 database files (manual_resync)');
expect(stdout).toContain('[diff] Tables: =7 unchanged');
expect(stdout).toContain('Full demo ingest: done');
});
it('skips plain progress lines for json output mode', async () => {
const testIo = makeIo();
const runFullDemo = vi.fn(
async (options: { projectDir: string; onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void }) => {
expect(options.onMemoryFlowChange).toBeUndefined();
return fakeFullResult(tempDir);
},
);
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo(
{ command: 'full', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{ env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, runFullDemo }, // pragma: allowlist secret
),
).resolves.toBe(0);
expect(testIo.stdout()).not.toContain('[connect]');
expect(testIo.stdout()).not.toContain('[snapshot]');
});
it('routes demo ingest full mode', async () => {
const testIo = makeIo();
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo(
{ command: 'ingest', mode: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{ env: {}, runFullDemo },
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('Full demo ingest: done');
});
it('saves full-demo replay output for the next demo replay command', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-demo-full-replay-'));
await ensureDemoProject({ projectDir: tempDir, force: false });
const io = makeIo();
await expect(
runKtxDemo(
{ command: 'full', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
io.io,
{
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
runFullDemo: vi.fn(async () => fakeFullResult(tempDir)),
},
),
).resolves.toBe(0);
const replayIo = makeIo();
await expect(
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' }, replayIo.io),
).resolves.toBe(0);
expect(JSON.parse(replayIo.stdout())).toMatchObject({
runId: 'run-full',
metadata: { mode: 'full', origin: 'captured' },
});
});
it('routes demo ingest seeded mode through the seeded path', async () => {
const testIo = makeIo();
await expect(
runKtxDemo(
{ command: 'ingest', mode: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('Mode: seeded');
expect(testIo.stdout()).toContain('LLM calls: none');
});
it('routes demo doctor through the doctor module', async () => {
const testIo = makeIo();
const runDoctor = vi.fn().mockResolvedValue(0);
await expect(
runKtxDemo(
{
command: 'doctor',
projectDir: tempDir,
outputMode: 'plain',
inputMode: 'disabled',
},
testIo.io,
{ runDoctor },
),
).resolves.toBe(0);
expect(runDoctor).toHaveBeenCalledWith(
{
command: 'demo',
projectDir: tempDir,
outputMode: 'plain',
inputMode: 'disabled',
},
testIo.io,
);
});
it('resets the demo project only when force is explicit', async () => {
await ensureDemoProject({ projectDir: tempDir, force: false });
await rm(join(tempDir, 'demo.db'), { force: true });
const rejected = makeIo();
await expect(
runKtxDemo({ command: 'reset', projectDir: tempDir, force: false, inputMode: 'disabled' }, rejected.io),
).resolves.toBe(1);
expect(rejected.stderr()).toContain(`ktx setup demo reset is destructive; pass --force to recreate ${tempDir}`);
const accepted = makeIo();
await expect(
runKtxDemo({ command: 'reset', projectDir: tempDir, force: true, inputMode: 'disabled' }, accepted.io),
).resolves.toBe(0);
expect(accepted.stdout()).toContain(`Demo project reset: ${tempDir}`);
});
it('rehydrates seeded assets after reset --force', async () => {
const resetIo = makeIo();
await expect(
runKtxDemo({ command: 'reset', projectDir: tempDir, force: true, inputMode: 'disabled' }, resetIo.io),
).resolves.toBe(0);
const seededIo = makeIo();
await expect(
runKtxDemo(
{ command: 'seeded', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
seededIo.io,
),
).resolves.toBe(0);
expect(seededIo.stdout()).toContain('Status: ready');
expect(seededIo.stdout()).toContain(
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} files`,
);
expect(seededIo.stdout()).toContain(
`Knowledge pages: ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} manifest, ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} files`,
);
expect(seededIo.stdout()).not.toContain('Status: corrupt');
expect(seededIo.stdout()).not.toContain(
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, 0 files`,
);
});
it('fails corrupted demo projects in no-input mode with reset guidance', async () => {
await ensureDemoProject({ projectDir: tempDir, force: false });
await rm(join(tempDir, 'demo.db'), { force: true });
const testIo = makeIo();
await expect(
runKtxDemo({ command: 'replay', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, testIo.io),
).resolves.toBe(1);
expect(testIo.stderr()).toContain(`Demo project is not ready at ${tempDir}: missing demo.db`);
expect(testIo.stderr()).toContain(`ktx setup demo reset --project-dir ${tempDir} --force --no-input`);
});
it('uses a process-local Anthropic key from the interactive prompt', async () => {
const testIo = makeIo({ isTTY: true, columns: 120 });
const runFullDemo = vi.fn().mockResolvedValue(fakeFullResult(tempDir));
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo(
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
testIo.io,
{
env: {},
prompts: createTestDemoPromptAdapter({
choices: ['reuse', 'process_key'],
passwords: ['sk-ant-process'], // pragma: allowlist secret
}),
runFullDemo,
},
),
).resolves.toBe(0);
expect(runFullDemo).toHaveBeenCalledWith(
expect.objectContaining({
projectDir: tempDir,
env: { ANTHROPIC_API_KEY: 'sk-ant-process' }, // pragma: allowlist secret
onMemoryFlowChange: expect.any(Function),
}),
);
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('api_key: env:ANTHROPIC_API_KEY');
});
it('routes an interactive missing-key choice to seeded mode', async () => {
const testIo = makeIo({ isTTY: true, columns: 120 });
const runFullDemo = vi.fn();
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo(
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
testIo.io,
{
env: {},
prompts: createTestDemoPromptAdapter({ choices: ['reuse', 'seeded'] }),
runFullDemo,
},
),
).resolves.toBe(0);
expect(runFullDemo).not.toHaveBeenCalled();
expect(testIo.stdout()).toContain('Mode: seeded');
expect(testIo.stdout()).toContain('LLM calls: none');
expect(testIo.stdout()).not.toContain('deterministic');
});
it('routes missing full-mode credentials to seeded when the interactive user chooses the no-LLM demo', async () => {
const testIo = makeIo({ isTTY: true });
await expect(
runKtxDemo(
{ command: 'full', projectDir: tempDir, outputMode: 'plain' },
testIo.io,
{
env: { ...process.env, ANTHROPIC_API_KEY: '' },
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('Mode: seeded');
expect(testIo.stdout()).toContain('LLM calls: none');
expect(testIo.stdout()).not.toContain('deterministic');
});
it('routes an interactive missing-key choice to replay mode', async () => {
const testIo = makeIo({ isTTY: true, columns: 120 });
const runFullDemo = vi.fn();
await ensureDemoProject({ projectDir: tempDir, force: false });
await expect(
runKtxDemo(
{ command: 'full', projectDir: tempDir, outputMode: 'viz' },
testIo.io,
{
env: {},
prompts: createTestDemoPromptAdapter({ choices: ['reuse', 'replay'] }),
runFullDemo,
},
),
).resolves.toBe(0);
expect(runFullDemo).not.toHaveBeenCalled();
expect(testIo.stdout()).toContain('KTX memory flow');
expect(testIo.stdout()).toContain('done');
});
});

View file

@ -1,544 +0,0 @@
import {
buildMemoryFlowViewModel,
formatMemoryFlowFinalSummary,
renderMemoryFlowReplay,
type MemoryFlowReplayInput,
} from '@ktx/context/ingest/memory-flow';
import { resolveKtxConfigReference } from '@ktx/context/core';
import { loadKtxProject } from '@ktx/context/project';
import {
DEMO_ADAPTER,
DEMO_CONNECTION_ID,
DEMO_FULL_JOB_ID,
ensureDemoProject,
loadProjectDemoReplay,
resetDemoProject,
} from './demo-assets.js';
import { writeDemoReplay } from './demo-replay-store.js';
import {
formatDemoInspect,
formatDemoScanSummary,
inspectDemoProject,
runDemoScan,
} from './demo-scan.js';
import {
formatSeededInspect,
inspectSeededProject,
runDemoSeeded,
} from './demo-seeded.js';
import { buildFullDemoReplay, formatCleanDemoSummary, formatFullDemoSummary, fullDemoCredentialStatus, runDemoFull } from './demo-full.js';
import { createPlainProgressEmitter } from './demo-progress.js';
import {
chooseDemoProjectForInteractiveRun,
createClackDemoPromptAdapter,
resolveFullCredentialDecision,
type DemoPromptAdapter,
} from './demo-interaction.js';
import type { KtxDoctorArgs } from './doctor.js';
import {
renderMemoryFlowTui,
startLiveMemoryFlowTui,
type KtxMemoryFlowTuiIo,
type MemoryFlowTuiLiveSession,
} from './memory-flow-tui.js';
import {
rendererUnavailableVizFallback,
resolveVizFallback,
warnVizFallbackOnce,
} from './viz-fallback.js';
import { profileMark } from './startup-profile.js';
import { formatNextStepLines } from './next-steps.js';
profileMark('module:demo');
export type KtxDemoOutputMode = 'plain' | 'json' | 'viz';
export type KtxDemoInputMode = 'auto' | 'disabled';
export type KtxDemoMode = 'full' | 'seeded';
export type KtxDemoArgs =
| { command: 'init'; projectDir: string; force: boolean; inputMode?: KtxDemoInputMode }
| { command: 'reset'; projectDir: string; force: boolean; inputMode?: KtxDemoInputMode }
| { command: 'replay'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
| { command: 'scan'; projectDir: string; inputMode?: KtxDemoInputMode }
| { command: 'inspect'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
| { command: 'doctor'; projectDir: string; outputMode: Exclude<KtxDemoOutputMode, 'viz'>; inputMode?: KtxDemoInputMode }
| { command: 'seeded'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
| { command: 'full'; projectDir: string; outputMode: KtxDemoOutputMode; inputMode?: KtxDemoInputMode }
| {
command: 'ingest';
mode: KtxDemoMode;
projectDir: string;
outputMode: KtxDemoOutputMode;
inputMode?: KtxDemoInputMode;
};
export interface KtxDemoIo {
stdin?: KtxMemoryFlowTuiIo['stdin'];
stdout: { isTTY?: boolean; columns?: number; write(chunk: string): void };
stderr: { write(chunk: string): void };
}
interface KtxDemoDeps {
runFullDemo?: typeof runDemoFull;
runDoctor?: (args: KtxDoctorArgs, io: KtxDemoIo) => Promise<number>;
renderStoredMemoryFlow?: typeof renderMemoryFlowTui;
startLiveMemoryFlow?: typeof startLiveMemoryFlowTui;
env?: NodeJS.ProcessEnv;
prompts?: DemoPromptAdapter;
}
const ADAPTER_PREFIXES = ['live_database_', 'metabase_', 'looker_', 'lookml_', 'metricflow_', 'notion_', 'historic_sql_', 'dbt_descriptions_'];
const DEMO_TUI_SPEED_MULTIPLIER = 0.125;
function humanizeUnitKeyPlain(unitKey: string): string {
let key = unitKey.replace(/-/g, '_');
for (const prefix of ADAPTER_PREFIXES) {
if (key.startsWith(prefix)) { key = key.slice(prefix.length); break; }
}
return key.replace(/_/g, ' ');
}
function formatReplaySummary(input: MemoryFlowReplayInput): string {
let slCount = 0;
let wikiCount = 0;
let chunkCount = 0;
const unitResults: Array<{ unitKey: string; artifacts: Array<{ icon: string; text: string; hasSummary: boolean }> }> = [];
let currentUnit: { unitKey: string; artifacts: Array<{ icon: string; text: string; hasSummary: boolean }> } | null = null;
let conflictCount = 0;
for (const e of input.events) {
if (e.type === 'chunks_planned') {
chunkCount = e.chunkCount;
} else if (e.type === 'work_unit_started') {
currentUnit = { unitKey: e.unitKey, artifacts: [] };
} else if (e.type === 'candidate_action') {
if (e.target === 'sl') slCount++;
else wikiCount++;
const detail = input.details.actions.find((a) => a.key === e.key && a.unitKey === e.unitKey);
const icon = e.target === 'sl' ? '📊' : '📝';
const name = e.key.split('.').pop()?.replace(/[_-]/g, ' ') ?? e.key;
const text = detail?.summary ?? name;
currentUnit?.artifacts.push({ icon, text, hasSummary: !!detail?.summary });
} else if (e.type === 'work_unit_finished' && currentUnit) {
unitResults.push(currentUnit);
currentUnit = null;
} else if (e.type === 'reconciliation_finished') {
conflictCount = e.conflictCount;
}
}
const lines: string[] = ['', '★ KTX finished ingesting your data', ''];
if (chunkCount > 0) {
lines.push(` ✓ Analyzed ${chunkCount} business area${chunkCount === 1 ? '' : 's'}`);
}
lines.push(` ✓ Reconciled — ${conflictCount > 0 ? `${conflictCount} conflict${conflictCount === 1 ? '' : 's'} resolved` : 'no conflicts'}`);
lines.push('');
if (slCount > 0 || wikiCount > 0) {
lines.push(' KTX created:');
if (slCount > 0) lines.push(` 📊 ${slCount} query definition${slCount === 1 ? '' : 's'} — so agents can write accurate SQL for your data`);
if (wikiCount > 0) lines.push(` 📝 ${wikiCount} knowledge page${wikiCount === 1 ? '' : 's'} — so agents understand your business context`);
lines.push('');
}
const described = unitResults.flatMap((u) => u.artifacts).filter((a) => a.hasSummary);
for (const a of described) {
lines.push(` ${a.icon} ${a.text}`);
}
lines.push('');
lines.push(' What to do next:');
lines.push(...formatNextStepLines());
if (input.sourceDir) {
lines.push('');
lines.push(` Your KTX project files are at: ${input.sourceDir}`);
}
lines.push('');
return lines.join('\n');
}
function formatPlainReplaySummary(input: MemoryFlowReplayInput): string {
return [formatMemoryFlowFinalSummary(input).trimEnd(), '', 'What to do next:', ...formatNextStepLines(), ''].join('\n');
}
function writeReplay(input: MemoryFlowReplayInput, outputMode: KtxDemoOutputMode, io: KtxDemoIo): void {
if (outputMode === 'json') {
io.stdout.write(`${JSON.stringify(input, null, 2)}\n`);
return;
}
if (outputMode === 'plain') {
io.stdout.write(formatPlainReplaySummary(input));
return;
}
const view = buildMemoryFlowViewModel(input);
io.stdout.write(renderMemoryFlowReplay(view, { terminalWidth: io.stdout.columns ?? process.stdout.columns }));
}
async function writeStoredReplay(
input: MemoryFlowReplayInput,
outputMode: KtxDemoOutputMode,
inputMode: KtxDemoArgs['inputMode'],
io: KtxDemoIo,
deps: KtxDemoDeps,
env: NodeJS.ProcessEnv,
): Promise<void> {
const resolvedOutputMode = effectiveDemoOutputMode(outputMode, io, env, {
requireInput: inputMode !== 'disabled',
});
if (resolvedOutputMode !== 'viz') {
writeReplay(input, resolvedOutputMode, io);
return;
}
if (inputMode !== 'disabled') {
const renderStoredMemoryFlow = deps.renderStoredMemoryFlow ?? renderMemoryFlowTui;
if (
isTuiCapableDemoIo(io) &&
(await renderStoredMemoryFlow(input, io, { speedMultiplier: DEMO_TUI_SPEED_MULTIPLIER }))
) {
io.stdout.write(formatReplaySummary(input));
return;
}
}
writeReplay(input, resolvedOutputMode, io);
}
function writeInspect(
summary: Awaited<ReturnType<typeof inspectDemoProject>>,
outputMode: KtxDemoOutputMode,
io: KtxDemoIo,
): void {
if (outputMode === 'json') {
io.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
return;
}
io.stdout.write(formatDemoInspect(summary));
}
function writeFullDemo(
result: Awaited<ReturnType<typeof runDemoFull>>,
outputMode: KtxDemoOutputMode,
io: KtxDemoIo,
options: { liveWasRendered?: boolean; projectDir?: string } = {},
): void {
if (outputMode === 'json') {
io.stdout.write(`${JSON.stringify({ report: result.report, replay: result.replay }, null, 2)}\n`);
return;
}
if (outputMode === 'viz' && options.liveWasRendered !== true) {
writeReplay(buildFullDemoReplay(result.report), outputMode, io);
io.stdout.write('\n');
}
if (outputMode === 'viz' && options.liveWasRendered) {
io.stdout.write(formatCleanDemoSummary(result.report, options.projectDir ?? ''));
return;
}
if (outputMode === 'viz') {
io.stdout.write(formatMemoryFlowFinalSummary(buildFullDemoReplay(result.report)));
}
io.stdout.write(formatFullDemoSummary(result.report));
}
function replayWithFullMetadata(result: Awaited<ReturnType<typeof runDemoFull>>): MemoryFlowReplayInput {
if (result.replay.metadata) {
return result.replay;
}
return {
...result.replay,
metadata: {
schemaVersion: 1,
mode: 'full',
origin: 'captured',
timing: 'captured',
capturedAt: result.report.createdAt,
sourceReportId: result.report.id,
sourceReportPath: result.report.id,
fallbackReason: null,
},
reportId: result.replay.reportId ?? result.report.id,
reportPath: result.replay.reportPath ?? result.report.id,
};
}
function pickMemoryFlowProgress(
liveSession: MemoryFlowTuiLiveSession | null,
outputMode: KtxDemoOutputMode,
io: KtxDemoIo,
): ((snapshot: MemoryFlowReplayInput) => void) | undefined {
if (liveSession) {
return (snapshot: MemoryFlowReplayInput) => {
if (!liveSession.isClosed()) {
liveSession.update(snapshot);
}
};
}
if (outputMode === 'json') {
return undefined;
}
return createPlainProgressEmitter(io);
}
function isTuiCapableDemoIo(io: KtxDemoIo): io is KtxDemoIo & KtxMemoryFlowTuiIo {
return (
io.stdin?.isTTY === true &&
io.stdout.isTTY === true &&
typeof io.stdin.setRawMode === 'function' &&
typeof io.stdout.write === 'function'
);
}
interface EffectiveDemoOutputModeOptions {
requireInput?: boolean;
}
function effectiveDemoOutputMode(
outputMode: KtxDemoOutputMode,
io: KtxDemoIo,
env: NodeJS.ProcessEnv,
options: EffectiveDemoOutputModeOptions = {},
): KtxDemoOutputMode {
if (outputMode !== 'viz') {
return outputMode;
}
const fallback = resolveVizFallback(io, env, { requireInput: options.requireInput ?? false });
if (!fallback.shouldDegrade) {
return outputMode;
}
warnVizFallbackOnce(io, fallback);
return 'plain';
}
function initialFullDemoMemoryFlowInput(projectDir: string): MemoryFlowReplayInput {
return {
runId: DEMO_FULL_JOB_ID,
connectionId: DEMO_CONNECTION_ID,
adapter: DEMO_ADAPTER,
status: 'running',
sourceDir: `${projectDir}/raw-sources/${DEMO_CONNECTION_ID}/${DEMO_ADAPTER}`,
syncId: 'pending',
errors: [],
events: [],
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
};
}
async function ensureDemoProjectForCommand(projectDir: string): Promise<void> {
await ensureDemoProject({ projectDir, force: false }).catch((error) => {
if (error instanceof Error && error.message.includes('Demo project already exists')) {
return null;
}
throw error;
});
}
async function prepareProjectForDemoCommand(args: KtxDemoArgs, io: KtxDemoIo, deps: KtxDemoDeps): Promise<string | null> {
if (args.command === 'init' || args.command === 'reset' || args.command === 'doctor') {
return args.projectDir;
}
const prompts = deps.prompts ?? createClackDemoPromptAdapter();
const decision = await chooseDemoProjectForInteractiveRun({
projectDir: args.projectDir,
inputMode: args.inputMode,
io,
prompts,
});
if (decision.action === 'cancel') {
return null;
}
if (decision.reset) {
await resetDemoProject({ projectDir: decision.projectDir, force: true });
}
return decision.projectDir;
}
async function runReplayDemo(
projectDir: string,
outputMode: KtxDemoOutputMode,
inputMode: KtxDemoArgs['inputMode'],
io: KtxDemoIo,
deps: KtxDemoDeps,
env: NodeJS.ProcessEnv = process.env,
): Promise<number> {
await ensureDemoProjectForCommand(projectDir);
await writeStoredReplay(await loadProjectDemoReplay(projectDir), outputMode, inputMode, io, deps, env);
return 0;
}
async function runSeededDemo(
projectDir: string,
outputMode: KtxDemoOutputMode,
inputMode: KtxDemoArgs['inputMode'],
io: KtxDemoIo,
deps: KtxDemoDeps,
env: NodeJS.ProcessEnv = process.env,
): Promise<number> {
const result = await runDemoSeeded({ projectDir });
const resolvedOutputMode = effectiveDemoOutputMode(outputMode, io, env, {
requireInput: inputMode !== 'disabled',
});
if (resolvedOutputMode === 'json') {
io.stdout.write(`${JSON.stringify({ replay: result.replay, inspect: result.inspect }, null, 2)}\n`);
return 0;
}
if (resolvedOutputMode === 'viz') {
await writeStoredReplay(result.replay, resolvedOutputMode, inputMode, io, deps, env);
} else {
writeReplay(result.replay, resolvedOutputMode, io);
io.stdout.write('\n');
io.stdout.write(formatSeededInspect(result.inspect));
}
return 0;
}
export async function runKtxDemo(args: KtxDemoArgs, io: KtxDemoIo = process, deps: KtxDemoDeps = {}): Promise<number> {
try {
if (args.command === 'init') {
const result = await ensureDemoProject({ projectDir: args.projectDir, force: args.force });
io.stdout.write(`Demo project: ${result.projectDir}\n`);
io.stdout.write(`Config: ${result.configPath}\n`);
io.stdout.write(`Database: ${result.databasePath}\n`);
io.stdout.write(`Replay: ${result.replayPath}\n`);
io.stdout.write('Next: ktx setup demo --no-input\n');
io.stdout.write(' Runs the pre-seeded demo without calling the LLM.\n');
return 0;
}
if (args.command === 'reset') {
const result = await resetDemoProject({ projectDir: args.projectDir, force: args.force });
io.stdout.write(`Demo project reset: ${result.projectDir}\n`);
io.stdout.write(`Config: ${result.configPath}\n`);
io.stdout.write(`Database: ${result.databasePath}\n`);
io.stdout.write(`Replay: ${result.replayPath}\n`);
io.stdout.write('Next: ktx setup demo --mode full\n');
io.stdout.write(' Runs the full AI-backed pass with your LLM provider.\n');
return 0;
}
const preparedProjectDir = await prepareProjectForDemoCommand(args, io, deps);
if (preparedProjectDir === null) {
return 1;
}
const env = deps.env ?? process.env;
if (args.command === 'scan') {
const { result } = await runDemoScan({ projectDir: preparedProjectDir });
io.stdout.write(formatDemoScanSummary(result.report));
return 0;
}
if (args.command === 'seeded' || (args.command === 'ingest' && args.mode === 'seeded')) {
return await runSeededDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env);
}
if (args.command === 'full' || (args.command === 'ingest' && args.mode === 'full')) {
const executeFullDemo = deps.runFullDemo ?? runDemoFull;
await ensureDemoProjectForCommand(preparedProjectDir);
const project = await loadKtxProject({ projectDir: preparedProjectDir });
const credentialStatus = fullDemoCredentialStatus(project, env);
const credentialDecision = await resolveFullCredentialDecision({
needsAnthropicKey:
credentialStatus.status === 'missing-anthropic-key' &&
project.config.llm.provider.backend === 'anthropic' &&
!resolveKtxConfigReference(project.config.llm.provider.anthropic?.api_key, env),
inputMode: args.inputMode,
io,
env,
prompts: deps.prompts ?? createClackDemoPromptAdapter(),
});
if (credentialDecision.action === 'cancel') {
return 1;
}
if (credentialDecision.action === 'run-mode') {
return credentialDecision.mode === 'seeded'
? await runSeededDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env)
: await runReplayDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env);
}
let liveSession: MemoryFlowTuiLiveSession | null = null;
let liveWasRendered = false;
const startLiveMemoryFlow = deps.startLiveMemoryFlow ?? startLiveMemoryFlowTui;
let fullOutputMode = effectiveDemoOutputMode(args.outputMode, io, env, {
requireInput: args.inputMode !== 'disabled',
});
const shouldUseLiveViz = fullOutputMode === 'viz' && args.inputMode !== 'disabled';
if (shouldUseLiveViz && isTuiCapableDemoIo(io)) {
liveSession = await startLiveMemoryFlow(initialFullDemoMemoryFlowInput(preparedProjectDir), io);
liveWasRendered = liveSession !== null;
} else if (shouldUseLiveViz) {
warnVizFallbackOnce(io, rendererUnavailableVizFallback());
fullOutputMode = 'plain';
}
const onMemoryFlowChange = pickMemoryFlowProgress(liveSession, fullOutputMode, io);
const result = await executeFullDemo({
projectDir: preparedProjectDir,
env: credentialDecision.env,
...(onMemoryFlowChange ? { onMemoryFlowChange } : {}),
});
await writeDemoReplay(preparedProjectDir, replayWithFullMetadata(result), { label: 'full' });
liveSession?.close();
writeFullDemo(result, fullOutputMode, io, { liveWasRendered, projectDir: preparedProjectDir });
if (fullOutputMode !== 'json' && !liveWasRendered) {
io.stdout.write(formatDemoInspect(await inspectDemoProject(preparedProjectDir)));
}
return 0;
}
if (args.command === 'inspect') {
const seededInspect = await inspectSeededProject(preparedProjectDir).catch(() => null);
if (seededInspect?.mode === 'seeded') {
if (args.outputMode === 'json') {
io.stdout.write(`${JSON.stringify(seededInspect, null, 2)}\n`);
} else {
io.stdout.write(formatSeededInspect(seededInspect));
}
return 0;
}
writeInspect(await inspectDemoProject(preparedProjectDir), args.outputMode, io);
return 0;
}
if (args.command === 'doctor') {
const { runKtxDoctor } = await import('./doctor.js');
const executeDoctor = deps.runDoctor ?? runKtxDoctor;
return await executeDoctor(
{
command: 'demo',
projectDir: args.projectDir,
outputMode: args.outputMode,
...(args.inputMode ? { inputMode: args.inputMode } : {}),
},
io,
);
}
return await runReplayDemo(preparedProjectDir, args.outputMode, args.inputMode, io, deps, env);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}

View file

@ -29,8 +29,7 @@ interface DoctorReport {
export type KtxDoctorArgs =
| { command: 'setup'; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
| { command: 'demo'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
interface KtxDoctorIo {
stdout: { write(chunk: string): void };
@ -323,7 +322,7 @@ async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): P
'connections',
'Connections',
'0 configured',
'Add a connection to ktx.yaml or run `ktx setup demo init`',
'Add a connection to ktx.yaml or run `ktx setup`',
),
);
checks.push(check('pass', 'storage', 'Storage', `${project.config.storage.state}/${project.config.storage.search}`));
@ -346,94 +345,6 @@ async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): P
return checks;
}
async function runDemoProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<DoctorCheck[]> {
const env = deps.env ?? process.env;
const { DEMO_CONNECTION_ID, DEMO_REPLAY_FILE } = await import('./demo-assets.js');
const { loadKtxProject } = await import('@ktx/context/project');
const checks: DoctorCheck[] = [];
const requiredPaths = [
['demo-config', 'Demo config', 'ktx.yaml'],
['demo-database', 'Demo dataset', 'demo.db'],
['demo-state', 'Demo state database', 'state.sqlite'],
['demo-replay', 'Demo replay', join('replays', DEMO_REPLAY_FILE)],
['demo-raw-sources', 'Demo raw sources directory', 'raw-sources'],
['demo-semantic-layer', 'Demo semantic-layer directory', 'semantic-layer'],
['demo-knowledge', 'Demo knowledge directory', 'knowledge'],
] as const;
for (const [id, label, relativePath] of requiredPaths) {
const absolutePath = join(projectDir, relativePath);
checks.push(
(await defaultPathExists(absolutePath))
? check('pass', id, label, relativePath)
: check(
'fail',
id,
label,
`Missing ${relativePath}`,
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
),
);
}
try {
const project = await loadKtxProject({ projectDir });
const connection = project.config.connections[DEMO_CONNECTION_ID];
checks.push(
connection?.driver === 'sqlite'
? check('pass', 'demo-connection', 'Demo connection', `${DEMO_CONNECTION_ID} uses sqlite`)
: check(
'fail',
'demo-connection',
'Demo connection',
`${DEMO_CONNECTION_ID} is missing or is not sqlite`,
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
),
);
const provider = project.config.llm.provider.backend;
checks.push(
provider === 'anthropic' || provider === 'vertex' || provider === 'gateway'
? check('pass', 'demo-llm-provider', 'Demo LLM provider', provider)
: check(
'fail',
'demo-llm-provider',
'Demo LLM provider',
provider,
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
),
);
if (provider === 'anthropic' && !env.ANTHROPIC_API_KEY) {
checks.push(
check(
'warn',
'anthropic-credentials',
'Anthropic credentials',
'ANTHROPIC_API_KEY is not set',
'Export ANTHROPIC_API_KEY to run `ktx setup demo --mode full --no-input`',
),
);
} else {
checks.push(check('pass', 'anthropic-credentials', 'Anthropic credentials', 'Configured for current provider'));
}
checks.push(await runSemanticSearchEmbeddingCheck(project.config.ingest.embeddings, projectDir, deps));
const runHistoricSqlDoctorChecks =
deps.runHistoricSqlDoctorChecks ?? (await import('./historic-sql-doctor.js')).runPostgresHistoricSqlDoctorChecks;
checks.push(...(await runHistoricSqlDoctorChecks(project, deps)));
} catch (error) {
checks.push(
check(
'fail',
'demo-config-parse',
'Demo config parse',
failureMessage(error),
`Run: ktx setup demo init --project-dir ${projectDir} --force --no-input`,
),
);
}
return checks;
}
export function formatDoctorReport(report: DoctorReport): string {
const lines = [report.title];
for (const item of report.checks) {
@ -469,15 +380,10 @@ export async function runKtxDoctor(
const report: DoctorReport =
args.command === 'setup'
? { title: 'KTX setup doctor', checks: setupChecks }
: args.command === 'demo'
? {
title: 'KTX demo doctor',
checks: [...setupChecks, ...(await runDemoProjectChecks(args.projectDir, deps))],
}
: {
title: 'KTX project doctor',
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
};
: {
title: 'KTX project doctor',
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
};
writeReport(report, args.outputMode, io);
return hasFailures(report) ? 1 : 0;

View file

@ -342,14 +342,15 @@ describe('runKtxCli', () => {
expect(io.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
});
it('exposes demo under setup help instead of root help', async () => {
it('documents setup as a bare command without subcommands', async () => {
const testIo = makeIo();
await expect(runKtxCli(['setup', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx setup [options] [command]');
expect(testIo.stdout()).toContain('demo');
expect(testIo.stdout()).toContain('Run the packaged KTX demo from setup');
expect(testIo.stdout()).toContain('Usage: ktx setup [options]');
expect(testIo.stdout()).not.toContain('Commands:');
expect(testIo.stdout()).not.toContain('setup demo');
expect(testIo.stdout()).not.toContain('setup context');
expect(testIo.stdout()).not.toContain('--skip-llm');
expect(testIo.stdout()).not.toContain('--skip-embeddings');
expect(testIo.stdout()).not.toContain('--embedding-model');
@ -373,13 +374,14 @@ describe('runKtxCli', () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const commands = [
['--project-dir', projectDir, 'setup', 'status', '--json'],
['--project-dir', projectDir, 'status', '--json'],
['--project-dir', projectDir, 'sl', 'list', '--json'],
];
for (const argv of commands) {
const testIo = makeIo();
await expect(runKtxCli(argv, testIo.io)).resolves.toBe(0);
const code = await runKtxCli(argv, testIo.io);
expect([0, 1]).toContain(code);
expect(() => JSON.parse(testIo.stdout())).not.toThrow();
expect(testIo.stderr()).toBe('');
@ -747,139 +749,33 @@ describe('runKtxCli', () => {
it('rejects standalone demo commands', async () => {
const testIo = makeIo();
const demo = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(['demo', '--mode', 'replay', '--no-input'], testIo.io, { demo })).resolves.toBe(1);
await expect(runKtxCli(['demo', '--mode', 'replay', '--no-input'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown command|error:/i);
expect(demo).not.toHaveBeenCalled();
});
it('dispatches setup demo commands', async () => {
const testIo = makeIo();
const demo = vi.fn().mockResolvedValue(0);
it('rejects removed setup subcommands', async () => {
const setup = vi.fn(async () => 0);
const cases = [
['setup', 'demo', '--mode', 'replay', '--no-input'],
['setup', '--no-input', 'demo', '--mode', 'seeded'],
['setup', 'demo', 'ingest', '--mode', 'full', '--no-input'],
['setup', 'context', 'build'],
['setup', 'context', 'watch', 'setup-context-local-1'],
['setup', 'context', 'status', 'setup-context-local-1', '--json'],
['setup', 'context', 'stop', 'setup-context-local-1'],
['setup', 'remove', '--agents'],
['setup', 'status', '--json'],
];
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'replay', '--no-input'], testIo.io, { demo }),
).resolves.toBe(0);
for (const args of cases) {
const testIo = makeIo();
await expect(runKtxCli(['--project-dir', tempDir, ...args], testIo.io, { setup })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown command|error:/i);
}
expect(demo).toHaveBeenCalledWith(
{
command: 'replay',
projectDir: tempDir,
outputMode: 'viz',
inputMode: 'disabled',
},
testIo.io,
);
demo.mockClear();
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'seeded', '--no-input'], testIo.io, {
demo,
}),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
{
command: 'seeded',
projectDir: tempDir,
outputMode: 'viz',
inputMode: 'disabled',
},
testIo.io,
);
demo.mockClear();
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', '--mode', 'seeded'], testIo.io, {
demo,
}),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
{
command: 'seeded',
projectDir: tempDir,
outputMode: 'viz',
inputMode: 'disabled',
},
testIo.io,
);
demo.mockClear();
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'inspect', '--no-input'], testIo.io, { demo }),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
{
command: 'inspect',
projectDir: tempDir,
outputMode: 'plain',
inputMode: 'disabled',
},
testIo.io,
);
});
it('dispatches demo ingest argv', async () => {
const testIo = makeIo();
const demo = vi.fn().mockResolvedValue(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input'], testIo.io, {
demo,
}),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
{
command: 'ingest',
mode: 'full',
projectDir: tempDir,
outputMode: 'viz',
inputMode: 'disabled',
},
testIo.io,
);
demo.mockClear();
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', 'ingest', '--mode', 'seeded'], testIo.io, {
demo,
}),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
{
command: 'ingest',
mode: 'seeded',
projectDir: tempDir,
outputMode: 'viz',
inputMode: 'disabled',
},
testIo.io,
);
demo.mockClear();
await expect(
runKtxCli(
['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input', '--plain'],
testIo.io,
{
demo,
},
),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
{
command: 'ingest',
mode: 'full',
projectDir: tempDir,
outputMode: 'plain',
inputMode: 'disabled',
},
testIo.io,
);
expect(setup).not.toHaveBeenCalled();
});
it('prints public ingest help without invoking ingest execution', async () => {
@ -1067,21 +963,16 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
});
it('keeps setup status on the setup runner and routes top-level status through doctor', async () => {
it('routes top-level status through doctor', async () => {
const setup = vi.fn(async () => 0);
const doctor = vi.fn(async () => 0);
const setupIo = makeIo();
const statusIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'status', '--json'], setupIo.io, { setup }),
).resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'status', '--json', '--no-input'], statusIo.io, { setup, doctor }),
).resolves.toBe(0);
expect(setup).toHaveBeenNthCalledWith(1, { command: 'status', projectDir: tempDir, json: true }, setupIo.io);
expect(setup).toHaveBeenCalledTimes(1);
expect(setup).not.toHaveBeenCalled();
expect(doctor).toHaveBeenCalledWith(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
statusIo.io,
@ -1121,54 +1012,6 @@ describe('runKtxCli', () => {
}
});
it('dispatches setup context recovery commands through the setup runner', async () => {
const setup = vi.fn(async () => 0);
const buildIo = makeIo();
const watchIo = makeIo();
const statusIo = makeIo();
const stopIo = makeIo();
await expect(runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'build'], buildIo.io, { setup })).resolves.toBe(
0,
);
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'watch', 'setup-context-local-1'], watchIo.io, {
setup,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'status', 'setup-context-local-1', '--json'], statusIo.io, {
setup,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'stop', 'setup-context-local-1'], stopIo.io, {
setup,
}),
).resolves.toBe(0);
expect(setup).toHaveBeenNthCalledWith(
1,
{ command: 'context-build', projectDir: tempDir, inputMode: 'auto' },
buildIo.io,
);
expect(setup).toHaveBeenNthCalledWith(
2,
{ command: 'context-watch', projectDir: tempDir, runId: 'setup-context-local-1', inputMode: 'auto' },
watchIo.io,
);
expect(setup).toHaveBeenNthCalledWith(
3,
{ command: 'context-status', projectDir: tempDir, runId: 'setup-context-local-1', json: true },
statusIo.io,
);
expect(setup).toHaveBeenNthCalledWith(
4,
{ command: 'context-stop', projectDir: tempDir, runId: 'setup-context-local-1' },
stopIo.io,
);
});
it('dispatches Anthropic setup flags to the setup runner', async () => {
const setup = vi.fn(async () => 0);
const setupIo = makeIo();
@ -1366,10 +1209,9 @@ describe('runKtxCli', () => {
);
});
it('dispatches setup agent flags and removal', async () => {
it('dispatches setup agent flags', async () => {
const setup = vi.fn(async () => 0);
const setupIo = makeIo();
const removeIo = makeIo();
await expect(
runKtxCli(
@ -1388,12 +1230,8 @@ describe('runKtxCli', () => {
{ setup },
),
).resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', 'remove', '--agents'], removeIo.io, { setup }),
).resolves.toBe(0);
expect(setup).toHaveBeenNthCalledWith(
1,
expect(setup).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
agents: true,
@ -1404,7 +1242,6 @@ describe('runKtxCli', () => {
}),
setupIo.io,
);
expect(setup).toHaveBeenNthCalledWith(2, { command: 'remove-agents', projectDir: tempDir }, removeIo.io);
});
it('rejects source-path with source-git-url', async () => {
@ -2345,21 +2182,17 @@ describe('runKtxCli', () => {
it('rejects mutually exclusive output modes before invoking runners', async () => {
const ingest = vi.fn(async () => 0);
const demo = vi.fn(async () => 0);
for (const argv of [
['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
['dev', 'ingest', 'status', 'run-1', '--json', '--viz'],
['setup', 'demo', '--json', '--plain'],
['setup', 'demo', 'replay', '--json', '--plain'],
]) {
const testIo = makeIo();
await expect(runKtxCli(argv, testIo.io, { ingest, demo })).resolves.toBe(1);
await expect(runKtxCli(argv, testIo.io, { ingest })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/conflict|cannot be used/i);
}
expect(ingest).not.toHaveBeenCalled();
expect(demo).not.toHaveBeenCalled();
});
it('rejects mutually exclusive credential and scan mode options before invoking runners', async () => {

View file

@ -12,17 +12,13 @@ describe('KTX demo next steps', () => {
it('uses supported context-build commands before agent usage', () => {
expect(KTX_CONTEXT_BUILD_COMMANDS).toEqual([
{
command: 'ktx setup context build',
description: 'Build agent-ready context from configured primary and context sources',
command: 'ktx setup',
description: 'Build or resume agent-ready context from configured sources',
},
{
command: 'ktx status',
description: 'Check setup and context readiness',
},
{
command: 'ktx setup context status',
description: 'Check the setup-managed context build state',
},
]);
});
@ -98,9 +94,8 @@ describe('KTX demo next steps', () => {
expect(rendered).toContain('Build KTX context next.');
expect(rendered).toContain('primary-source scans and context-source ingests');
expect(rendered).toContain('ktx setup context build');
expect(rendered).toContain('ktx setup');
expect(rendered).toContain('ktx status');
expect(rendered).toContain('ktx setup context status');
expect(rendered).not.toContain('ktx agent context --json');
expect(rendered).not.toContain('ktx serve --mcp');
});

View file

@ -1,16 +1,12 @@
export const KTX_CONTEXT_BUILD_COMMANDS = [
{
command: 'ktx setup context build',
description: 'Build agent-ready context from configured primary and context sources',
command: 'ktx setup',
description: 'Build or resume agent-ready context from configured sources',
},
{
command: 'ktx status',
description: 'Check setup and context readiness',
},
{
command: 'ktx setup context status',
description: 'Check the setup-managed context build state',
},
] as const;
export const KTX_NEXT_STEP_DIRECT_COMMANDS = [

View file

@ -31,14 +31,13 @@ describe('project directory defaults', () => {
process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project';
const connection = vi.fn(async () => 0);
const demo = vi.fn(async () => 0);
const doctor = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
const scan = vi.fn(async () => 0);
const setup = vi.fn(async () => 0);
const agent = vi.fn(async () => 0);
const deps: KtxCliDeps = { agent, connection, demo, doctor, ingest, publicIngest, scan, setup };
const deps: KtxCliDeps = { agent, connection, doctor, ingest, publicIngest, scan, setup };
const cases: Array<{
argv: string[];
@ -52,12 +51,6 @@ describe('project directory defaults', () => {
expected: { command: 'list', projectDir: '/tmp/ktx-env-project' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['setup', 'demo', 'scan', '--no-input'],
spy: demo,
expected: { command: 'scan', projectDir: '/tmp/ktx-env-project' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['status', '--no-input'],
spy: doctor,
@ -71,9 +64,9 @@ describe('project directory defaults', () => {
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['setup', 'status'],
argv: ['setup', '--no-input'],
spy: setup,
expected: { command: 'status', projectDir: '/tmp/ktx-env-project' },
expected: { command: 'run', projectDir: '/tmp/ktx-env-project' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{

View file

@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
contextBuildCommands,
readKtxSetupContextState,
runKtxSetupContextCommand,
runKtxSetupContextStep,
writeKtxSetupContextState,
} from './setup-context.js';
@ -154,8 +153,8 @@ describe('setup context build state', () => {
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: ['docs'],
commands: {
watch: `ktx setup context watch setup-context-local-abc123 --project-dir ${tempDir}`,
status: `ktx setup context status setup-context-local-abc123 --project-dir ${tempDir}`,
watch: `ktx setup --project-dir ${tempDir}`,
status: `ktx status --project-dir ${tempDir}`,
resume: `ktx setup --project-dir ${tempDir}`,
},
});
@ -588,109 +587,4 @@ describe('setup context build state', () => {
expect(output).toContain('Context build continuing in the background.');
expect(output).toContain('Resume: ktx setup --project-dir');
});
it('prints JSON setup context command status with watch and resume commands', async () => {
await mkdir(join(tempDir, '.ktx', 'setup'), { recursive: true });
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-abc123',
status: 'detached',
startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:01:00.000Z',
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: ['docs'],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-abc123'),
});
const io = makeIo();
await expect(
runKtxSetupContextCommand(
{ command: 'status', projectDir: tempDir, runId: 'setup-context-local-abc123', json: true },
io.io,
),
).resolves.toBe(0);
expect(JSON.parse(io.stdout())).toMatchObject({
ready: false,
status: 'detached',
runId: 'setup-context-local-abc123',
watchCommand: `ktx setup context watch setup-context-local-abc123 --project-dir ${tempDir}`,
statusCommand: `ktx setup context status setup-context-local-abc123 --project-dir ${tempDir}`,
});
});
it('watches setup context command status until the run reaches a terminal state', async () => {
await mkdir(join(tempDir, '.ktx', 'setup'), { recursive: true });
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-watch',
status: 'running',
startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:00:00.000Z',
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: ['docs'],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-watch'),
});
const io = makeIo();
const completeRun = async () => {
await writeKtxSetupContextState(tempDir, {
runId: 'setup-context-local-watch',
status: 'completed',
startedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:02:00.000Z',
completedAt: '2026-05-09T10:02:00.000Z',
primarySourceConnectionIds: ['warehouse'],
contextSourceConnectionIds: ['docs'],
reportIds: [],
artifactPaths: [],
retryableFailedTargets: [],
commands: contextBuildCommands(tempDir, 'setup-context-local-watch'),
});
};
await expect(
runKtxSetupContextCommand(
{ command: 'watch', projectDir: tempDir, runId: 'setup-context-local-watch', inputMode: 'disabled' },
io.io,
{ sleep: completeRun, watchIntervalMs: 1 },
),
).resolves.toBe(0);
expect(io.stdout()).toContain('KTX context built: running');
expect(io.stdout()).toContain('KTX context built: yes');
});
it('runs direct build commands without asking for setup confirmation first', async () => {
await writeReadyProject(tempDir);
const io = makeIo();
const runContextBuildMock = vi.fn(async () => ({ exitCode: 0, detached: false }));
await expect(
runKtxSetupContextCommand(
{ command: 'build', projectDir: tempDir, inputMode: 'auto' },
io.io,
{
prompts: {
select: vi.fn(async () => {
throw new Error('direct build should not prompt');
}),
cancel: vi.fn(),
},
runIdFactory: () => 'setup-context-local-direct',
runContextBuild: runContextBuildMock,
verifyContextReady: vi.fn(async () => ({
ready: true,
agentContextReady: true,
semanticSearchReady: true,
details: [],
})),
},
),
).resolves.toBe(0);
expect(runContextBuildMock).toHaveBeenCalled();
});
});

View file

@ -91,11 +91,11 @@ export interface KtxSetupContextStepArgs {
autoWatch?: boolean;
}
export type KtxSetupContextCommandArgs =
| { command: 'build'; projectDir: string; inputMode: 'auto' | 'disabled' }
| { command: 'watch'; projectDir: string; runId?: string; inputMode: 'auto' | 'disabled' }
| { command: 'status'; projectDir: string; runId?: string; json: boolean }
| { command: 'stop'; projectDir: string; runId?: string };
interface KtxSetupContextWatchArgs {
projectDir: string;
runId?: string;
inputMode: 'auto' | 'disabled';
}
export interface KtxSetupContextPromptAdapter {
select(options: { message: string; options: Array<{ value: string; label: string }> }): Promise<string>;
@ -154,12 +154,11 @@ async function pathExists(path: string): Promise<boolean> {
export function contextBuildCommands(projectDir: string, runId?: string): KtxSetupContextCommands {
const resolvedProjectDir = resolve(projectDir);
const runIdArg = runId ? ` ${runId}` : '';
return {
build: `ktx setup context build --project-dir ${resolvedProjectDir}`,
watch: `ktx setup context watch${runIdArg} --project-dir ${resolvedProjectDir}`,
status: `ktx setup context status${runIdArg} --project-dir ${resolvedProjectDir}`,
stop: `ktx setup context stop${runIdArg} --project-dir ${resolvedProjectDir}`,
build: `ktx setup --project-dir ${resolvedProjectDir}`,
watch: `ktx setup --project-dir ${resolvedProjectDir}`,
status: `ktx status --project-dir ${resolvedProjectDir}`,
stop: `ktx setup --project-dir ${resolvedProjectDir}`,
resume: `ktx setup --project-dir ${resolvedProjectDir}`,
};
}
@ -498,7 +497,7 @@ 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 directly:\n ktx setup context build --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`);
}
@ -726,7 +725,6 @@ export async function runKtxSetupContextStep(
if (args.autoWatch) {
const watched = await watchContextStatus(
{
command: 'watch',
projectDir: args.projectDir,
...(existingState.runId ? { runId: existingState.runId } : {}),
inputMode: args.inputMode,
@ -752,7 +750,6 @@ export async function runKtxSetupContextStep(
if (choice === 'watch') {
const watched = await watchContextStatus(
{
command: 'watch',
projectDir: args.projectDir,
...(existingState.runId ? { runId: existingState.runId } : {}),
inputMode: args.inputMode,
@ -833,10 +830,6 @@ function defaultSleep(ms: number): Promise<void> {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function statusPayload(state: KtxSetupContextState): KtxSetupContextStatusSummary {
return setupContextStatusFromState(state, { completedStep: state.status === 'completed' });
}
function writeContextStatus(state: KtxSetupContextState, io: KtxCliIo): void {
io.stdout.write(`KTX context built: ${state.status === 'completed' ? 'yes' : state.status.replaceAll('_', ' ')}\n`);
if (state.runId) {
@ -850,7 +843,7 @@ function writeContextStatus(state: KtxSetupContextState, io: KtxCliIo): void {
}
async function watchContextStatus(
args: Extract<KtxSetupContextCommandArgs, { command: 'watch' }>,
args: KtxSetupContextWatchArgs,
initialState: KtxSetupContextState,
io: KtxCliIo,
deps: KtxSetupContextDeps,
@ -862,7 +855,7 @@ async function watchContextStatus(
}
async function watchContextStatusText(
args: Extract<KtxSetupContextCommandArgs, { command: 'watch' }>,
args: KtxSetupContextWatchArgs,
initialState: KtxSetupContextState,
io: KtxCliIo,
deps: KtxSetupContextDeps,
@ -894,7 +887,7 @@ async function watchContextStatusText(
}
async function watchContextStatusWithProgressView(
args: Extract<KtxSetupContextCommandArgs, { command: 'watch' }>,
args: KtxSetupContextWatchArgs,
initialState: KtxSetupContextState,
io: KtxCliIo,
deps: KtxSetupContextDeps,
@ -975,7 +968,7 @@ async function watchContextStatusWithProgressView(
io.stdout.write('\n\nContext build continuing in the background.\n');
io.stdout.write(`Resume: ktx setup --project-dir ${projectDir}\n`);
io.stdout.write(`Status: ktx setup context status --project-dir ${projectDir}\n`);
io.stdout.write(`Status: ktx status --project-dir ${projectDir}\n`);
return { exitCode: 0, state };
}
@ -991,51 +984,3 @@ function setupResultFromWatchedState(projectDir: string, state: KtxSetupContextS
}
return { status: 'failed', projectDir };
}
export async function runKtxSetupContextCommand(
args: KtxSetupContextCommandArgs,
io: KtxCliIo,
deps: KtxSetupContextDeps = {},
): Promise<number> {
if (args.command === 'build') {
const result = await runKtxSetupContextStep(
{ projectDir: args.projectDir, inputMode: args.inputMode, prompt: false },
io,
deps,
);
return result.status === 'ready' || result.status === 'skipped' ? 0 : 1;
}
const state = await readKtxSetupContextState(args.projectDir);
if (!stateMatchesRunId(state, args.runId)) {
io.stderr.write(`KTX setup context run "${args.runId}" was not found.\n`);
return 1;
}
if (args.command === 'status') {
if (args.json) {
io.stdout.write(`${JSON.stringify(statusPayload(state), null, 2)}\n`);
} else {
writeContextStatus(state, io);
}
return 0;
}
if (args.command === 'watch') {
return (await watchContextStatus(args, state, io, deps)).exitCode;
}
const updatedAt = new Date().toISOString();
const nextState: KtxSetupContextState = {
...state,
status: state.status === 'completed' ? 'completed' : 'paused',
updatedAt,
};
await writeKtxSetupContextState(args.projectDir, nextState);
io.stdout.write(
state.status === 'completed'
? 'KTX context build already completed.\n'
: 'KTX context build pause requested. Resume with setup when ready.\n',
);
return 0;
}

View file

@ -39,7 +39,7 @@ function createDemoTarget(
driver,
operation,
...(adapter ? { adapter } : {}),
debugCommand: `ktx setup context build --target ${connectionId}`,
debugCommand: `ktx setup --project-dir <project-dir>`,
steps: operation === 'scan'
? ['scan', 'enrich', 'memory-update']
: ['source-ingest', 'enrich', 'memory-update'],

View file

@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
import { runDemoTour } from './setup-demo-tour.js';
import { readKtxSetupStatus, runKtxSetup } from './setup.js';
import { formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from './setup.js';
vi.mock('./setup-demo-tour.js', () => ({
runDemoTour: vi.fn(async () => 0),
@ -310,8 +310,8 @@ describe('setup status', () => {
ready: false,
status: 'running',
runId: 'setup-context-local-abc123',
watchCommand: `ktx setup context watch setup-context-local-abc123 --project-dir ${tempDir}`,
statusCommand: `ktx setup context status setup-context-local-abc123 --project-dir ${tempDir}`,
watchCommand: `ktx setup --project-dir ${tempDir}`,
statusCommand: `ktx status --project-dir ${tempDir}`,
},
});
});
@ -363,44 +363,36 @@ describe('setup status', () => {
);
const status = await readKtxSetupStatus(tempDir);
const io = makeIo();
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, io.io)).resolves.toBe(0);
const rendered = formatKtxSetupStatus(status);
expect(status.llm).toMatchObject({ backend: 'vertex', ready: true, model: 'claude-sonnet-4-6' });
expect(status.context).toMatchObject({ ready: true, status: 'completed' });
expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)');
expect(io.stdout()).toContain('KTX context built: yes');
expect(rendered).toContain('LLM ready: yes (claude-sonnet-4-6)');
expect(rendered).toContain('KTX context built: yes');
});
it('prints plain and JSON setup status', async () => {
const plainIo = makeIo();
const jsonIo = makeIo();
it('formats plain and JSON setup status payloads', async () => {
const status = await readKtxSetupStatus(tempDir);
const rendered = formatKtxSetupStatus(status);
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, plainIo.io)).resolves.toBe(0);
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: true }, jsonIo.io)).resolves.toBe(0);
expect(plainIo.stdout()).toContain(`No KTX project found at ${tempDir}.`);
expect(plainIo.stdout()).toContain('Check another project: ktx --project-dir <folder> setup status');
expect(plainIo.stdout()).toContain('Or from that folder: ktx setup status');
expect(plainIo.stdout()).toContain('Create a new KTX project here: ktx setup');
expect(plainIo.stdout()).not.toContain('Project ready: no');
expect(JSON.parse(jsonIo.stdout())).toMatchObject({ project: { path: tempDir, ready: false } });
expect(plainIo.stderr()).toBe('');
expect(jsonIo.stderr()).toBe('');
expect(rendered).toContain(`No KTX project found at ${tempDir}.`);
expect(rendered).toContain('Check another project: ktx --project-dir <folder> status');
expect(rendered).toContain('Or from that folder: ktx status');
expect(rendered).toContain('Create a new KTX project here: ktx setup');
expect(rendered).not.toContain('Project ready: no');
expect(JSON.parse(JSON.stringify(status))).toMatchObject({ project: { path: tempDir, ready: false } });
});
it('prints the readiness checklist for an existing project', async () => {
const testIo = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, testIo.io)).resolves.toBe(0);
const rendered = formatKtxSetupStatus(await readKtxSetupStatus(tempDir));
expect(testIo.stdout()).toContain(`KTX project: ${tempDir}`);
expect(testIo.stdout()).toContain('Project ready: yes');
expect(testIo.stdout()).toContain('LLM ready: no');
expect(testIo.stdout()).toContain('KTX context built: no');
expect(testIo.stdout()).not.toContain('No KTX project found.');
expect(testIo.stderr()).toBe('');
expect(rendered).toContain(`KTX project: ${tempDir}`);
expect(rendered).toContain('Project ready: yes');
expect(rendered).toContain('LLM ready: no');
expect(rendered).toContain('KTX context built: no');
expect(rendered).not.toContain('No KTX project found.');
});
it('prints the setup shell intro for auto-created run mode', async () => {
@ -2030,17 +2022,6 @@ describe('setup status', () => {
expect(agents).toHaveBeenCalledTimes(1);
});
it('removes agent integrations through setup remove command', async () => {
const io = makeIo();
const removeAgents = vi.fn(async () => 0);
await expect(runKtxSetup({ command: 'remove-agents', projectDir: tempDir }, io.io, { removeAgents })).resolves.toBe(
0,
);
expect(removeAgents).toHaveBeenCalledWith(tempDir, io.io);
});
it('does not run embedding setup when the model step fails', async () => {
const testIo = makeIo();
const model = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));

View file

@ -11,7 +11,6 @@ import {
type KtxAgentTarget,
type KtxSetupAgentsDeps,
readKtxAgentInstallManifest,
removeKtxAgentInstall,
runKtxSetupAgentsStep,
} from './setup-agents.js';
import {
@ -32,8 +31,6 @@ import { type KtxSetupSourcesDeps, type KtxSetupSourceType, runKtxSetupSourcesSt
import { withMenuOptionsSpacing } from './prompt-navigation.js';
import {
readKtxSetupContextState,
runKtxSetupContextCommand,
type KtxSetupContextCommandArgs,
type KtxSetupContextDeps,
type KtxSetupContextResult,
runKtxSetupContextStep,
@ -105,13 +102,7 @@ export type KtxSetupArgs =
runInitialSourceIngest?: boolean;
skipSources?: boolean;
showEntryMenu?: boolean;
}
| { command: 'status'; projectDir: string; json: boolean }
| { command: 'context-build'; projectDir: string; inputMode: 'auto' | 'disabled' }
| { command: 'context-watch'; projectDir: string; runId?: string; inputMode: 'auto' | 'disabled' }
| { command: 'context-status'; projectDir: string; runId?: string; json: boolean }
| { command: 'context-stop'; projectDir: string; runId?: string }
| { command: 'remove-agents'; projectDir: string };
};
export interface KtxSetupDeps {
project?: KtxSetupProjectDeps;
@ -142,7 +133,6 @@ export interface KtxSetupDeps {
agentsDeps?: KtxSetupAgentsDeps;
context?: (args: Parameters<typeof runKtxSetupContextStep>[0], io: KtxCliIo) => Promise<KtxSetupContextResult>;
contextDeps?: KtxSetupContextDeps;
removeAgents?: typeof removeKtxAgentInstall;
readyMenuDeps?: KtxSetupReadyMenuDeps;
entryMenuDeps?: KtxSetupEntryMenuDeps;
}
@ -358,8 +348,8 @@ export function formatKtxSetupStatus(status: KtxSetupStatus): string {
return [
`No KTX project found at ${status.project.path}.`,
'',
'Check another project: ktx --project-dir <folder> setup status',
'Or from that folder: ktx setup status',
'Check another project: ktx --project-dir <folder> status',
'Or from that folder: ktx status',
'Create a new KTX project here: ktx setup',
'',
].join('\n');
@ -383,9 +373,7 @@ export function formatKtxSetupStatus(status: KtxSetupStatus): string {
lines.push(`Resume: ${status.context.watchCommand}`);
}
if (!status.context.ready && status.context.status === 'failed' && status.context.detail) {
lines.push(
`Retry: ${status.context.retryCommand ?? `ktx setup context build --project-dir ${status.project.path}`}`,
);
lines.push(`Retry: ${status.context.retryCommand ?? `ktx setup --project-dir ${status.project.path}`}`);
}
return `${lines.join('\n')}\n`;
@ -420,7 +408,7 @@ function setupContextActive(status: KtxSetupStatus): boolean {
function writeContextNotReadyForAgents(projectDir: string, io: KtxCliIo): void {
io.stderr.write('KTX context is not ready for agents.\n\n');
io.stderr.write(`Build context first:\n ktx setup context build --project-dir ${resolve(projectDir)}\n\n`);
io.stderr.write(`Build context first:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`);
io.stderr.write(`Then install agent integration:\n ktx setup --agents --project-dir ${resolve(projectDir)}\n`);
}
@ -448,43 +436,6 @@ export async function runKtxSetup(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSet
}
async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetupDeps = {}): Promise<number> {
if (args.command === 'remove-agents') {
return await (deps.removeAgents ?? removeKtxAgentInstall)(args.projectDir, io);
}
if (
args.command === 'context-build' ||
args.command === 'context-watch' ||
args.command === 'context-status' ||
args.command === 'context-stop'
) {
const commandArgs: KtxSetupContextCommandArgs =
args.command === 'context-build'
? { command: 'build', projectDir: args.projectDir, inputMode: args.inputMode }
: args.command === 'context-watch'
? {
command: 'watch',
projectDir: args.projectDir,
...(args.runId ? { runId: args.runId } : {}),
inputMode: args.inputMode,
}
: args.command === 'context-status'
? {
command: 'status',
projectDir: args.projectDir,
...(args.runId ? { runId: args.runId } : {}),
json: args.json,
}
: { command: 'stop', projectDir: args.projectDir, ...(args.runId ? { runId: args.runId } : {}) };
return await runKtxSetupContextCommand(commandArgs, io, deps.contextDeps);
}
if (args.command === 'status') {
const status = await readKtxSetupStatus(args.projectDir);
io.stdout.write(args.json ? `${JSON.stringify(status, null, 2)}\n` : formatKtxSetupStatus(status));
return 0;
}
io.stdout.write('KTX setup\n');
let entryAction: KtxSetupEntryAction | undefined;
let projectResult: Awaited<ReturnType<typeof runKtxSetupProjectStep>>;

View file

@ -199,76 +199,6 @@ describe('standalone built ktx CLI smoke', () => {
);
});
it('runs the default pre-seeded demo without credentials', async () => {
const projectDir = join(tempDir, 'demo-project');
const result = await runBuiltCli(
['setup', 'demo', '--project-dir', projectDir, '--plain', '--no-input'],
{
env: { ...process.env, ANTHROPIC_API_KEY: '' },
},
);
expectProjectStderr(result, projectDir);
expect(result.stdout).toContain('Mode: seeded');
expect(result.stdout).toContain('Source: packaged demo project');
expect(result.stdout).toContain('LLM calls: none');
expect(result.stdout).toContain('Warehouse:');
expect(result.stdout).toContain('dbt:');
expect(result.stdout).toContain('BI:');
expect(result.stdout).toContain('Notion:');
expect(result.stdout).toContain('Semantic-layer sources:');
expect(result.stdout).toContain('Knowledge pages:');
expect(result.stdout).not.toContain('ktx serve --mcp stdio');
expect(result.stdout).not.toContain(['--mode', 'deterministic'].join(' '));
});
it('runs hybrid agent search against the seeded demo through the built binary', async () => {
const projectDir = join(tempDir, 'seeded-hybrid-search-project');
const seeded = await runBuiltCli(['setup', 'demo', '--project-dir', projectDir, '--plain', '--no-input'], {
env: { ...process.env, ANTHROPIC_API_KEY: '' },
});
expectProjectStderr(seeded, projectDir);
expect(seeded.stdout).toContain('Mode: seeded');
const wikiSearch = await runBuiltCli([
'agent',
'wiki',
'search',
'ARR contract',
'--json',
'--limit',
'5',
'--project-dir',
projectDir,
]);
expect(wikiSearch).toMatchObject({ code: 0, stderr: '' });
const wikiJson = parseJsonOutput<{
results: Array<{ key: string; score: number; matchReasons?: string[] }>;
totalFound: number;
}>(wikiSearch.stdout);
expect(wikiJson.totalFound).toBeGreaterThan(0);
expect(wikiJson.results.some((result) => result.matchReasons?.length)).toBe(true);
const slSearch = await runBuiltCli([
'agent',
'sl',
'list',
'--json',
'--query',
'ARR',
'--project-dir',
projectDir,
]);
expect(slSearch).toMatchObject({ code: 0, stderr: '' });
const slJson = parseJsonOutput<{
sources: Array<{ connectionId: string; name: string; score?: number; matchReasons?: string[] }>;
totalSources: number;
}>(slSearch.stdout);
expect(slJson.totalSources).toBeGreaterThan(0);
expect(slJson.sources.some((source) => source.matchReasons?.length)).toBe(true);
});
it('prints guided JSON for agent semantic-layer search outside a project through the built binary', async () => {
const projectDir = join(tempDir, 'missing-search-project');
await mkdir(projectDir, { recursive: true });
@ -296,8 +226,8 @@ describe('standalone built ktx CLI smoke', () => {
code: 'agent_sl_search_missing_project',
message: `Semantic-layer search needs an initialized KTX project at ${projectDir}.`,
nextSteps: [
'ktx demo',
`ktx setup --project-dir ${projectDir}`,
`ktx status --project-dir ${projectDir}`,
'ktx ingest <connection>',
`ktx agent sl list --json --query "revenue" --project-dir ${projectDir}`,
],
@ -305,37 +235,6 @@ describe('standalone built ktx CLI smoke', () => {
});
});
it('runs the pre-seeded demo and inspect without credentials', async () => {
const projectDir = join(tempDir, 'seeded-demo-project');
const seeded = await runBuiltCli(['setup', 'demo', '--mode', 'seeded', '--project-dir', projectDir, '--no-input']);
expect(seeded.code).toBe(0);
expect(seeded.stdout).toContain('Mode: seeded');
expect(seeded.stdout).toContain('LLM calls: none');
expect(seeded.stdout).toContain('Semantic-layer sources:');
expect(seeded.stdout).toContain('Knowledge pages:');
const inspect = await runBuiltCli(['setup', 'demo', 'inspect', '--project-dir', projectDir, '--no-input']);
expectProjectStderr(inspect, projectDir);
expect(inspect.stdout).toContain('Mode: seeded');
expect(inspect.stdout).toContain('Status: ready');
expect(inspect.stdout).toContain('Warehouse: 8 tables, 11,234 rows');
expect(inspect.stdout).toContain('Rows: accounts 210, arr_movements 720');
expect(inspect.stdout).toContain('dbt: 3 models, 8 source tables');
expect(inspect.stdout).toContain('BI: 5 explores, 2 dashboards');
expect(inspect.stdout).toContain('Notion: 8 pages');
expect(inspect.stdout).toContain('Semantic-layer sources:');
expect(inspect.stdout).toContain('Knowledge pages:');
expect(inspect.stdout).toContain('Evidence links:');
expect(inspect.stdout).toContain('Report: reports/seeded-demo-report.json');
expect(inspect.stdout).toContain('Replay: replays/replay.memory-flow.v1.json');
expect(inspect.stdout).toContain('Latest replay: seeded (packaged, prebuilt)');
expect(inspect.stdout).toContain('ktx agent tools --json');
expect(inspect.stdout).toContain('ktx agent context --json');
expect(inspect.stdout).not.toContain('ktx ask "your question here"');
expect(inspect.stdout).not.toContain('ktx serve --mcp stdio');
});
it('runs doctor setup through the built binary', async () => {
const result = await runBuiltCli(['status', '--no-input']);
@ -346,90 +245,6 @@ describe('standalone built ktx CLI smoke', () => {
expect([0, 1]).toContain(result.code);
});
it('reports missing Anthropic credentials for full demo through the built binary', async () => {
const projectDir = join(tempDir, 'full-demo-missing-key');
const result = await runBuiltCli(['setup', 'demo', '--mode', 'full', '--project-dir', projectDir, '--no-input'], {
env: { ...process.env, ANTHROPIC_API_KEY: '' },
});
expect(result.code).toBe(1);
expect(result.stderr).toContain('ktx setup demo --mode full needs ANTHROPIC_API_KEY');
expect(result.stderr).toContain('ktx setup demo --mode seeded --no-input');
});
it('requires force for demo reset through the built binary', async () => {
const projectDir = join(tempDir, 'reset-demo-project');
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
expectProjectStderr(init, projectDir);
const withoutForce = await runBuiltCli(['setup', 'demo', 'reset', '--project-dir', projectDir, '--no-input']);
expect(withoutForce.code).toBe(1);
expect(withoutForce.stderr).toContain(
`ktx setup demo reset is destructive; pass --force to recreate ${projectDir}`,
);
const withForce = await runBuiltCli([
'setup',
'demo',
'reset',
'--project-dir',
projectDir,
'--force',
'--no-input',
]);
expectProjectStderr(withForce, projectDir);
expect(withForce.stdout).toContain(`Demo project reset: ${projectDir}`);
});
it('reports corrupted demo state with reset guidance through the built binary', async () => {
const projectDir = join(tempDir, 'corrupt-demo-project');
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
expectProjectStderr(init, projectDir);
await rm(join(projectDir, 'demo.db'), { force: true });
const replay = await runBuiltCli(['setup', 'demo', '--mode', 'replay', '--project-dir', projectDir, '--no-input']);
expect(replay.code).toBe(1);
expect(replay.stderr).toContain(`Demo project is not ready at ${projectDir}: missing demo.db`);
expect(replay.stderr).toContain(`ktx setup demo reset --project-dir ${projectDir} --force --no-input`);
});
it('runs demo doctor through the built binary', async () => {
const projectDir = join(tempDir, 'doctor-demo-project');
const init = await runBuiltCli(['setup', 'demo', 'init', '--project-dir', projectDir, '--no-input']);
expectProjectStderr(init, projectDir);
const result = await runBuiltCli(['setup', 'demo', 'doctor', '--project-dir', projectDir, '--no-input']);
expect(result.stdout).toContain('KTX demo doctor');
expect(result.stdout).toContain('Demo dataset');
expect(result.stdout).toContain('Demo replay');
expect(result.stdout).toContain('Demo LLM provider');
expect(result.stderr).toBe(`Project: ${projectDir}\n`);
expect([0, 1]).toContain(result.code);
});
it('runs demo ingest seeded mode through the built binary', async () => {
const projectDir = join(tempDir, 'seeded-ingest-alias');
const result = await runBuiltCli([
'setup',
'demo',
'ingest',
'--mode',
'seeded',
'--project-dir',
projectDir,
'--no-input',
]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('Mode: seeded');
expect(result.stdout).toContain('LLM calls: none');
});
it('runs structural and enriched scans through the built binary with manifest artifacts', async () => {
const projectDir = join(tempDir, 'scan-project');
const init = await runSetupNewProject(projectDir);

View file

@ -618,8 +618,8 @@ try {
const missingProjectError = parseJsonFailure('ktx agent sl list missing project', missingProjectSearch);
assert.equal(missingProjectError.error.code, 'agent_sl_search_missing_project');
assert.deepEqual(missingProjectError.error.nextSteps, [
'ktx demo',
'ktx setup --project-dir ' + missingProjectDir,
'ktx status --project-dir ' + missingProjectDir,
'ktx ingest <connection>',
'ktx agent sl list --json --query "revenue" --project-dir ' + missingProjectDir,
]);
@ -675,8 +675,8 @@ try {
const emptySearchError = parseJsonFailure('ktx agent sl list no connections', emptySearch);
assert.equal(emptySearchError.error.code, 'agent_sl_search_no_connections');
assert.deepEqual(emptySearchError.error.nextSteps, [
'ktx demo',
'ktx setup --project-dir ' + emptyProjectDir,
'ktx status --project-dir ' + emptyProjectDir,
'ktx ingest <connection>',
'ktx agent sl list --json --query "revenue" --project-dir ' + emptyProjectDir,
]);
@ -766,8 +766,8 @@ try {
const noSourceSearchError = parseJsonFailure('ktx agent sl list no indexed sources', noSourceSearch);
assert.equal(noSourceSearchError.error.code, 'agent_sl_search_no_indexed_sources');
assert.deepEqual(noSourceSearchError.error.nextSteps, [
'ktx demo',
'ktx setup --project-dir ' + projectDir,
'ktx status --project-dir ' + projectDir,
'ktx ingest <connection>',
'ktx agent sl list --json --query "revenue" --project-dir ' + projectDir,
]);
@ -1004,7 +1004,7 @@ try {
`;
}
export function npmDemoSmokeSource() {
export function npmCliSmokeSource() {
return `
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
@ -1046,18 +1046,8 @@ function requireStdout(label, result, pattern) {
assert.match(result.stdout, pattern, label + ' stdout did not match ' + pattern);
}
function requireProjectStderr(label, result, projectDir) {
assert.equal(
result.code,
0,
label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr,
);
assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr');
}
const root = await mkdtemp(join(tmpdir(), 'ktx-packed-demo-smoke-'));
const root = await mkdtemp(join(tmpdir(), 'ktx-cli-smoke-'));
try {
const projectDir = join(root, 'demo-project');
const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8'));
assert.deepEqual(Object.keys(packageJson.dependencies), ['@kaelio/ktx']);
@ -1066,61 +1056,10 @@ try {
requireStdout('ktx --help', help, /Usage: ktx/);
requireStdout('ktx --help', help, /setup/);
const seeded = await run(
'pnpm',
['exec', 'ktx', 'setup', 'demo', '--project-dir', projectDir, '--no-input', '--plain'],
);
requireSuccess('ktx setup demo seeded', seeded);
requireStdout('ktx setup demo seeded', seeded, /Mode: seeded/);
requireStdout('ktx setup demo seeded', seeded, /Source: packaged demo project/);
requireStdout('ktx setup demo seeded', seeded, /LLM calls: none/);
requireStdout('ktx setup demo seeded', seeded, /ktx agent context --json/);
assert.doesNotMatch(seeded.stdout, new RegExp(['--mode', 'deterministic'].join(' ')));
assert.doesNotMatch(seeded.stdout, /KTX memory flow/);
requireProjectStderr('ktx setup demo seeded', seeded, projectDir);
const demoWikiSearch = await run('pnpm', [
'exec',
'ktx',
'agent',
'wiki',
'search',
'ARR contract',
'--json',
'--limit',
'5',
'--project-dir',
projectDir,
]);
requireSuccess('ktx seeded demo agent wiki search', demoWikiSearch);
const demoWikiSearchJson = JSON.parse(demoWikiSearch.stdout);
assert.ok(demoWikiSearchJson.totalFound > 0, 'seeded demo wiki search should find results');
assert.ok(
demoWikiSearchJson.results.some((result) => Array.isArray(result.matchReasons) && result.matchReasons.length > 0),
'seeded demo wiki search should expose match reasons',
);
process.stdout.write('ktx seeded demo agent wiki search verified\\n');
const demoSlSearch = await run('pnpm', [
'exec',
'ktx',
'agent',
'sl',
'list',
'--json',
'--query',
'ARR',
'--project-dir',
projectDir,
]);
requireSuccess('ktx seeded demo agent sl search', demoSlSearch);
const demoSlSearchJson = JSON.parse(demoSlSearch.stdout);
assert.ok(demoSlSearchJson.totalSources > 0, 'seeded demo semantic-layer search should find sources');
assert.ok(
demoSlSearchJson.sources.some((source) => Array.isArray(source.matchReasons) && source.matchReasons.length > 0),
'seeded demo semantic-layer search should expose match reasons',
);
process.stdout.write('ktx seeded demo agent sl search verified\\n');
const setupHelp = await run('pnpm', ['exec', 'ktx', 'setup', '--help']);
requireSuccess('ktx setup --help', setupHelp);
requireStdout('ktx setup --help', setupHelp, /Usage: ktx setup/);
requireStdout('ktx setup --help', setupHelp, /--no-input/);
const doctor = await run('pnpm', ['exec', 'ktx', 'status', '--no-input']);
assert.ok([0, 1].includes(doctor.code), 'ktx status setup exit code must be 0 or 1');
@ -1174,28 +1113,28 @@ async function verifyNpmArtifacts(layout, tmpRoot) {
);
await writeFile(join(projectDir, 'verify-npm.mjs'), npmVerifySource());
await writeFile(join(projectDir, 'verify-installed-cli.mjs'), npmRuntimeSmokeSource());
await writeFile(join(projectDir, 'verify-installed-demo.mjs'), npmDemoSmokeSource());
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
await runCommand('pnpm', ['install'], { cwd: projectDir });
await runCommand('pnpm', ['rebuild', 'better-sqlite3'], { cwd: projectDir });
await runCommand('node', ['verify-npm.mjs'], { cwd: projectDir });
await runCommand('pnpm', ['exec', 'ktx', '--version'], { cwd: projectDir });
await runCommand('node', ['verify-installed-cli.mjs'], { cwd: projectDir });
await runCommand('node', ['verify-installed-demo.mjs'], { cwd: projectDir });
await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir });
}
async function verifyNpmDemoArtifacts(layout, tmpRoot) {
async function verifyNpmCliArtifacts(layout, tmpRoot) {
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
await assertPathExists(layout.npmTarballs[packageInfo.name], `${packageInfo.name} tarball`);
}
const projectDir = join(tmpRoot, 'npm-demo-clean-install');
const projectDir = join(tmpRoot, 'npm-cli-clean-install');
await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, 'package.json'), `${JSON.stringify(npmSmokePackageJson(layout), null, 2)}\n`);
await writeFile(join(projectDir, 'verify-installed-demo.mjs'), npmDemoSmokeSource());
await writeFile(join(projectDir, 'verify-installed-cli-commands.mjs'), npmCliSmokeSource());
await runCommand('pnpm', ['install'], { cwd: projectDir });
await runCommand('node', ['verify-installed-demo.mjs'], { cwd: projectDir });
await runCommand('node', ['verify-installed-cli-commands.mjs'], { cwd: projectDir });
}
async function verifyArtifacts(layout) {
@ -1209,12 +1148,12 @@ async function verifyArtifacts(layout) {
}
}
async function verifyDemoArtifacts(layout) {
async function verifyCliArtifacts(layout) {
await verifyArtifactManifest(layout);
const tmpRoot = await mkdtemp(join(tmpdir(), 'ktx-demo-artifacts-'));
const tmpRoot = await mkdtemp(join(tmpdir(), 'ktx-cli-artifacts-'));
try {
await verifyNpmDemoArtifacts(layout, tmpRoot);
await verifyNpmCliArtifacts(layout, tmpRoot);
} finally {
await rm(tmpRoot, { recursive: true, force: true });
}
@ -1233,7 +1172,7 @@ async function main() {
return;
}
if (command === 'verify-demo') {
await verifyDemoArtifacts(layout);
await verifyCliArtifacts(layout);
return;
}
if (command === 'verify-manifest') {

View file

@ -16,7 +16,7 @@ import {
copyRuntimeWheelAssets,
findPythonArtifacts,
NPM_ARTIFACT_PACKAGES,
npmDemoSmokeSource,
npmCliSmokeSource,
npmRuntimeSmokeSource,
npmSmokePackageJson,
npmVerifySource,
@ -386,9 +386,9 @@ describe('verifyNpmArtifacts', () => {
it('does not prepare an external Python environment for the npm smoke', async () => {
const source = await readFile(new URL('./package-artifacts.mjs', import.meta.url), 'utf8');
const start = source.indexOf('async function verifyNpmArtifacts');
const end = source.indexOf('async function verifyNpmDemoArtifacts');
const end = source.indexOf('async function verifyNpmCliArtifacts');
assert.ok(start > 0, 'verifyNpmArtifacts function must exist');
assert.ok(end > start, 'verifyNpmDemoArtifacts must follow verifyNpmArtifacts');
assert.ok(end > start, 'verifyNpmCliArtifacts must follow verifyNpmArtifacts');
const body = source.slice(start, end);
assert.doesNotMatch(body, /uv', \['venv', '\.venv'\]/);
@ -508,21 +508,16 @@ describe('verification snippets', () => {
assert.match(source, /ktx dev ingest provider guard verified/);
});
describe('npmDemoSmokeSource', () => {
it('exercises the public packed-demo first-run contract', () => {
const source = npmDemoSmokeSource();
describe('npmCliSmokeSource', () => {
it('exercises supported public package CLI commands', () => {
const source = npmCliSmokeSource();
assert.match(source, /pnpm', \['exec', 'ktx', '--help'\]/);
assert.match(source, /'demo', '--project-dir', projectDir, '--no-input', '--plain'/);
assert.match(source, /Mode: seeded/);
assert.match(source, /Source: packaged demo project/);
assert.match(source, /LLM calls: none/);
assert.match(source, /ktx agent context --json/);
assert.match(source, /pnpm', \['exec', 'ktx', 'setup', '--help'\]/);
assert.match(source, /Usage: ktx setup/);
assert.doesNotMatch(source, new RegExp(["'demo'", "'--mode'", "'deterministic'"].join(', ')));
assert.match(source, /'status', '--no-input'/);
assert.match(source, /'--plain'/);
assert.match(source, /function requireProjectStderr/);
assert.match(source, /requireProjectStderr\('ktx setup demo seeded', seeded, projectDir\)/);
assert.doesNotMatch(source, /function requireProjectStderr/);
assert.match(source, /Object\.keys\(packageJson\.dependencies\)/);
assert.match(source, /'@kaelio\/ktx'/);
});

View file

@ -1,4 +1,3 @@
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
@ -33,26 +32,6 @@ function registryEnv(config) {
return config.registry ? { npm_config_registry: config.registry } : {};
}
function runtimeCommandEnv(config, runtimeRoot) {
return { ...registryEnv(config), KTX_RUNTIME_ROOT: runtimeRoot };
}
function semanticQueryArgs(projectDir) {
return [
'sl',
'query',
'--project-dir',
projectDir,
'--connection-id',
'orbit_demo',
'--measure',
'contracts.contract_count',
'--format',
'sql',
'--yes',
];
}
function normalizePolicyConfig(policyConfig = {}) {
if (policyConfig === null || policyConfig === undefined) {
return { packageName: null, version: DEFAULT_VERSION_TAG, registry: null };
@ -150,24 +129,22 @@ export function buildPublishedPackageNpxCommand(config, args, label = 'published
export function buildPublishedPackageSmokeCommands(
config,
projectDir,
runtimeRoot = join(dirname(projectDir), 'managed-runtime'),
_projectDir,
) {
const runtimeEnv = runtimeCommandEnv(config, runtimeRoot);
const packageEnv = registryEnv(config);
const queryArgs = semanticQueryArgs(projectDir);
return [
buildPublishedPackageNpxCommand(config, ['--version'], 'published package npx version'),
buildPublishedPackageNpxCommand(
config,
['setup', 'demo', '--project-dir', projectDir, '--no-input', '--plain'],
'published package setup demo',
{ KTX_RUNTIME_ROOT: runtimeRoot },
['setup', '--help'],
'published package npx setup help',
),
buildPublishedPackageNpxCommand(
config,
['status', '--help'],
'published package npx status help',
),
buildPublishedPackageNpxCommand(config, queryArgs, 'published package npx sl query', {
KTX_RUNTIME_ROOT: runtimeRoot,
}),
{
label: 'published package local install',
command: 'pnpm',
@ -181,10 +158,10 @@ export function buildPublishedPackageSmokeCommands(
env: packageEnv,
},
{
label: 'published package local sl query',
label: 'published package local status help',
command: 'npx',
args: ['ktx', ...queryArgs],
env: runtimeEnv,
args: ['ktx', 'status', '--help'],
env: packageEnv,
},
{
label: 'published package global install',
@ -199,10 +176,10 @@ export function buildPublishedPackageSmokeCommands(
env: packageEnv,
},
{
label: 'published package global sl query',
label: 'published package global status help',
command: 'ktx',
args: queryArgs,
env: runtimeEnv,
args: ['status', '--help'],
env: packageEnv,
},
];
}

View file

@ -29,20 +29,10 @@ const VERSION_LABELS = new Set([
'published package global version',
]);
const SEMANTIC_QUERY_LABELS = new Set([
'published package npx sl query',
'published package local sl query',
'published package global sl query',
]);
export function isPublishedPackageVersionLabel(label) {
return VERSION_LABELS.has(label);
}
export function isPublishedPackageSemanticQueryLabel(label) {
return SEMANTIC_QUERY_LABELS.has(label);
}
function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
}
@ -100,10 +90,6 @@ export async function runPublishedPackageSmoke(config) {
if (isPublishedPackageVersionLabel(command.label)) {
assert.match(result.stdout, /@kaelio\/ktx /);
}
if (isPublishedPackageSemanticQueryLabel(command.label)) {
assert.match(result.stdout, /SELECT/i);
assert.match(result.stdout, /contracts/i);
}
}
process.stdout.write('published package invocation smoke verified\n');

View file

@ -5,7 +5,6 @@ import { describe, it } from 'node:test';
import {
buildPublishedPackageNpxCommand,
buildPublishedPackageSmokeCommands,
isPublishedPackageSemanticQueryLabel,
isPublishedPackageVersionLabel,
publishedPackageSpec,
readPublishedPackageSmokeConfig,
@ -149,16 +148,11 @@ describe('published package smoke config', () => {
});
describe('published package smoke output validation labels', () => {
it('classifies version and semantic query commands', () => {
it('classifies version commands', () => {
assert.equal(isPublishedPackageVersionLabel('published package npx version'), true);
assert.equal(isPublishedPackageVersionLabel('published package local version'), true);
assert.equal(isPublishedPackageVersionLabel('published package global version'), true);
assert.equal(isPublishedPackageVersionLabel('published package setup demo'), false);
assert.equal(isPublishedPackageSemanticQueryLabel('published package npx sl query'), true);
assert.equal(isPublishedPackageSemanticQueryLabel('published package local sl query'), true);
assert.equal(isPublishedPackageSemanticQueryLabel('published package global sl query'), true);
assert.equal(isPublishedPackageSemanticQueryLabel('published package local install'), false);
assert.equal(isPublishedPackageVersionLabel('published package npx setup help'), false);
});
});
@ -199,45 +193,16 @@ describe('published package smoke command construction', () => {
env: { npm_config_registry: 'https://registry.npmjs.org/' },
},
{
label: 'published package setup demo',
label: 'published package npx setup help',
command: 'npx',
args: [
'--yes',
'@kaelio/ktx@latest',
'setup',
'demo',
'--project-dir',
'/tmp/ktx-smoke/demo',
'--no-input',
'--plain',
],
env: {
npm_config_registry: 'https://registry.npmjs.org/',
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
},
args: ['--yes', '@kaelio/ktx@latest', 'setup', '--help'],
env: { npm_config_registry: 'https://registry.npmjs.org/' },
},
{
label: 'published package npx sl query',
label: 'published package npx status help',
command: 'npx',
args: [
'--yes',
'@kaelio/ktx@latest',
'sl',
'query',
'--project-dir',
'/tmp/ktx-smoke/demo',
'--connection-id',
'orbit_demo',
'--measure',
'contracts.contract_count',
'--format',
'sql',
'--yes',
],
env: {
npm_config_registry: 'https://registry.npmjs.org/',
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
},
args: ['--yes', '@kaelio/ktx@latest', 'status', '--help'],
env: { npm_config_registry: 'https://registry.npmjs.org/' },
},
{
label: 'published package local install',
@ -252,26 +217,10 @@ describe('published package smoke command construction', () => {
env: { npm_config_registry: 'https://registry.npmjs.org/' },
},
{
label: 'published package local sl query',
label: 'published package local status help',
command: 'npx',
args: [
'ktx',
'sl',
'query',
'--project-dir',
'/tmp/ktx-smoke/demo',
'--connection-id',
'orbit_demo',
'--measure',
'contracts.contract_count',
'--format',
'sql',
'--yes',
],
env: {
npm_config_registry: 'https://registry.npmjs.org/',
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
},
args: ['ktx', 'status', '--help'],
env: { npm_config_registry: 'https://registry.npmjs.org/' },
},
{
label: 'published package global install',
@ -286,25 +235,10 @@ describe('published package smoke command construction', () => {
env: { npm_config_registry: 'https://registry.npmjs.org/' },
},
{
label: 'published package global sl query',
label: 'published package global status help',
command: 'ktx',
args: [
'sl',
'query',
'--project-dir',
'/tmp/ktx-smoke/demo',
'--connection-id',
'orbit_demo',
'--measure',
'contracts.contract_count',
'--format',
'sql',
'--yes',
],
env: {
npm_config_registry: 'https://registry.npmjs.org/',
KTX_RUNTIME_ROOT: '/tmp/ktx-smoke/managed-runtime',
},
args: ['status', '--help'],
env: { npm_config_registry: 'https://registry.npmjs.org/' },
},
],
);