diff --git a/README.md b/README.md index 469369ad..cfabfbcc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index de04594e..f490988a 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -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 ` | `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 diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index d4eb731c..ece3ceac 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -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 diff --git a/docs/superpowers/plans/2026-05-13-cli-command-tree-script.md b/docs/superpowers/plans/2026-05-13-cli-command-tree-script.md new file mode 100644 index 00000000..e336eb31 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-cli-command-tree-script.md @@ -0,0 +1,580 @@ +# CLI Command-Tree Script Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a build-time script that prints the full `ktx` CLI command tree (name, aliases, description per node) as an indented text tree, for docs and discovery — without adding a runtime `ktx` subcommand. + +**Architecture:** Commander.js exposes every registered command as a `Command` instance with `.commands`, `.name()`, `.aliases()`, `.description()` — we walk that tree. The current `runCommanderKtxCli` in `packages/cli/src/cli-program.ts` builds the program inline; we extract that assembly into a pure `buildKtxProgram(...)` helper that any caller can use to materialize the configured root `Command` without parsing argv. A new pure module `command-tree.ts` walks the `Command` into plain data and renders it as indented text. A new TypeScript entrypoint `print-command-tree.ts` compiles alongside `bin.ts` into `dist/print-command-tree.js`, instantiates the program with stub IO/deps, and writes the rendered tree to stdout. A pnpm script under `@ktx/cli` exposes it as `pnpm --filter @ktx/cli run docs:commands`. + +**Tech Stack:** TypeScript (NodeNext ESM), Node 22, Commander 14 via `@commander-js/extra-typings`, vitest 4. + +--- + +## File Map + +- **Modify:** `packages/cli/src/cli-program.ts` — extract `buildKtxProgram` from `runCommanderKtxCli`. +- **Create:** `packages/cli/src/cli-program.test.ts` — vitest tests for the new helper. +- **Create:** `packages/cli/src/command-tree.ts` — pure `walkCommandTree` + `formatCommandTree`. +- **Create:** `packages/cli/src/command-tree.test.ts` — vitest tests against ad-hoc Command trees. +- **Create:** `packages/cli/src/print-command-tree.ts` — script entrypoint; thin glue. +- **Create:** `packages/cli/src/print-command-tree.test.ts` — vitest test that calls the script's exported `main()` with a fake stdout and asserts the rendered tree includes known top-level commands. +- **Modify:** `packages/cli/package.json` — add `docs:commands` script and include the new entry in tsc build output (no change needed if `tsconfig` already globs `src/**/*.ts`, but verify). +- **Modify:** `packages/cli/README.md` (if it exists; otherwise skip) — document `pnpm run docs:commands`. + +Files that change together (cli-program + its test, command-tree + its test, print-command-tree + its test) live next to each other under `packages/cli/src/`, matching the existing convention (e.g. `bin.ts`, `cli-runtime.ts`, `runtime.ts` + `runtime.test.ts`). + +--- + +## Task 1: Extract `buildKtxProgram` from `runCommanderKtxCli` + +Refactor only — no behavior change. The current code in `cli-program.ts` interleaves program construction with `parseAsync` dispatch. Splitting them lets the new script reuse construction without invoking the CLI. + +**Files:** +- Modify: `packages/cli/src/cli-program.ts:197-275` (function `runCommanderKtxCli`) +- Create: `packages/cli/src/cli-program.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/cli-program.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import type { Command } from '@commander-js/extra-typings'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; + +function stubIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' }; +} + +describe('buildKtxProgram', () => { + it('returns a Command named "ktx" with all registered top-level subcommands', () => { + const program: Command = buildKtxProgram({ + io: stubIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + + expect(program.name()).toBe('ktx'); + const topLevel = program.commands.map((c) => c.name()).sort(); + // Sanity check: at least these registrar surfaces must be present. + for (const expected of ['setup', 'serve', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + }); + + it('does not parse argv or invoke action handlers', async () => { + // Build should be a pure call; no rejections, no side-effects to stdout. + let wrote = ''; + const io: KtxCliIo = { + stdout: { isTTY: false, columns: 80, write: (chunk) => { wrote += chunk; } }, + stderr: { write: (chunk) => { wrote += chunk; } }, + }; + buildKtxProgram({ io, deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 }); + expect(wrote).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/cli-program.test.ts` + +Expected: FAIL — `buildKtxProgram is not exported from './cli-program.js'` (or similar TS/ESM error). + +- [ ] **Step 3: Extract `buildKtxProgram` from `runCommanderKtxCli`** + +Edit `packages/cli/src/cli-program.ts`. Add a new exported function above `runCommanderKtxCli`: + +```typescript +export interface BuildKtxProgramOptions { + io: KtxCliIo; + deps: KtxCliDeps; + packageInfo: KtxCliPackageInfo; + runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise; + setExitCode?: (code: number) => void; +} + +export function buildKtxProgram(options: BuildKtxProgramOptions): Command { + const program = createBaseProgram(options.packageInfo, options.io); + const context: KtxCliCommandContext = { + io: options.io, + deps: options.deps, + packageInfo: options.packageInfo, + setExitCode: options.setExitCode ?? (() => {}), + runInit: options.runInit, + writeDebug: (command, commandContext) => { + writeDebug(options.io, commandContext, command); + }, + }; + + registerSetupCommands(program, context); + registerConnectionCommands(program, context); + registerPublicIngestCommands(program, context); + registerWikiCommands(program, context); + registerSlCommands(program, context); + registerRuntimeCommands(program, context); + registerServeCommands(program, context); + registerStatusCommands(program, context); + registerAgentCommands(program, context); + registerDevCommands(program, context); + + return program; +} +``` + +Then rewrite the body of `runCommanderKtxCli` (lines 197-275) to delegate program assembly. Replace the block from `const program = createBaseProgram(info, io);` (line 206) through `registerDevCommands(program, context);` (line 248) with: + +```typescript + profileMark('commander:entry'); + let exitCode = 0; + const program = buildKtxProgram({ + io, + deps, + packageInfo: info, + runInit: options.runInit, + setExitCode: (code: number) => { + exitCode = code; + }, + }); + profileMark('commander:program-built'); + const context: KtxCliCommandContext = { + io, + deps, + packageInfo: info, + setExitCode: (code: number) => { + exitCode = code; + }, + runInit: options.runInit, + writeDebug: (command: string, commandContext: CommandWithGlobalOptions) => { + writeDebug(io, commandContext, command); + }, + }; +``` + +Keep the `context` re-declaration only if subsequent code (the `if (argv.length === 0)` branch that calls `runBareInteractiveCommand(program, io, context)`) still needs it. It does — `runBareInteractiveCommand` consumes `context`. Keep `context` exactly as it was after the deletion; do not change `runBareInteractiveCommand`'s signature or behavior. Drop the now-removed individual `register*` calls and their `profileMark` lines from `runCommanderKtxCli`. + +- [ ] **Step 4: Run the new test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/cli-program.test.ts` + +Expected: PASS — both `it` blocks green. + +- [ ] **Step 5: Run the full CLI test suite to confirm no regression** + +Run: `pnpm --filter @ktx/cli run test 2>&1 | tee /tmp/ktx-cli-test-output.log` + +Expected: PASS overall. Inspect the log if any previously-passing test now fails — most likely a missing register call (compare to lines 221-249 of the pre-change file). + +- [ ] **Step 6: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/cli-program.ts packages/cli/src/cli-program.test.ts +git commit -m "refactor(cli): extract buildKtxProgram for reuse outside runCommanderKtxCli" +``` + +--- + +## Task 2: Pure tree walker `walkCommandTree` + +Take a Commander `Command` and produce plain data: `{ name, description, aliases, children }`. No formatting yet. Pure function — depends only on the public `Command` API. + +**Files:** +- Create: `packages/cli/src/command-tree.ts` +- Create: `packages/cli/src/command-tree.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/command-tree.test.ts`: + +```typescript +import { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { walkCommandTree } from './command-tree.js'; + +describe('walkCommandTree', () => { + it('captures name, description, aliases, and nested children', () => { + const root = new Command('root').description('the root'); + const child = new Command('child').description('a child').alias('c').alias('ch'); + const grandchild = new Command('grand').description('a grandchild'); + child.addCommand(grandchild); + root.addCommand(child); + + const tree = walkCommandTree(root); + + expect(tree).toEqual({ + name: 'root', + description: 'the root', + aliases: [], + children: [ + { + name: 'child', + description: 'a child', + aliases: ['c', 'ch'], + children: [ + { name: 'grand', description: 'a grandchild', aliases: [], children: [] }, + ], + }, + ], + }); + }); + + it('returns an empty children array when there are no subcommands', () => { + const leaf = new Command('leaf').description('alone'); + expect(walkCommandTree(leaf)).toEqual({ + name: 'leaf', + description: 'alone', + aliases: [], + children: [], + }); + }); + + it('uses an empty string when description is unset', () => { + const cmd = new Command('bare'); + expect(walkCommandTree(cmd).description).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: FAIL — `walkCommandTree` cannot be resolved. + +- [ ] **Step 3: Implement `walkCommandTree`** + +Create `packages/cli/src/command-tree.ts`: + +```typescript +import type { Command } from '@commander-js/extra-typings'; + +export interface CommandTreeNode { + name: string; + description: string; + aliases: string[]; + children: CommandTreeNode[]; +} + +export function walkCommandTree(command: Command): CommandTreeNode { + return { + name: command.name(), + description: command.description(), + aliases: command.aliases(), + children: command.commands.map((child) => walkCommandTree(child as Command)), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: PASS (3 of 3). + +- [ ] **Step 5: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +--- + +## Task 3: Indented-text renderer `formatCommandTree` + +Render a `CommandTreeNode` as plain text. Each node on its own line: `[ (alias1, alias2)][ — description]`. Indent is two spaces per depth level. Children sorted alphabetically by name to keep output stable across changes that reorder registrar calls. + +**Files:** +- Modify: `packages/cli/src/command-tree.ts` +- Modify: `packages/cli/src/command-tree.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `packages/cli/src/command-tree.test.ts`: + +```typescript +import { formatCommandTree } from './command-tree.js'; + +describe('formatCommandTree', () => { + it('renders a single node with no children', () => { + const node = { name: 'solo', description: 'just me', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('solo — just me\n'); + }); + + it('renders aliases in parentheses before the description', () => { + const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], children: [] }; + expect(formatCommandTree(node)).toBe('cmd (c, co) — does things\n'); + }); + + it('omits the dash when description is empty', () => { + const node = { name: 'bare', description: '', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('bare\n'); + }); + + it('indents children by two spaces per depth level and sorts siblings alphabetically', () => { + const tree = { + name: 'root', + description: 'top', + aliases: [], + children: [ + { name: 'beta', description: 'b', aliases: [], children: [] }, + { name: 'alpha', description: 'a', aliases: ['al'], children: [ + { name: 'inner', description: 'i', aliases: [], children: [] }, + ] }, + ], + }; + expect(formatCommandTree(tree)).toBe( + 'root — top\n' + + ' alpha (al) — a\n' + + ' inner — i\n' + + ' beta — b\n', + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: FAIL — `formatCommandTree` is not exported. + +- [ ] **Step 3: Implement `formatCommandTree`** + +Append to `packages/cli/src/command-tree.ts`: + +```typescript +export function formatCommandTree(node: CommandTreeNode): string { + const lines: string[] = []; + appendNode(node, 0, lines); + return `${lines.join('\n')}\n`; +} + +function appendNode(node: CommandTreeNode, depth: number, lines: string[]): void { + const indent = ' '.repeat(depth); + const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; + const descPart = node.description.length > 0 ? ` — ${node.description}` : ''; + lines.push(`${indent}${node.name}${aliasPart}${descPart}`); + + const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name)); + for (const child of sortedChildren) { + appendNode(child, depth + 1, lines); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/command-tree.test.ts` + +Expected: PASS (7 of 7 across walkCommandTree + formatCommandTree). + +- [ ] **Step 5: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/command-tree.ts packages/cli/src/command-tree.test.ts +git commit -m "feat(cli): add walkCommandTree and formatCommandTree helpers" +``` + +--- + +## Task 4: Script entrypoint `print-command-tree.ts` + +Thin glue: build the program with stub IO/deps, walk, format, write to a provided stdout. Export a `main(stdout)` function for unit testing; only auto-run when invoked as a script. + +**Files:** +- Create: `packages/cli/src/print-command-tree.ts` +- Create: `packages/cli/src/print-command-tree.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/print-command-tree.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { renderKtxCommandTree } from './print-command-tree.js'; + +describe('renderKtxCommandTree', () => { + it('renders an indented tree rooted at "ktx" with known top-level commands', () => { + const output = renderKtxCommandTree(); + + const lines = output.split('\n'); + expect(lines[0]).toMatch(/^ktx( |$|\s—)/); + + // Top-level commands are indented exactly two spaces. + const topLevel = lines + .filter((line) => /^ {2}\S/.test(line)) + .map((line) => line.trim().split(' ')[0]); + + for (const expected of ['setup', 'serve', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + }); + + it('ends with a single trailing newline', () => { + const output = renderKtxCommandTree(); + expect(output.endsWith('\n')).toBe(true); + expect(output.endsWith('\n\n')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @ktx/cli exec vitest run src/print-command-tree.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the script** + +Create `packages/cli/src/print-command-tree.ts`: + +```typescript +import { fileURLToPath } from 'node:url'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { formatCommandTree, walkCommandTree } from './command-tree.js'; + +function silentIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-docs', contextPackageName: '@ktx/context' }; +} + +export function renderKtxCommandTree(): string { + const program = buildKtxProgram({ + io: silentIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + return formatCommandTree(walkCommandTree(program)); +} + +export function main(stdout: { write(chunk: string): void }): void { + stdout.write(renderKtxCommandTree()); +} + +const invokedAsScript = + typeof process !== 'undefined' && + Array.isArray(process.argv) && + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === process.argv[1]; + +if (invokedAsScript) { + main(process.stdout); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @ktx/cli exec vitest run src/print-command-tree.test.ts` + +Expected: PASS — both assertions green. + +- [ ] **Step 5: Type-check** + +Run: `pnpm --filter @ktx/cli run type-check` + +Expected: no errors. + +- [ ] **Step 6: Build and run the script end-to-end** + +Run: +```bash +pnpm --filter @ktx/cli run build +node packages/cli/dist/print-command-tree.js | head -20 +``` + +Expected: first line begins with `ktx`, followed by indented top-level commands (`setup`, `serve`, `sl`, `dev`, etc.). No errors on stderr. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/print-command-tree.ts packages/cli/src/print-command-tree.test.ts +git commit -m "feat(cli): add print-command-tree build-time script" +``` + +--- + +## Task 5: Wire pnpm script and document + +Expose the script through pnpm so contributors and CI don't need to remember the `node dist/…` path. + +**Files:** +- Modify: `packages/cli/package.json` (add `docs:commands` to `scripts`) + +- [ ] **Step 1: Inspect existing scripts block** + +Run: `node -e "const p=require('./packages/cli/package.json'); console.log(JSON.stringify(p.scripts, null, 2))"` + +Note the current keys (`build`, `smoke`, `test`, `test:slow`, `type-check`, `assets:demo`). Add a new entry that depends on `build`. + +- [ ] **Step 2: Add the `docs:commands` script** + +Edit `packages/cli/package.json`. In the `"scripts"` object, add (after `"build"`): + +```json +"docs:commands": "pnpm run build && node dist/print-command-tree.js", +``` + +Keep alphabetical-ish ordering consistent with the existing block; if other scripts use `&&` chains for build prerequisites, match the style. + +- [ ] **Step 3: Verify the script runs** + +Run: `pnpm --filter @ktx/cli run docs:commands | head -30` + +Expected: builds the CLI, then prints the tree (first line `ktx ...`, two-space-indented children below). + +- [ ] **Step 4: Verify nothing else broke** + +Run in parallel: +- `pnpm --filter @ktx/cli run type-check` +- `pnpm --filter @ktx/cli run test` + +Expected: both PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/package.json +git commit -m "chore(cli): add docs:commands pnpm script" +``` + +--- + +## Verification Summary + +After all tasks, confirm: + +- [ ] `pnpm --filter @ktx/cli run type-check` — clean +- [ ] `pnpm --filter @ktx/cli run test` — green, including new tests in `cli-program.test.ts`, `command-tree.test.ts`, `print-command-tree.test.ts` +- [ ] `pnpm --filter @ktx/cli run docs:commands` — prints `ktx` followed by indented subcommand tree +- [ ] `git status --short` — only the files listed in the File Map are modified or created; no incidental edits + +If any check fails, fix in place and re-run before declaring done. diff --git a/packages/cli/package.json b/packages/cli/package.json index d3a676ea..19bcc64b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,6 +27,7 @@ "scripts": { "assets:demo": "node scripts/build-demo-assets.mjs", "build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node ../../scripts/prepare-cli-bin.mjs", + "docs:commands": "pnpm run build && node dist/print-command-tree.js", "smoke": "vitest run src/standalone-smoke.test.ts src/example-smoke.test.ts --testTimeout 30000", "test": "vitest run --exclude src/standalone-smoke.test.ts --exclude src/example-smoke.test.ts --exclude src/setup-databases.test.ts --exclude src/scan.test.ts --exclude src/commands/connection-metabase-setup.test.ts --exclude src/setup-models.test.ts --exclude src/setup-sources.test.ts --exclude src/setup.test.ts --exclude src/connection.test.ts --exclude src/setup-embeddings.test.ts --exclude src/ingest.test.ts --exclude src/commands/connection-mapping.test.ts --exclude src/ingest-viz.test.ts --exclude src/demo.test.ts --exclude src/setup-project.test.ts --exclude src/sl.test.ts --exclude src/local-scan-connectors.test.ts --exclude src/commands/connection-notion.test.ts", "test:slow": "vitest run src/setup-databases.test.ts src/scan.test.ts src/commands/connection-metabase-setup.test.ts src/setup-models.test.ts src/setup-sources.test.ts src/setup.test.ts src/connection.test.ts src/setup-embeddings.test.ts src/ingest.test.ts src/commands/connection-mapping.test.ts src/ingest-viz.test.ts src/demo.test.ts src/setup-project.test.ts src/sl.test.ts src/local-scan-connectors.test.ts src/commands/connection-notion.test.ts --testTimeout 30000", diff --git a/packages/cli/src/agent-search-readiness.test.ts b/packages/cli/src/agent-search-readiness.test.ts index e7975c7e..cfb2999e 100644 --- a/packages/cli/src/agent-search-readiness.test.ts +++ b/packages/cli/src/agent-search-readiness.test.ts @@ -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 ', 'ktx agent sl list --json --query "gross revenue" --project-dir /tmp/ktx-search', ], diff --git a/packages/cli/src/agent-search-readiness.ts b/packages/cli/src/agent-search-readiness.ts index 263676c1..c3927613 100644 --- a/packages/cli/src/agent-search-readiness.ts +++ b/packages/cli/src/agent-search-readiness.ts @@ -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 ', projectSearchCommand(projectDir, query), ]; diff --git a/packages/cli/src/agent.test.ts b/packages/cli/src/agent.test.ts index d72814bf..2c86598d 100644 --- a/packages/cli/src/agent.test.ts +++ b/packages/cli/src/agent.test.ts @@ -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 ', `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 ', `ktx agent sl list --json --query "revenue" --project-dir ${tempDir}`, ], diff --git a/packages/cli/src/cli-program.test.ts b/packages/cli/src/cli-program.test.ts new file mode 100644 index 00000000..79b0d23a --- /dev/null +++ b/packages/cli/src/cli-program.test.ts @@ -0,0 +1,54 @@ +import type { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; + +function stubIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' }; +} + +describe('buildKtxProgram', () => { + it('returns a Command named "ktx" with all registered top-level subcommands', () => { + const program: Command = buildKtxProgram({ + io: stubIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + + expect(program.name()).toBe('ktx'); + const topLevel = program.commands.map((command) => command.name()).sort(); + for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + }); + + it('does not parse argv or invoke action handlers', () => { + let wrote = ''; + const io: KtxCliIo = { + stdout: { + isTTY: false, + columns: 80, + write: (chunk) => { + wrote += chunk; + }, + }, + stderr: { + write: (chunk) => { + wrote += chunk; + }, + }, + }; + + buildKtxProgram({ io, deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 }); + + expect(wrote).toBe(''); + }); +}); diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 408f3620..e2091bef 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -33,6 +33,14 @@ interface KtxCommanderProgramOptions { runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise; } +export interface BuildKtxProgramOptions { + io: KtxCliIo; + deps: KtxCliDeps; + packageInfo: KtxCliPackageInfo; + runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise; + setExitCode?: (code: number) => void; +} + type CommanderExitLike = { exitCode: number; code: string; message: string }; interface KtxGlobalOptionValues { @@ -288,6 +296,35 @@ async function runBareInteractiveCommand( return 0; } +export function buildKtxProgram(options: BuildKtxProgramOptions): Command { + const program = createBaseProgram(options.packageInfo, options.io); + program.hook('preAction', (_thisCommand, actionCommand) => { + writeProjectDir(options.io, actionCommand as CommandPathNode); + }); + + const context: KtxCliCommandContext = { + io: options.io, + deps: options.deps, + packageInfo: options.packageInfo, + setExitCode: options.setExitCode ?? (() => {}), + runInit: options.runInit, + writeDebug: (command: string, commandContext: CommandWithGlobalOptions) => { + writeDebug(options.io, commandContext, command); + }, + }; + + registerSetupCommands(program, context); + registerConnectionCommands(program, context); + registerPublicIngestCommands(program, context); + registerWikiCommands(program, context); + registerSlCommands(program, context); + registerStatusCommands(program, context); + registerAgentCommands(program, context); + registerDevCommands(program, context); + + return program; +} + export async function runCommanderKtxCli( argv: string[], io: KtxCliIo, @@ -297,11 +334,16 @@ export async function runCommanderKtxCli( ): Promise { profileMark('commander:entry'); let exitCode = 0; - const program = createBaseProgram(info, io); - program.hook('preAction', (_thisCommand, actionCommand) => { - writeProjectDir(io, actionCommand as CommandPathNode); + const program = buildKtxProgram({ + io, + deps, + packageInfo: info, + runInit: options.runInit, + setExitCode: (code: number) => { + exitCode = code; + }, }); - profileMark('commander:base-program'); + profileMark('commander:program-built'); const context: KtxCliCommandContext = { io, deps, @@ -315,30 +357,6 @@ export async function runCommanderKtxCli( }, }; - registerSetupCommands(program, context); - profileMark('commander:register-setup'); - - registerConnectionCommands(program, context); - profileMark('commander:register-connection'); - - registerPublicIngestCommands(program, context); - profileMark('commander:register-public-ingest'); - - registerWikiCommands(program, context); - profileMark('commander:register-wiki'); - - registerSlCommands(program, context); - profileMark('commander:register-sl'); - - registerStatusCommands(program, context); - profileMark('commander:register-status'); - - registerAgentCommands(program, context); - profileMark('commander:register-agent'); - - registerDevCommands(program, context); - profileMark('commander:register-dev'); - if (argv.length === 0) { if (io.stdout.isTTY === true) { try { diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index 93c33893..8b143373 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -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; connectionNotion?: (args: KtxConnectionNotionArgs, io: KtxCliIo) => Promise; connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise; - demo?: (args: KtxDemoArgs, io: KtxCliIo) => Promise; doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise; ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise; publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise; diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/src/command-tree.test.ts new file mode 100644 index 00000000..85fa0e84 --- /dev/null +++ b/packages/cli/src/command-tree.test.ts @@ -0,0 +1,114 @@ +import { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { formatCommandTree, walkCommandTree } from './command-tree.js'; + +describe('walkCommandTree', () => { + it('captures name, description, aliases, and nested children', () => { + const root = new Command('root').description('the root'); + const child = new Command('child').description('a child').alias('c').alias('ch'); + const grandchild = new Command('grand').description('a grandchild'); + child.addCommand(grandchild); + root.addCommand(child); + + const tree = walkCommandTree(root); + + expect(tree).toEqual({ + name: 'root', + description: 'the root', + aliases: [], + arguments: [], + children: [ + { + name: 'child', + description: 'a child', + aliases: ['c', 'ch'], + arguments: [], + children: [{ name: 'grand', description: 'a grandchild', aliases: [], arguments: [], children: [] }], + }, + ], + }); + }); + + it('returns an empty children array when there are no subcommands', () => { + const leaf = new Command('leaf').description('alone'); + expect(walkCommandTree(leaf)).toEqual({ + name: 'leaf', + description: 'alone', + aliases: [], + arguments: [], + children: [], + }); + }); + + it('uses an empty string when description is unset', () => { + const command = new Command('bare'); + expect(walkCommandTree(command).description).toBe(''); + }); + + it('captures required, optional, and variadic arguments', () => { + const command = new Command('scan') + .argument('', 'KTX connection id') + .argument('[schemas...]', 'Schemas'); + + expect(walkCommandTree(command).arguments).toEqual(['', '[schemas...]']); + }); +}); + +describe('formatCommandTree', () => { + it('renders a single node with no children', () => { + const node = { name: 'solo', description: 'just me', aliases: [], arguments: [], children: [] }; + expect(formatCommandTree(node)).toMatch(/^solo\s+just me\n$/); + }); + + it('renders aliases in parentheses before the description', () => { + const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], arguments: [], children: [] }; + expect(formatCommandTree(node)).toMatch(/^cmd \(c, co\)\s+does things\n$/); + }); + + it('renders command arguments after the command name', () => { + const node = { + name: 'test', + description: 'Test a configured connection', + aliases: [], + arguments: [''], + children: [], + }; + expect(formatCommandTree(node)).toMatch(/^test \s+Test a configured connection\n$/); + }); + + it('omits the dash when description is empty', () => { + const node = { name: 'bare', description: '', aliases: [], arguments: [], children: [] }; + expect(formatCommandTree(node)).toBe('bare\n'); + }); + + it('renders tree connectors and preserves sibling registration order', () => { + const tree = { + name: 'root', + description: 'top', + aliases: [], + arguments: [], + children: [ + { + name: 'beta', + description: 'b', + aliases: [], + arguments: [], + children: [{ name: 'leaf', description: 'l', aliases: [], arguments: [], children: [] }], + }, + { + name: 'alpha', + description: 'a', + aliases: ['al'], + arguments: [''], + children: [{ name: 'inner', description: 'i', aliases: [], arguments: [], children: [] }], + }, + ], + }; + const lines = formatCommandTree(tree).trimEnd().split('\n'); + expect(lines[0]).toMatch(/^root\s+top$/); + expect(lines[1]).toMatch(/^ ├── beta\s+b$/); + expect(lines[2]).toMatch(/^ │ └── leaf\s+l$/); + expect(lines[3]).toMatch(/^ └── alpha \(al\)\s+a$/); + expect(lines[4]).toMatch(/^ └── inner\s+i$/); + }); +}); diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts new file mode 100644 index 00000000..2eeb24e8 --- /dev/null +++ b/packages/cli/src/command-tree.ts @@ -0,0 +1,59 @@ +import type { Argument, CommandUnknownOpts } from '@commander-js/extra-typings'; + +const DESCRIPTION_COLUMN = 42; + +export interface CommandTreeNode { + name: string; + description: string; + aliases: string[]; + arguments: string[]; + children: CommandTreeNode[]; +} + +export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode { + return { + name: command.name(), + description: command.description(), + aliases: command.aliases(), + arguments: command.registeredArguments.map(formatArgumentDeclaration), + children: command.commands.map((child) => walkCommandTree(child)), + }; +} + +export function formatCommandTree(node: CommandTreeNode): string { + const lines: string[] = []; + appendNode(node, '', '', lines); + return `${lines.join('\n')}\n`; +} + +function formatArgumentDeclaration(argument: Argument): string { + const name = `${argument.name()}${argument.variadic ? '...' : ''}`; + return argument.required ? `<${name}>` : `[${name}]`; +} + +function appendNode(node: CommandTreeNode, prefix: string, connector: string, lines: string[]): void { + const label = formatLabel(node); + lines.push(formatLine(`${prefix}${connector}${label}`, node.description)); + + const childPrefix = + connector === '' ? `${prefix} ` : `${prefix}${connector === '└── ' ? ' ' : '│ '}`; + node.children.forEach((child, index) => { + const isLast = index === node.children.length - 1; + const childConnector = isLast ? '└── ' : '├── '; + appendNode(child, childPrefix, childConnector, lines); + }); +} + +function formatLabel(node: CommandTreeNode): string { + const argumentPart = node.arguments.length > 0 ? ` ${node.arguments.join(' ')}` : ''; + const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; + return `${node.name}${argumentPart}${aliasPart}`; +} + +function formatLine(label: string, description: string): string { + if (description.length === 0) { + return label; + } + const padding = label.length >= DESCRIPTION_COLUMN ? ' ' : ' '.repeat(DESCRIPTION_COLUMN - label.length); + return `${label}${padding}${description}`; +} diff --git a/packages/cli/src/commands/demo-commands.test.ts b/packages/cli/src/commands/demo-commands.test.ts deleted file mode 100644 index 16e8e088..00000000 --- a/packages/cli/src/commands/demo-commands.test.ts +++ /dev/null @@ -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', - }); - }); -}); diff --git a/packages/cli/src/commands/demo-commands.ts b/packages/cli/src/commands/demo-commands.ts deleted file mode 100644 index fe9dd614..00000000 --- a/packages/cli/src/commands/demo-commands.ts +++ /dev/null @@ -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 }) | undefined; - while (current) { - const source = current.getOptionValueSource?.(key); - if (source !== undefined) { - return source; - } - current = current.parent as (CommandOptionSourceReader & { opts?: () => Record }) | undefined; - } - return undefined; -} - -function definedOptions( - options: Record, - inherited: Record = {}, - command?: CommandOptionSourceReader, -): Record { - 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(command: { opts: () => T; optsWithGlobals?: () => T; parent?: unknown }): T { - const chain: Array<{ opts?: () => Record; parent?: unknown }> = []; - let current = command.parent as { opts?: () => Record; parent?: unknown } | undefined; - while (current) { - chain.unshift(current); - current = current.parent as { opts?: () => Record; parent?: unknown } | undefined; - } - const inherited = Object.assign({}, ...chain.map((parent) => definedOptions(parent.opts?.() ?? {}))); - - if (command.optsWithGlobals) { - const withGlobals = { - ...inherited, - ...definedOptions(command.optsWithGlobals() as Record, inherited, command), - }; - return { - ...withGlobals, - ...definedOptions(command.opts() as Record, withGlobals, command), - } as T; - } - - return { ...inherited, ...definedOptions(command.opts() as Record, inherited, command) } as T; -} - -async function runDemoArgs(context: KtxCliCommandContext, args: KtxDemoArgs): Promise { - 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 ', 'Demo mode: seeded (default), replay, or full') - .choices(['seeded', 'replay', 'full']) - .default('seeded'), - ) - .option('--project-dir ', '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(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 ', '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 ', '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 ', '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 ', '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 ', '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 ', '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 ', 'Demo ingest mode: full or seeded') - .choices(['full', 'seeded']) - .default('full'), - ) - .option('--project-dir ', '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), - }); - }); -} diff --git a/packages/cli/src/commands/doctor-commands.ts b/packages/cli/src/commands/doctor-commands.ts index 280f9d5a..a7127e48 100644 --- a/packages/cli/src/commands/doctor-commands.ts +++ b/packages/cli/src/commands/doctor-commands.ts @@ -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) => { diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 0efada1f..90251ae1 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -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, - }); - }); } diff --git a/packages/cli/src/context-build-view.test.ts b/packages/cli/src/context-build-view.test.ts index e0132d33..647357a7 100644 --- a/packages/cli/src/context-build-view.test.ts +++ b/packages/cli/src/context-build-view.test.ts @@ -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(); }); diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index 6b706ae7..7457f9b5 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -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); }, diff --git a/packages/cli/src/demo-assets.test.ts b/packages/cli/src/demo-assets.test.ts index 7ef89296..575e9bb7 100644 --- a/packages/cli/src/demo-assets.test.ts +++ b/packages/cli/src/demo-assets.test.ts @@ -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 ', - '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(); }); }); diff --git a/packages/cli/src/demo-assets.ts b/packages/cli/src/demo-assets.ts index 6754164a..4bab5ead 100644 --- a/packages/cli/src/demo-assets.ts +++ b/packages/cli/src/demo-assets.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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(); -} diff --git a/packages/cli/src/demo-full.test.ts b/packages/cli/src/demo-full.test.ts deleted file mode 100644 index 7d3887e8..00000000 --- a/packages/cli/src/demo-full.test.ts +++ /dev/null @@ -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 => { - 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'); - }); -}); diff --git a/packages/cli/src/demo-full.ts b/packages/cli/src/demo-full.ts deleted file mode 100644 index 6b483d73..00000000 --- a/packages/cli/src/demo-full.ts +++ /dev/null @@ -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 { - 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 { - 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'); -} diff --git a/packages/cli/src/demo-interaction.test.ts b/packages/cli/src/demo-interaction.test.ts deleted file mode 100644 index 4ab89adc..00000000 --- a/packages/cli/src/demo-interaction.test.ts +++ /dev/null @@ -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: {} }); - }); -}); diff --git a/packages/cli/src/demo-interaction.ts b/packages/cli/src/demo-interaction.ts deleted file mode 100644 index 7702498c..00000000 --- a/packages/cli/src/demo-interaction.ts +++ /dev/null @@ -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 = ClackOption; - -export interface DemoPromptAdapter { - select(options: { message: string; options: Array> }): Promise; - confirm(options: { message: string; initialValue?: boolean }): Promise; - password(options: { message: string }): Promise; - text(options: { message: string; placeholder?: string }): Promise; - 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(value: T | symbol, prompts: Pick): T { - if (isCancel(value)) { - prompts.cancel('Demo cancelled.'); - throw new Error('Demo cancelled.'); - } - return value as T; -} - -export function createClackDemoPromptAdapter(): DemoPromptAdapter { - return { - async select(options: { message: string; options: Array> }): Promise { - return ensureNotCancelled(await select(withMenuOptionsSpacing(options)), this); - }, - async confirm(options: { message: string; initialValue?: boolean }): Promise { - return ensureNotCancelled(await confirm(options), this); - }, - async password(options: { message: string }): Promise { - return ensureNotCancelled(await password(options), this); - }, - async text(options: { message: string; placeholder?: string }): Promise { - 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(): Promise { - return choices.shift() as T; - }, - async confirm(): Promise { - return confirms.shift() ?? false; - }, - async password(): Promise { - return passwords.shift() ?? ''; - }, - async text(): Promise { - return texts.shift() ?? ''; - }, - cancel(): void { - return; - }, - }; -} - -export async function chooseDemoProjectForInteractiveRun(options: { - projectDir: string; - inputMode?: KtxDemoInputMode; - io: DemoInteractiveIo; - prompts?: DemoPromptAdapter; -}): Promise { - 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 { - 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 } }; -} diff --git a/packages/cli/src/demo-progress.test.ts b/packages/cli/src/demo-progress.test.ts deleted file mode 100644 index a7605990..00000000 --- a/packages/cli/src/demo-progress.test.ts +++ /dev/null @@ -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']); - }); -}); diff --git a/packages/cli/src/demo-progress.ts b/packages/cli/src/demo-progress.ts deleted file mode 100644 index 82d01163..00000000 --- a/packages/cli/src/demo-progress.ts +++ /dev/null @@ -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`); - } - } - }; -} diff --git a/packages/cli/src/demo-replay-store.test.ts b/packages/cli/src/demo-replay-store.test.ts deleted file mode 100644 index 4ec9e236..00000000 --- a/packages/cli/src/demo-replay-store.test.ts +++ /dev/null @@ -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 { - 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(); - }); -}); diff --git a/packages/cli/src/demo-replay-store.ts b/packages/cli/src/demo-replay-store.ts deleted file mode 100644 index 46e067bd..00000000 --- a/packages/cli/src/demo-replay-store.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 }; -} diff --git a/packages/cli/src/demo-scan.test.ts b/packages/cli/src/demo-scan.test.ts deleted file mode 100644 index 3b7a8e43..00000000 --- a/packages/cli/src/demo-scan.test.ts +++ /dev/null @@ -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' }); - }); -}); diff --git a/packages/cli/src/demo-scan.ts b/packages/cli/src/demo-scan.ts deleted file mode 100644 index 5dd4b182..00000000 --- a/packages/cli/src/demo-scan.ts +++ /dev/null @@ -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; -} - -async function ensureDemoProjectForReuse(projectDir: string): Promise { - 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 { - 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 { - const { files } = await project.fileStore.listFiles(root, true); - return files.filter(predicate).length; -} - -async function findFullDemoReport(project: KtxLocalProject): Promise { - 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 { - 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 { - 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 { - 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'); -} diff --git a/packages/cli/src/demo-seeded-inspect.test.ts b/packages/cli/src/demo-seeded-inspect.test.ts deleted file mode 100644 index a2aa3c16..00000000 --- a/packages/cli/src/demo-seeded-inspect.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/cli/src/demo-seeded-inspect.ts b/packages/cli/src/demo-seeded-inspect.ts deleted file mode 100644 index 929d8702..00000000 --- a/packages/cli/src/demo-seeded-inspect.ts +++ /dev/null @@ -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 }; - 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; - 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 { - try { - await access(path, fsConstants.F_OK); - return true; - } catch { - return false; - } -} - -async function loadSeededManifest(projectDir: string): Promise { - const raw = await readFile(join(projectDir, 'manifest.json'), 'utf-8'); - return JSON.parse(raw) as DemoSeededManifest; -} - -async function listFilesInDir(dir: string, ext?: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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'); -} diff --git a/packages/cli/src/demo-seeded.test.ts b/packages/cli/src/demo-seeded.test.ts deleted file mode 100644 index 95bf0a5a..00000000 --- a/packages/cli/src/demo-seeded.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/cli/src/demo-seeded.ts b/packages/cli/src/demo-seeded.ts deleted file mode 100644 index 58275548..00000000 --- a/packages/cli/src/demo-seeded.ts +++ /dev/null @@ -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 { - 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, - }; -} diff --git a/packages/cli/src/demo.test.ts b/packages/cli/src/demo.test.ts deleted file mode 100644 index 97d37f4e..00000000 --- a/packages/cli/src/demo.test.ts +++ /dev/null @@ -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(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(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'); - }); -}); diff --git a/packages/cli/src/demo.ts b/packages/cli/src/demo.ts deleted file mode 100644 index ef2b1ba6..00000000 --- a/packages/cli/src/demo.ts +++ /dev/null @@ -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; 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; - 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 { - 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>, - 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>, - 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>): 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 { - 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 { - 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 { - 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 { - 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 { - 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; - } -} diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 915c7cef..4203369c 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -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 { - 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; diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index f3dd3131..6a968fa9 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -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 () => { diff --git a/packages/cli/src/next-steps.test.ts b/packages/cli/src/next-steps.test.ts index ff6b8695..fd8d8216 100644 --- a/packages/cli/src/next-steps.test.ts +++ b/packages/cli/src/next-steps.test.ts @@ -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'); }); diff --git a/packages/cli/src/next-steps.ts b/packages/cli/src/next-steps.ts index c219c9e0..db85da66 100644 --- a/packages/cli/src/next-steps.ts +++ b/packages/cli/src/next-steps.ts @@ -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 = [ diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/src/print-command-tree.test.ts new file mode 100644 index 00000000..c50ee9a3 --- /dev/null +++ b/packages/cli/src/print-command-tree.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { renderKtxCommandTree } from './print-command-tree.js'; + +describe('renderKtxCommandTree', () => { + it('renders an indented tree rooted at "ktx" with known top-level commands', () => { + const output = renderKtxCommandTree(); + + const lines = output.split('\n'); + expect(lines[0]).toMatch(/^ktx( |$)/); + + const topLevel = lines + .filter((line) => /^ {2}[├└]── \S/.test(line)) + .map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]); + + for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) { + expect(topLevel).toContain(expected); + } + + expect(output).toContain('│ ├── test '); + }); + + it('ends with a single trailing newline', () => { + const output = renderKtxCommandTree(); + expect(output.endsWith('\n')).toBe(true); + expect(output.endsWith('\n\n')).toBe(false); + }); +}); diff --git a/packages/cli/src/print-command-tree.ts b/packages/cli/src/print-command-tree.ts new file mode 100644 index 00000000..2ede889c --- /dev/null +++ b/packages/cli/src/print-command-tree.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from 'node:url'; +import { buildKtxProgram } from './cli-program.js'; +import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; +import { formatCommandTree, walkCommandTree } from './command-tree.js'; + +function silentIo(): KtxCliIo { + return { + stdout: { isTTY: false, columns: 80, write: () => {} }, + stderr: { write: () => {} }, + }; +} + +function stubPackageInfo(): KtxCliPackageInfo { + return { name: '@ktx/cli', version: '0.0.0-docs', contextPackageName: '@ktx/context' }; +} + +export function renderKtxCommandTree(): string { + const program = buildKtxProgram({ + io: silentIo(), + deps: {}, + packageInfo: stubPackageInfo(), + runInit: async () => 0, + }); + return formatCommandTree(walkCommandTree(program)); +} + +export function main(stdout: { write(chunk: string): void }): void { + stdout.write(renderKtxCommandTree()); +} + +const invokedAsScript = + typeof process !== 'undefined' && + Array.isArray(process.argv) && + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === process.argv[1]; + +if (invokedAsScript) { + main(process.stdout); +} diff --git a/packages/cli/src/project-dir.test.ts b/packages/cli/src/project-dir.test.ts index 53ed4aef..6e3ca901 100644 --- a/packages/cli/src/project-dir.test.ts +++ b/packages/cli/src/project-dir.test.ts @@ -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', }, { diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/src/setup-context.test.ts index 89de6a91..7012edb6 100644 --- a/packages/cli/src/setup-context.test.ts +++ b/packages/cli/src/setup-context.test.ts @@ -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(); - }); }); diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 00928706..2095a038 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -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; @@ -154,12 +154,11 @@ async function pathExists(path: string): Promise { 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 { 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, + args: KtxSetupContextWatchArgs, initialState: KtxSetupContextState, io: KtxCliIo, deps: KtxSetupContextDeps, @@ -862,7 +855,7 @@ async function watchContextStatus( } async function watchContextStatusText( - args: Extract, + args: KtxSetupContextWatchArgs, initialState: KtxSetupContextState, io: KtxCliIo, deps: KtxSetupContextDeps, @@ -894,7 +887,7 @@ async function watchContextStatusText( } async function watchContextStatusWithProgressView( - args: Extract, + 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 { - 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; -} diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts index b05f4080..f3d71f70 100644 --- a/packages/cli/src/setup-demo-tour.ts +++ b/packages/cli/src/setup-demo-tour.ts @@ -39,7 +39,7 @@ function createDemoTarget( driver, operation, ...(adapter ? { adapter } : {}), - debugCommand: `ktx setup context build --target ${connectionId}`, + debugCommand: `ktx setup --project-dir `, steps: operation === 'scan' ? ['scan', 'enrich', 'memory-update'] : ['source-ingest', 'enrich', 'memory-update'], diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index 22d070f1..0435aad3 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -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 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 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 })); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index e7e237ad..893297f9 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -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[0], io: KtxCliIo) => Promise; 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 setup status', - 'Or from that folder: ktx setup status', + 'Check another project: ktx --project-dir 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 { - 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>; diff --git a/packages/cli/src/standalone-smoke.test.ts b/packages/cli/src/standalone-smoke.test.ts index 72d96791..a712e373 100644 --- a/packages/cli/src/standalone-smoke.test.ts +++ b/packages/cli/src/standalone-smoke.test.ts @@ -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 ', `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); diff --git a/scripts/package-artifacts.mjs b/scripts/package-artifacts.mjs index e3b02714..07b9aaa7 100644 --- a/scripts/package-artifacts.mjs +++ b/scripts/package-artifacts.mjs @@ -619,8 +619,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 ', 'ktx agent sl list --json --query "revenue" --project-dir ' + missingProjectDir, ]); @@ -676,8 +676,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 ', 'ktx agent sl list --json --query "revenue" --project-dir ' + emptyProjectDir, ]); @@ -767,8 +767,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 ', 'ktx agent sl list --json --query "revenue" --project-dir ' + projectDir, ]); @@ -1005,7 +1005,7 @@ try { `; } -export function npmDemoSmokeSource() { +export function npmCliSmokeSource() { return ` import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; @@ -1047,18 +1047,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']); @@ -1067,61 +1057,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'); @@ -1176,29 +1115,29 @@ async function verifyNpmArtifacts(layout, tmpRoot) { await writeFile(join(projectDir, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml()); 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, 'pnpm-workspace.yaml'), npmSmokePnpmWorkspaceYaml()); - 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) { @@ -1212,12 +1151,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 }); } @@ -1236,7 +1175,7 @@ async function main() { return; } if (command === 'verify-demo') { - await verifyDemoArtifacts(layout); + await verifyCliArtifacts(layout); return; } if (command === 'verify-manifest') { diff --git a/scripts/package-artifacts.test.mjs b/scripts/package-artifacts.test.mjs index 6f7a3582..4d498e05 100644 --- a/scripts/package-artifacts.test.mjs +++ b/scripts/package-artifacts.test.mjs @@ -16,7 +16,7 @@ import { copyRuntimeWheelAssets, findPythonArtifacts, NPM_ARTIFACT_PACKAGES, - npmDemoSmokeSource, + npmCliSmokeSource, npmRuntimeSmokeSource, npmSmokePackageJson, npmSmokePnpmWorkspaceYaml, @@ -387,9 +387,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'\]/); @@ -513,21 +513,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'/); }); diff --git a/scripts/published-package-smoke-config.mjs b/scripts/published-package-smoke-config.mjs index 6aea8688..6330850a 100644 --- a/scripts/published-package-smoke-config.mjs +++ b/scripts/published-package-smoke-config.mjs @@ -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, }, ]; } diff --git a/scripts/published-package-smoke.mjs b/scripts/published-package-smoke.mjs index af1795bd..e304a7af 100644 --- a/scripts/published-package-smoke.mjs +++ b/scripts/published-package-smoke.mjs @@ -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'); diff --git a/scripts/published-package-smoke.test.mjs b/scripts/published-package-smoke.test.mjs index 6852e237..56bbe424 100644 --- a/scripts/published-package-smoke.test.mjs +++ b/scripts/published-package-smoke.test.mjs @@ -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/' }, }, ], );