fix(cli): remove legacy ingest and wiki commands

This commit is contained in:
Andrey Avtomonov 2026-05-13 22:42:07 +02:00
parent 011d694ed3
commit 75e04cfa56
41 changed files with 328 additions and 851 deletions

View file

@ -1,6 +1,6 @@
---
title: "ktx ingest"
description: "Build, inspect, and replay KTX context ingest runs."
description: "Build or refresh KTX context from configured connections."
---
`ktx ingest` builds or refreshes KTX context from configured connections.
@ -36,16 +36,6 @@ connections when you use `--all`.
database connections. Query-history flags apply only to database connections
that support query history.
## Status and replay
| Subcommand | Description |
|------------|-------------|
| `status [runId]` | Print status for the latest or selected stored ingest run or report file |
| `replay <runId>` | Replay a stored ingest run or bundle report through memory-flow output |
Both subcommands accept `--report-file <path>`, `--plain`, `--json`, `--viz`,
and `--no-input`.
## Examples
```bash
@ -57,14 +47,6 @@ ktx ingest warehouse --query-history-window-days 30
ktx ingest notion
ktx ingest --all
ktx ingest --all --deep
ktx ingest status
ktx ingest status run-abc123
ktx ingest status --json
ktx ingest replay run-abc123
ktx ingest replay run-abc123 --viz
ktx ingest replay run-abc123 --report-file /tmp/ingest-report.json
```
## Common errors
@ -74,5 +56,4 @@ ktx ingest replay run-abc123 --report-file /tmp/ingest-report.json
| Connection not configured | The connection id is not present in `ktx.yaml` | Add the connection with `ktx setup` or update `ktx.yaml` |
| Deep readiness is missing | `--deep` or query history needs model, embedding, and scan-enrichment configuration | Run `ktx setup` or rerun with `--fast` |
| Query history is unsupported | The selected database driver does not support query history | Run schema ingest without query-history flags |
| Latest run not found | No stored ingest report exists in this project | Run `ktx ingest <connectionId>` first |
| Visual replay fails in a non-interactive shell | Visual report replay needs a terminal | Use `ktx ingest status --json` for agent and CI workflows |
| No ingest target was selected | No connection id was provided and `--all` was omitted | Run `ktx ingest <connectionId>` or `ktx ingest --all` |

View file

@ -1,6 +1,6 @@
---
title: "ktx wiki"
description: "List, read, search, or write wiki pages."
description: "List or search wiki pages."
---
Manage wiki pages in your KTX project. Wiki pages are Markdown documents that capture business definitions, rules, and gotchas. Agents search them for context when answering questions about your data.
@ -16,9 +16,7 @@ ktx wiki <subcommand> [options]
| Subcommand | Description |
|-----------|-------------|
| `list` | List local wiki pages |
| `read <key>` | Read one local wiki page |
| `search <query>` | Search local wiki pages |
| `write <key>` | Write one local wiki page |
## Options
@ -29,13 +27,6 @@ ktx wiki <subcommand> [options]
| `--json` | Print JSON output | `false` |
| `--user-id <id>` | Local user id | `local` |
### `wiki read`
| Flag | Description | Default |
|------|-------------|---------|
| `--json` | Print JSON output | `false` |
| `--user-id <id>` | Local user id | `local` |
### `wiki search`
| Flag | Description | Default |
@ -44,18 +35,6 @@ ktx wiki <subcommand> [options]
| `--user-id <id>` | Local user id | `local` |
| `--limit <number>` | Maximum search results | — |
### `wiki write`
| Flag | Description | Default |
|------|-------------|---------|
| `--user-id <id>` | Local user id | `local` |
| `--scope <scope>` | Scope: `global` or `user` | `global` |
| `--summary <summary>` | Wiki page summary (required) | — |
| `--content <content>` | Wiki page content (required) | — |
| `--tag <tag>` | Wiki tag; repeatable | — |
| `--ref <ref>` | Wiki ref; repeatable | — |
| `--sl-ref <ref>` | Semantic-layer ref; repeatable | — |
## Examples
```bash
@ -65,48 +44,17 @@ ktx wiki list
# List all wiki pages as JSON
ktx wiki list --json
# Read a specific wiki page
ktx wiki read revenue-definitions
# Read a specific wiki page as JSON
ktx wiki read revenue-definitions --json
# Search wiki pages
ktx wiki search "monthly recurring revenue"
# Search wiki pages as JSON
ktx wiki search "monthly recurring revenue" --json --limit 10
# Write a global wiki page
ktx wiki write revenue-definitions \
--summary "Canonical revenue metric definitions" \
--content "## MRR\nMonthly Recurring Revenue is calculated as..."
# Write a user-scoped wiki page
ktx wiki write my-notes \
--scope user \
--summary "Personal analysis notes" \
--content "Things to check when revenue numbers look off..."
# Write a page with tags and references
ktx wiki write churn-rules \
--summary "Churn calculation business rules" \
--content "A customer is considered churned when..." \
--tag finance \
--tag retention \
--sl-ref customers \
--sl-ref subscriptions
# Write a page with external references
ktx wiki write data-freshness \
--summary "Data pipeline SLAs and freshness guarantees" \
--content "The orders table refreshes every 15 minutes..." \
--ref "https://wiki.example.com/data-pipelines"
```
## Output
Wiki commands print local wiki pages and search results. Agents should search first, then read the most relevant page by key.
Wiki commands print local wiki page listings and search results. Open the
matching Markdown files directly when you need the full page contents.
```json
{
@ -128,6 +76,4 @@ Wiki commands print local wiki pages and search results. Agents should search fi
| Error | Cause | Recovery |
|-------|-------|----------|
| Search returns no results | The query terms do not match summaries, tags, or content | Retry with business synonyms, then create a page if the knowledge is missing |
| Read fails for a key | The page key is wrong or scoped to a different user | Run `ktx wiki list` or search again to get the exact key |
| Write fails due to missing fields | `--summary` or `--content` was omitted | Pass both fields, and keep the summary short enough for search results |
| Agent writes duplicate pages | It did not search existing pages first | Always run `ktx wiki search` before `ktx wiki write` |
| A page is missing | No Markdown file exists for that business context | Add a file under `wiki/` or run `ktx ingest <connectionId>` |

View file

@ -211,8 +211,8 @@ KTX writes project state as plain files so agents can inspect and edit changes i
| `ktx.yaml` | `ktx setup` | Main project configuration: connections, LLM settings, embeddings, and context sources |
| `.ktx/secrets/*` | `ktx setup` when file-backed secrets are selected | Local secret files referenced from `ktx.yaml`; do not commit these |
| `semantic-layer/<connection-id>/*.yaml` | context build, ingestion, or direct file edits | Semantic source definitions agents use for SQL generation |
| `wiki/global/*.md` | ingestion, memory capture, `ktx wiki write --scope global`, or direct file edits | Shared business context and metric definitions |
| `wiki/user/<user-id>/*.md` | memory capture, `ktx wiki write --scope user`, or direct file edits | User-scoped notes for one agent/user context |
| `wiki/global/*.md` | ingestion, memory capture, or direct file edits | Shared business context and metric definitions |
| `wiki/user/<user-id>/*.md` | memory capture or direct file edits | User-scoped notes for one agent/user context |
| `.claude/skills/ktx/SKILL.md`, `.agents/skills/ktx/SKILL.md` | CLI-mode agent integration setup | Agent instructions for calling public `ktx` commands |
## Verify it worked

View file

@ -87,21 +87,7 @@ Useful output flags:
| `--json` | Output as JSON |
| `--plain` | Plain text output |
### Inspecting stored reports
```bash
# Check status of the latest ingest
ktx ingest status
# Check a specific run
ktx ingest status <run-id>
# Replay a past ingest run
ktx ingest replay <run-id>
```
`ktx ingest replay` opens the stored memory-flow output for a completed run.
Foreground context builds do not detach into background control sessions; if a
Foreground context builds do not detach into background control sessions. If a
run is interrupted, rerun `ktx ingest <connection-id>` or `ktx ingest --all`.
### Supported context sources
@ -178,12 +164,8 @@ sl_refs: [orders]
Orders in "pending" status for more than 48 hours are flagged for review.
```
### Deterministic replay
### Ingest transcripts
Every ingest session records a full transcript — tool calls, LLM responses, and write decisions. You can replay any session to debug why a source was written a certain way:
```bash
ktx ingest replay <run-id> --viz
```
This opens the same TUI view as the original run, letting you step through the agent's reasoning.
Every ingest session records a full transcript: tool calls, LLM responses, and
write decisions. Inspect the stored transcript files when you need to debug why
a source was written a certain way.

View file

@ -248,8 +248,7 @@ wiki/
### Editing pages
Create and edit wiki pages directly as Markdown files in the `wiki/`
directory, or with `ktx wiki write`. Ingest and memory capture also create
these pages automatically.
directory. Ingest and memory capture also create these pages automatically.
Wiki page fields:

View file

@ -125,8 +125,6 @@ All supported agent clients call the same KTX CLI commands:
|---------|-------------|
| `ktx status --json` | Return project setup and context readiness |
| `ktx wiki search <query> --json` | Search wiki pages |
| `ktx wiki read <key> --json` | Read a wiki page |
| `ktx wiki write <key>` | Write or update a wiki page |
| `ktx sl list --json` | List semantic-layer sources |
| `ktx sl search <query> --json` | Search semantic-layer sources |
| `ktx sl validate <source> --connection-id <id>` | Validate semantic source definitions |

View file

@ -15,7 +15,7 @@ Agents must configure and ingest context sources in this order:
2. Store tokens as `env:NAME` or `file:/path/to/secret`.
3. Run `ktx ingest <connectionId>` for one source or `ktx ingest --all` for
every configured source.
4. Check progress with `ktx ingest status --json`.
4. Review the foreground ingest output.
5. Review generated `semantic-layer/` YAML and `wiki/` Markdown files in git.
6. Validate changed semantic sources with `ktx sl validate`.

View file

@ -53,7 +53,27 @@ type CommandPathNode = CommandWithGlobalOptions & {
};
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
const REMOVED_ROOT_COMMANDS = new Set(['scan']);
const REMOVED_COMMAND_PATHS = new Set([
'scan',
'ingest run',
'ingest status',
'ingest watch',
'ingest replay',
'wiki read',
'wiki write',
]);
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
const OPTIONS_WITH_VALUE = new Set([
'--project-dir',
'--query-history-window-days',
'--user-id',
'--limit',
'--format',
'--connection-id',
'--source-name',
'--query-file',
'--max-rows',
]);
export interface CommandWithGlobalOptions {
opts: () => object;
@ -175,9 +195,6 @@ function shouldSuppressProjectDirLine(path: string[], options: Record<string, un
return true;
}
if (commandPathKey === 'ktx ingest watch') {
return options.json !== true && options.plain !== true;
}
const demoIndex = path.indexOf('demo');
if (demoIndex >= 0) {
const demoCommand = path[demoIndex + 1];
@ -222,10 +239,6 @@ function createBaseProgram(info: KtxCliPackageInfo, io: KtxCliIo): Command {
.version(`${info.name} ${info.version}`, '-v, --version', 'Show CLI version')
.helpOption('-h, --help', 'Show this help text')
.configureHelp({ showGlobalOptions: true })
.addHelpText(
'after',
'\nAdvanced:\n ktx dev Low-level project initialization and runtime management.\n',
)
.showHelpAfterError()
.exitOverride()
.configureOutput({
@ -255,6 +268,45 @@ function formatCliError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function commandPathFromArgv(argv: string[]): string[] {
const path: string[] = [];
for (let index = 0; index < argv.length && path.length < 2; index += 1) {
const arg = argv[index];
if (arg === undefined) {
continue;
}
if (arg === '--') {
break;
}
if ((path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE).has(arg)) {
index += 1;
continue;
}
const optionsWithValue = path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE;
if ([...optionsWithValue].some((option) => arg.startsWith(`${option}=`))) {
continue;
}
if (path.length === 0 && arg === '--debug') {
continue;
}
if (arg.startsWith('-')) {
continue;
}
path.push(arg);
}
return path;
}
function removedCommandName(argv: string[]): string | null {
const path = commandPathFromArgv(argv);
if (path.length === 0) {
return null;
}
const pathKey = path.join(' ');
return REMOVED_COMMAND_PATHS.has(pathKey) ? path.at(-1) ?? null : null;
}
async function runBareInteractiveCommand(
program: Command,
io: KtxCliIo,
@ -309,10 +361,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
registerSetupCommands(program, context);
registerConnectionCommands(program, context);
registerIngestCommands(program, context, {
runIngestWithProgress: async (ingestArgs, ingestIo, ingestDeps, defaultRunIngest) =>
await (ingestDeps.ingest ?? defaultRunIngest)(ingestArgs, ingestIo),
});
registerIngestCommands(program, context);
registerWikiCommands(program, context);
registerSlCommands(program, context);
registerStatusCommands(program, context);
@ -366,8 +415,9 @@ export async function runCommanderKtxCli(
return 0;
}
if (REMOVED_ROOT_COMMANDS.has(argv[0] ?? '')) {
io.stderr.write(`error: unknown command '${argv[0]}'\n`);
const removedCommand = removedCommandName(argv);
if (removedCommand) {
io.stderr.write(`error: unknown command '${removedCommand}'\n`);
return 1;
}

View file

@ -2,7 +2,6 @@ import { createRequire } from 'node:module';
import type { KtxConnectionArgs } from './connection.js';
import type { KtxDoctorArgs } from './doctor.js';
import type { KtxIngestArgs } from './ingest.js';
import type { KtxKnowledgeArgs } from './knowledge.js';
import type { KtxPublicIngestArgs } from './public-ingest.js';
import type { KtxRuntimeArgs } from './runtime.js';
@ -29,7 +28,6 @@ export interface KtxCliDeps {
setup?: (args: KtxSetupArgs, io: KtxCliIo) => Promise<number>;
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise<number>;
ingest?: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>;
publicIngest?: (args: KtxPublicIngestArgs, io: KtxCliIo) => Promise<number>;
runtime?: (args: KtxRuntimeArgs, io: KtxCliIo) => Promise<number>;
knowledge?: (args: KtxKnowledgeArgs, io: KtxCliIo) => Promise<number>;

View file

@ -3,19 +3,6 @@ import { z } from 'zod';
const projectDirSchema = z.string().min(1);
const stringArraySchema = z.array(z.string());
export const wikiWriteCommandSchema = z.object({
command: z.literal('write'),
projectDir: projectDirSchema,
key: z.string().min(1),
scope: z.enum(['GLOBAL', 'USER']),
userId: z.string().min(1),
summary: z.string().min(1),
content: z.string().min(1),
tags: stringArraySchema,
refs: stringArraySchema,
slRefs: stringArraySchema,
});
const orderBySchema = z.union([
z.string().min(1),
z.object({

View file

@ -53,7 +53,7 @@ describe('walkCommandTree', () => {
expect(walkCommandTree(command).arguments).toEqual(['<connectionId>', '[schemas...]']);
});
it('omits Commander hidden commands from the public tree', () => {
it('walks registered commands without applying hidden-command policy', () => {
const root = new Command('ktx');
root.command('scan', { hidden: true }).description('Run a standalone connection scan');
const ingest = root.command('ingest').description('Build or inspect KTX context');
@ -64,10 +64,19 @@ describe('walkCommandTree', () => {
const tree = walkCommandTree(root);
expect(tree.children.map((child) => child.name)).toEqual(['ingest', 'status']);
expect(tree.children.map((child) => child.name)).toEqual(['scan', 'ingest', 'status']);
expect(tree.children[0]).toMatchObject({
name: 'scan',
description: 'Run a standalone connection scan',
children: [],
});
expect(tree.children[1]).toMatchObject({
name: 'ingest',
children: [{ name: 'status', description: 'Print status', aliases: [], arguments: [], children: [] }],
children: [
{ name: 'run', description: 'Run local ingest by adapter', aliases: [], arguments: [], children: [] },
{ name: 'watch', description: 'Open a stored visual report', aliases: [], arguments: [], children: [] },
{ name: 'status', description: 'Print status', aliases: [], arguments: [], children: [] },
],
});
});
});

View file

@ -10,17 +10,13 @@ export interface CommandTreeNode {
children: CommandTreeNode[];
}
function isHiddenCommand(command: CommandUnknownOpts): boolean {
return (command as CommandUnknownOpts & { _hidden?: boolean })._hidden === true;
}
export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode {
return {
name: command.name(),
description: command.description(),
aliases: command.aliases(),
arguments: command.registeredArguments.map(formatArgumentDeclaration),
children: command.commands.filter((child) => !isHiddenCommand(child)).map((child) => walkCommandTree(child)),
children: command.commands.map((child) => walkCommandTree(child)),
};
}

View file

@ -1,81 +1,15 @@
import { resolve } from 'node:path';
import { type Command, Option } from '@commander-js/extra-typings';
import {
type KtxCliCommandContext,
type OutputModeOptions,
parsePositiveIntegerOption,
resolveCommandProjectDir,
} from '../cli-program.js';
import type { KtxCliDeps, KtxCliIo } from '../index.js';
import type { KtxIngestArgs, KtxIngestOutputMode } from '../ingest.js';
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
import type { KtxPublicIngestArgs } from '../public-ingest.js';
import { profileMark } from '../startup-profile.js';
profileMark('module:commands/ingest-commands');
interface IngestCommandOptions {
runIngestWithProgress: (
args: KtxIngestArgs,
io: KtxCliIo,
deps: KtxCliDeps,
defaultRunIngest: (args: KtxIngestArgs, io: KtxCliIo) => Promise<number>,
) => Promise<number>;
}
function outputMode(options: OutputModeOptions): KtxIngestOutputMode {
if (options.json === true) {
return 'json';
}
if (options.viz === true) {
return 'viz';
}
return 'plain';
}
function watchOutputMode(options: OutputModeOptions): KtxIngestOutputMode {
if (options.json === true) {
return 'json';
}
if (options.plain === true) {
return 'plain';
}
return 'viz';
}
function inputMode(options: OutputModeOptions): Pick<KtxIngestArgs, 'inputMode'> {
return options.input === false ? { inputMode: 'disabled' } : {};
}
function resolvedOptions<T extends object>(command: { optsWithGlobals?: () => object }, fallback: T): T {
return (command.optsWithGlobals ? command.optsWithGlobals() : fallback) as T;
}
function assertOutputModeCompatible(options: OutputModeOptions): void {
const requested = [
options.plain === true ? '--plain' : undefined,
options.json === true ? '--json' : undefined,
options.viz === true ? '--viz' : undefined,
].filter((option): option is string => option !== undefined);
if (requested.length > 1) {
throw new Error(`Output mode options cannot be used together: ${requested.join(', ')}`);
}
}
async function runIngestArgs(
context: KtxCliCommandContext,
args: KtxIngestArgs,
options: IngestCommandOptions,
): Promise<void> {
const { runKtxIngest } = await import('../ingest.js');
context.setExitCode(await options.runIngestWithProgress(args, context.io, context.deps, runKtxIngest));
}
export function registerIngestCommands(
program: Command,
context: KtxCliCommandContext,
commandOptions: IngestCommandOptions,
): void {
export function registerIngestCommands(program: Command, context: KtxCliCommandContext): void {
const ingest = program
.command('ingest')
.description('Build or inspect KTX context')
@ -114,123 +48,4 @@ export function registerIngestCommands(
ingest.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.('ingest', actionCommand);
});
ingest
.command('run', { hidden: true })
.description('Run local ingest for one configured connection and source adapter')
.requiredOption('--connection-id <connectionId>', 'KTX connection id')
.requiredOption('--adapter <adapter>', 'Ingest source adapter name')
.option('--source-dir <path>', 'Directory containing source files')
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
.option('--debug-llm-request-file <path>', 'Write sanitized LLM request structure to a JSONL file')
.option('--report-file <path>', 'Unsupported for ingest run; use ingest status/watch instead')
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
.option('--no-input', 'Disable interactive terminal input for visualization')
.action(async (options, command) => {
const commandOptionsWithGlobals = resolvedOptions(command, options);
assertOutputModeCompatible(commandOptionsWithGlobals);
if (options.reportFile) {
throw new Error('--report-file is only supported for ingest status/watch');
}
await runIngestArgs(
context,
{
command: 'run',
projectDir: resolveCommandProjectDir(command),
connectionId: commandOptionsWithGlobals.connectionId,
adapter: commandOptionsWithGlobals.adapter,
sourceDir: commandOptionsWithGlobals.sourceDir ? resolve(commandOptionsWithGlobals.sourceDir) : undefined,
databaseIntrospectionUrl: commandOptionsWithGlobals.databaseIntrospectionUrl || undefined,
cliVersion: context.packageInfo.version,
runtimeInstallPolicy: runtimeInstallPolicyFromFlags({ yes: commandOptionsWithGlobals.yes }),
...(commandOptionsWithGlobals.debugLlmRequestFile
? { debugLlmRequestFile: resolve(commandOptionsWithGlobals.debugLlmRequestFile) }
: {}),
outputMode: outputMode(commandOptionsWithGlobals),
...inputMode(commandOptionsWithGlobals),
},
commandOptions,
);
});
ingest
.command('status')
.description('Print status for the latest or selected stored ingest report')
.argument('[runId]', 'Local ingest id, report id, run id, or job id')
.option('--report-file <path>', 'Bundle ingest report JSON file to render')
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
.option('--no-input', 'Disable interactive terminal input for visualization')
.action(async (runId: string | undefined, options, command) => {
const commandOptionsWithGlobals = resolvedOptions(command, options);
assertOutputModeCompatible(commandOptionsWithGlobals);
await runIngestArgs(
context,
{
command: 'status',
projectDir: resolveCommandProjectDir(command),
...(runId ? { runId } : {}),
...(commandOptionsWithGlobals.reportFile ? { reportFile: resolve(commandOptionsWithGlobals.reportFile) } : {}),
outputMode: outputMode(commandOptionsWithGlobals),
...inputMode(commandOptionsWithGlobals),
},
commandOptions,
);
});
ingest
.command('watch', { hidden: true })
.description('Open the latest or selected stored ingest visual report')
.argument('[runId]', 'Local ingest id, report id, run id, or job id')
.option('--report-file <path>', 'Bundle ingest report JSON file to render')
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
.option('--no-input', 'Disable interactive terminal input for visualization')
.action(async (runId: string | undefined, options, command) => {
const commandOptionsWithGlobals = resolvedOptions(command, options);
assertOutputModeCompatible(commandOptionsWithGlobals);
await runIngestArgs(
context,
{
command: 'watch',
projectDir: resolveCommandProjectDir(command),
...(runId ? { runId } : {}),
...(commandOptionsWithGlobals.reportFile ? { reportFile: resolve(commandOptionsWithGlobals.reportFile) } : {}),
outputMode: watchOutputMode(commandOptionsWithGlobals),
...inputMode(commandOptionsWithGlobals),
},
commandOptions,
);
});
ingest
.command('replay')
.description('Replay a stored ingest report through memory-flow output')
.argument('<runId>', 'Local ingest id, report id, run id, or job id')
.option('--report-file <path>', 'Bundle ingest report JSON file to render')
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
.option('--no-input', 'Disable interactive terminal input for visualization')
.action(async (runId: string, options, command) => {
const commandOptionsWithGlobals = resolvedOptions(command, options);
assertOutputModeCompatible(commandOptionsWithGlobals);
await runIngestArgs(
context,
{
command: 'replay',
projectDir: resolveCommandProjectDir(command),
runId,
...(commandOptionsWithGlobals.reportFile ? { reportFile: resolve(commandOptionsWithGlobals.reportFile) } : {}),
outputMode: outputMode(commandOptionsWithGlobals),
...inputMode(commandOptionsWithGlobals),
},
commandOptions,
);
});
}

View file

@ -1,11 +1,9 @@
import { type Command, Option } from '@commander-js/extra-typings';
import type { Command } from '@commander-js/extra-typings';
import {
collectOption,
type KtxCliCommandContext,
parsePositiveIntegerOption,
resolveCommandProjectDir,
} from '../cli-program.js';
import { wikiWriteCommandSchema } from '../command-schemas.js';
import type { KtxKnowledgeArgs } from '../knowledge.js';
import { profileMark } from '../startup-profile.js';
@ -19,7 +17,7 @@ async function runKnowledgeArgs(context: KtxCliCommandContext, args: KtxKnowledg
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
const wiki = program
.command('wiki')
.description('List, read, search, or write local wiki pages')
.description('List or search local wiki pages')
.showHelpAfterError()
.addHelpText(
'after',
@ -40,22 +38,6 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
});
});
wiki
.command('read')
.description('Read one local wiki page')
.argument('<key>', 'Wiki page key')
.option('--json', 'Print JSON output', false)
.option('--user-id <id>', 'Local user id', 'local')
.action(async (key: string, options: { userId: string; json?: boolean }, command) => {
await runKnowledgeArgs(context, {
command: 'read',
projectDir: resolveCommandProjectDir(command),
key,
userId: options.userId,
json: options.json,
});
});
wiki
.command('search')
.description('Search local wiki pages')
@ -73,31 +55,4 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
...(options.limit !== undefined ? { limit: options.limit } : {}),
});
});
wiki
.command('write')
.description('Write one local wiki page')
.argument('<key>', 'Wiki page key')
.option('--user-id <id>', 'Local user id', 'local')
.addOption(new Option('--scope <scope>', 'global or user').choices(['global', 'user']).default('global'))
.requiredOption('--summary <summary>', 'Wiki summary')
.requiredOption('--content <content>', 'Wiki content')
.option('--tag <tag>', 'Wiki tag; repeatable', collectOption, [])
.option('--ref <ref>', 'Wiki ref; repeatable', collectOption, [])
.option('--sl-ref <ref>', 'Semantic-layer ref; repeatable', collectOption, [])
.action(async (key: string, options, command) => {
const args = wikiWriteCommandSchema.parse({
command: 'write',
projectDir: resolveCommandProjectDir(command),
key,
scope: options.scope === 'user' ? 'USER' : 'GLOBAL',
userId: options.userId,
summary: options.summary,
content: options.content,
tags: options.tag,
refs: options.ref,
slRefs: options.slRef,
});
await runKnowledgeArgs(context, args);
});
}

View file

@ -52,14 +52,14 @@ describe('dev Commander tree', () => {
expect(testIo.stderr()).toBe('');
});
it('keeps dev callable while hiding it from root command rows', async () => {
it('lists dev in root command rows', async () => {
const testIo = makeIo();
await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Advanced:');
expect(testIo.stdout()).toContain('ktx dev');
expect(testIo.stdout()).not.toContain('dev Low-level diagnostics');
expect(testIo.stdout()).not.toContain('Advanced:');
expect(testIo.stdout()).toContain('dev');
expect(testIo.stdout()).toMatch(/Low-level project initialization and runtime\s+management/);
expect(testIo.stderr()).toBe('');
});
@ -132,9 +132,8 @@ describe('dev Commander tree', () => {
])('prints generated nested help for $argv', async ({ argv, expected }) => {
const io = makeIo();
const doctor = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
await expect(runKtxCli(argv, io.io, { doctor, ingest })).resolves.toBe(0);
await expect(runKtxCli(argv, io.io, { doctor })).resolves.toBe(0);
for (const text of expected) {
expect(io.stdout()).toContain(text);
@ -145,28 +144,25 @@ describe('dev Commander tree', () => {
}
expect(io.stderr()).toBe('');
expect(doctor).not.toHaveBeenCalled();
expect(ingest).not.toHaveBeenCalled();
});
it('keeps legacy adapter-backed ingest run callable but hidden from ingest help', async () => {
it('rejects removed adapter-backed ingest run and keeps it out of ingest help', async () => {
const helpIo = makeIo();
const runIo = makeIo();
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
await expect(runKtxCli(['ingest', '--help'], helpIo.io, { ingest })).resolves.toBe(0);
await expect(runKtxCli(['ingest', '--help'], helpIo.io, { publicIngest })).resolves.toBe(0);
await expect(
runKtxCli(
['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase', '--project-dir', '/tmp/project'],
runIo.io,
{ ingest },
{ publicIngest },
),
).resolves.toBe(0);
).resolves.toBe(1);
expect(helpIo.stdout()).not.toMatch(/^ run\s/m);
expect(ingest).toHaveBeenCalledWith(
expect.objectContaining({ command: 'run', connectionId: 'warehouse', adapter: 'metabase' }),
runIo.io,
);
expect(runIo.stderr()).toMatch(/unknown command|error:/);
expect(publicIngest).not.toHaveBeenCalled();
});
it.each([
@ -177,17 +173,17 @@ describe('dev Commander tree', () => {
{ argv: ['scan', 'warehouse', '--project-dir', '/tmp/project', '--mode', 'relationships'] },
])('rejects removed top-level scan command $argv', async ({ argv }) => {
const io = makeIo();
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
await expect(runKtxCli(argv, io.io, { ingest })).resolves.toBe(1);
await expect(runKtxCli(argv, io.io, { publicIngest })).resolves.toBe(1);
expect(ingest).not.toHaveBeenCalled();
expect(publicIngest).not.toHaveBeenCalled();
expect(io.stderr()).toMatch(/unknown command|error:/);
});
it('dispatches top-level ingest run through the low-level ingest Commander registration', async () => {
it('rejects top-level ingest run through the removed low-level ingest registration', async () => {
const io = makeIo();
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
await expect(
runKtxCli(
@ -203,24 +199,11 @@ describe('dev Commander tree', () => {
'--json',
],
io.io,
{ ingest },
{ publicIngest },
),
).resolves.toBe(0);
).resolves.toBe(1);
expect(ingest).toHaveBeenCalledWith(
{
command: 'run',
projectDir: '/tmp/project',
connectionId: 'warehouse',
adapter: 'metabase',
sourceDir: undefined,
databaseIntrospectionUrl: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
outputMode: 'json',
},
io.io,
);
expect(io.stderr()).toBe('');
expect(publicIngest).not.toHaveBeenCalled();
expect(io.stderr()).toMatch(/unknown command|error:/);
});
});

View file

@ -8,7 +8,7 @@ profileMark('module:dev');
export function registerDevCommands(program: Command, context: KtxCliCommandContext): void {
const dev = program
.command('dev', { hidden: true })
.command('dev')
.description('Low-level project initialization and runtime management')
.showHelpAfterError();

View file

@ -71,7 +71,6 @@ describe('standalone local warehouse example', () => {
it('runs local CLI commands against the copied example project', async () => {
const projectDir = await copyExampleProject(tempDir);
const sourceDir = join(projectDir, 'source');
const knowledgeList = await runBuiltCli(['wiki', 'search', 'revenue', '--json', '--project-dir', projectDir]);
expect(knowledgeList).toMatchObject({ code: 0, stderr: '' });
@ -105,19 +104,13 @@ describe('standalone local warehouse example', () => {
const ingest = await runBuiltCli([
'ingest',
'run',
'--project-dir',
projectDir,
'--connection-id',
'warehouse',
'--adapter',
'fake',
'--source-dir',
sourceDir,
]);
expect(ingest).toMatchObject({ code: 1, stdout: '' });
expect(ingest.stderr).toContain(
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
);
expect(ingest.stderr).toContain("unknown command 'run'");
}, 30_000);
});

View file

@ -124,7 +124,7 @@ describe('runKtxCli', () => {
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
expect(testIo.stdout()).toContain('KTX data agent context layer CLI');
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']) {
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'dev']) {
expect(testIo.stdout()).toContain(`${command}`);
}
expect(testIo.stdout()).not.toMatch(/^ scan\s/m);
@ -135,71 +135,60 @@ describe('runKtxCli', () => {
expect(testIo.stdout()).toContain('KTX_PROJECT_DIR');
expect(testIo.stdout()).toContain('--debug');
expect(testIo.stdout()).not.toContain('--' + 'verbose');
expect(testIo.stdout()).toContain('Advanced:');
expect(testIo.stdout()).toContain('ktx dev');
expect(testIo.stdout()).not.toContain('Advanced:');
expect(testIo.stderr()).toBe('');
});
it('routes public wiki read and write commands', async () => {
it('routes supported public wiki commands', async () => {
const knowledge = vi.fn(async () => 0);
const readIo = makeIo();
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', 'read', 'revenue', '--json'], readIo.io, { knowledge }))
const listIo = makeIo();
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', 'list', '--json'], listIo.io, { knowledge }))
.resolves.toBe(0);
expect(knowledge).toHaveBeenCalledWith(
{
command: 'read',
command: 'list',
projectDir: tempDir,
key: 'revenue',
userId: 'local',
json: true,
},
readIo.io,
listIo.io,
);
const writeIo = makeIo();
const searchIo = makeIo();
await expect(
runKtxCli(
[
'--project-dir',
tempDir,
'wiki',
'write',
'revenue',
'--scope',
'user',
'--summary',
'Revenue',
'--content',
'Revenue.',
'--tag',
'finance',
'--ref',
'https://example.com/revenue',
'--sl-ref',
'orders',
],
writeIo.io,
{ knowledge },
),
runKtxCli(['--project-dir', tempDir, 'wiki', 'search', 'revenue', '--limit', '5'], searchIo.io, { knowledge }),
).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith(
{
command: 'write',
command: 'search',
projectDir: tempDir,
key: 'revenue',
scope: 'USER',
query: 'revenue',
userId: 'local',
summary: 'Revenue',
content: 'Revenue.',
tags: ['finance'],
refs: ['https://example.com/revenue'],
slRefs: ['orders'],
json: false,
limit: 5,
},
writeIo.io,
searchIo.io,
);
});
it('rejects removed public wiki read and write commands', async () => {
const knowledge = vi.fn(async () => 0);
for (const argv of [
['--project-dir', tempDir, 'wiki', 'read', 'revenue', '--json'],
['--project-dir', tempDir, 'wiki', 'write', 'revenue', '--summary', 'Revenue', '--content', 'Revenue.'],
]) {
const io = makeIo();
await expect(runKtxCli(argv, io.io, { knowledge })).resolves.toBe(1);
expect(io.stderr()).toMatch(/unknown command|error:/);
}
expect(knowledge).not.toHaveBeenCalled();
});
it('rejects removed public sl read/write commands', async () => {
const sl = vi.fn(async () => 0);
@ -334,23 +323,15 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toBe(`Project: ${tempDir}\n`);
});
it('skips the project directory line for JSON and TUI output modes', async () => {
const ingest = vi.fn(async () => 0);
it('skips the project directory line for JSON output mode', async () => {
const publicIngest = vi.fn(async () => 0);
const jsonIo = makeIo();
const vizIo = makeIo({ stdoutIsTty: true });
await expect(runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json'], jsonIo.io, { ingest }))
.resolves.toBe(0);
await expect(
runKtxCli(
['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--viz'],
vizIo.io,
{ ingest },
),
runKtxCli(['--project-dir', tempDir, 'ingest', 'warehouse', '--json'], jsonIo.io, { publicIngest }),
).resolves.toBe(0);
expect(jsonIo.stderr()).toBe('');
expect(vizIo.stderr()).toBe('');
});
it('documents runtime stop all in command help', async () => {
@ -692,60 +673,24 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toMatch(/option '--(deep|fast)' cannot be used with option '--(fast|deep)'/);
});
it('prints ingest watch help from Commander', async () => {
it.each([
{ argv: ['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'] },
{ argv: ['ingest', 'run', '--help'] },
{ argv: ['ingest', 'status'] },
{ argv: ['ingest', 'status', 'run-1', '--json', '--no-input'] },
{ argv: ['ingest', 'watch'] },
{ argv: ['ingest', 'watch', '--help'] },
{ argv: ['ingest', 'replay', 'run-1'] },
{ argv: ['ingest', 'replay', '--help'] },
{ argv: ['--project-dir', '/tmp/project', 'ingest', 'status', 'run-1'] },
])('rejects removed ingest subcommand $argv', async ({ argv }) => {
const testIo = makeIo();
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
await expect(runKtxCli(['ingest', 'watch', '--help'], testIo.io, { ingest })).resolves.toBe(0);
await expect(runKtxCli(argv, testIo.io, { publicIngest })).resolves.toBe(1);
expect(testIo.stdout()).toContain('Usage: ktx ingest watch [options] [runId]');
expect(testIo.stdout()).toContain('[runId]');
expect(testIo.stdout()).toContain('--project-dir <path>');
expect(testIo.stdout()).toContain('--json');
expect(testIo.stdout()).toContain('--no-input');
expect(testIo.stderr()).toBe('');
expect(ingest).not.toHaveBeenCalled();
});
it('dispatches ingest status and watch through Commander', async () => {
const statusIo = makeIo();
const watchIo = makeIo();
const ingest = vi.fn(async () => 0);
await expect(
runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json', '--no-input'], statusIo.io, {
ingest,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['--project-dir', tempDir, 'ingest', 'watch', '--no-input'], watchIo.io, {
ingest,
}),
).resolves.toBe(0);
expect(ingest).toHaveBeenNthCalledWith(
1,
{
command: 'status',
projectDir: tempDir,
runId: 'run-1',
outputMode: 'json',
inputMode: 'disabled',
},
statusIo.io,
);
expect(ingest).toHaveBeenNthCalledWith(
2,
{
command: 'watch',
projectDir: tempDir,
outputMode: 'viz',
inputMode: 'disabled',
},
watchIo.io,
);
expect(statusIo.stderr()).toBe('');
expect(watchIo.stderr()).toBe('');
expect(testIo.stderr()).toMatch(/unknown command|error:/);
expect(publicIngest).not.toHaveBeenCalled();
});
it('rejects standalone demo commands', async () => {
@ -781,9 +726,9 @@ describe('runKtxCli', () => {
it('prints ingest help without invoking ingest execution', async () => {
const testIo = makeIo();
const ingest = vi.fn();
const publicIngest = vi.fn();
await expect(runKtxCli(['ingest', '--help'], testIo.io, { ingest })).resolves.toBe(0);
await expect(runKtxCli(['ingest', '--help'], testIo.io, { publicIngest })).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]');
expect(testIo.stdout()).toContain('Build or inspect KTX context');
@ -793,37 +738,36 @@ describe('runKtxCli', () => {
expect(testIo.stdout()).toContain('--query-history');
expect(testIo.stdout()).toContain('--no-query-history');
expect(testIo.stdout()).toContain('--query-history-window-days <days>');
expect(testIo.stdout()).toContain('status');
expect(testIo.stdout()).toContain('replay');
expect(testIo.stdout()).not.toMatch(/^ status\s/m);
expect(testIo.stdout()).not.toMatch(/^ replay\s/m);
expect(testIo.stdout()).not.toMatch(/^ run\s/m);
expect(testIo.stdout()).not.toMatch(/^ watch\s/m);
expect(testIo.stderr()).toBe('');
expect(ingest).not.toHaveBeenCalled();
expect(publicIngest).not.toHaveBeenCalled();
});
it('routes ingest run at the top level and rejects removed dev ingest', async () => {
const runIo = makeIo();
it('rejects removed ingest run at the top level and under dev', async () => {
const rootRunIo = makeIo();
const devRunIo = makeIo();
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
await expect(
runKtxCli(['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], runIo.io, { ingest }),
).resolves.toBe(0);
await expect(
runKtxCli(['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, {
ingest,
runKtxCli(['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], rootRunIo.io, {
publicIngest,
}),
).resolves.toBe(1);
expect(ingest).toHaveBeenCalledWith(
expect.objectContaining({ command: 'run', connectionId: 'warehouse', adapter: 'metabase' }),
expect.anything(),
);
await expect(
runKtxCli(['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, {
publicIngest,
}),
).resolves.toBe(1);
expect(publicIngest).not.toHaveBeenCalled();
expect(rootRunIo.stderr()).toMatch(/unknown command|error:/);
expect(devRunIo.stderr()).toMatch(/unknown command|error:/);
});
it('rejects removed dev doctor while keeping ingest parser cases at the root', async () => {
it('rejects removed dev doctor and removed ingest parser cases', async () => {
const doctor = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const doctorIo = makeIo();
const ingestRunIo = makeIo();
const ingestReplayHelpIo = makeIo();
@ -848,94 +792,15 @@ describe('runKtxCli', () => {
'--no-input',
],
ingestRunIo.io,
{ ingest },
{},
),
).resolves.toBe(0);
await expect(runKtxCli(['ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0);
).resolves.toBe(1);
await expect(runKtxCli(['ingest', 'replay', '--help'], ingestReplayHelpIo.io)).resolves.toBe(1);
expect(doctor).not.toHaveBeenCalled();
expect(ingest).toHaveBeenCalledWith(
{
command: 'run',
projectDir: tempDir,
connectionId: 'warehouse',
adapter: 'fake',
sourceDir: tempDir,
databaseIntrospectionUrl: undefined,
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'prompt',
debugLlmRequestFile: `${tempDir}/debug.jsonl`,
outputMode: 'json',
inputMode: 'disabled',
},
ingestRunIo.io,
);
expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx ingest replay [options] <runId>');
expect(ingestReplayHelpIo.stdout()).toContain('<runId>');
expect(doctorIo.stderr()).toMatch(/unknown command|error:/);
expect(ingestRunIo.stderr()).toBe('');
expect(ingestReplayHelpIo.stderr()).toBe('');
});
it('routes ingest managed runtime install policy separately from visualization input mode', async () => {
const autoIo = makeIo();
const nonInteractiveIo = makeIo();
const ingest = vi.fn(async () => 0);
await expect(
runKtxCli(
[
'ingest',
'run',
'--project-dir',
tempDir,
'--connection-id',
'warehouse',
'--adapter',
'looker',
'--yes',
],
autoIo.io,
{ ingest },
),
).resolves.toBe(0);
await expect(
runKtxCli(
[
'ingest',
'run',
'--project-dir',
tempDir,
'--connection-id',
'warehouse',
'--adapter',
'looker',
'--yes',
'--no-input',
],
nonInteractiveIo.io,
{ ingest },
),
).resolves.toBe(0);
expect(ingest).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'auto',
}),
autoIo.io,
);
expect(ingest).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
cliVersion: '0.0.0-private',
runtimeInstallPolicy: 'auto',
inputMode: 'disabled',
}),
nonInteractiveIo.io,
);
expect(nonInteractiveIo.stderr()).toBe(`Project: ${tempDir}\n`);
expect(ingestRunIo.stderr()).toMatch(/unknown command|error:/);
expect(ingestReplayHelpIo.stderr()).toMatch(/unknown command|error:/);
});
it('dispatches public connection through the existing connection implementation', async () => {
@ -1199,7 +1064,9 @@ describe('runKtxCli', () => {
).resolves.toBe(1);
expect(setup).not.toHaveBeenCalled();
expect(testIo.stderr()).toContain('"status" is reserved for ktx ingest status; choose a different connection id.');
expect(testIo.stderr()).toContain(
'"status" is reserved for the KTX ingest command namespace; choose a different connection id.',
);
});
it('dispatches setup source flags', async () => {
@ -1573,12 +1440,12 @@ describe('runKtxCli', () => {
{ argv: ['scan', 'warehouse', '--mode', 'relationships'] },
])('rejects removed top-level scan command $argv', async ({ argv }) => {
const testIo = makeIo();
const ingest = vi.fn().mockResolvedValue(0);
const publicIngest = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(argv, testIo.io, { ingest })).resolves.toBe(1);
await expect(runKtxCli(argv, testIo.io, { publicIngest })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown command|error:/);
expect(ingest).not.toHaveBeenCalled();
expect(publicIngest).not.toHaveBeenCalled();
});
it('rejects removed public serve command options before dispatch', async () => {
@ -1626,13 +1493,13 @@ describe('runKtxCli', () => {
it('rejects removed dev command groups without invoking execution', async () => {
for (const command of ['scan', 'ingest', 'mapping']) {
const testIo = makeIo();
const ingest = vi.fn().mockResolvedValue(0);
const publicIngest = vi.fn().mockResolvedValue(0);
const sl = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(['dev', command], testIo.io, { ingest, sl })).resolves.toBe(1);
await expect(runKtxCli(['dev', command], testIo.io, { publicIngest, sl })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown command|error:/);
expect(ingest).not.toHaveBeenCalled();
expect(publicIngest).not.toHaveBeenCalled();
expect(sl).not.toHaveBeenCalled();
}
});
@ -1645,19 +1512,16 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toMatch(/unknown command|error:/);
});
it('rejects mutually exclusive output modes before invoking runners', async () => {
const ingest = vi.fn(async () => 0);
it('rejects mutually exclusive public ingest output modes before invoking runners', async () => {
const publicIngest = vi.fn(async () => 0);
for (const argv of [
['ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
['ingest', 'status', 'run-1', '--json', '--viz'],
]) {
const testIo = makeIo();
await expect(runKtxCli(argv, testIo.io, { ingest })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/conflict|cannot be used/i);
}
const testIo = makeIo();
await expect(runKtxCli(['ingest', 'warehouse', '--json', '--plain'], testIo.io, { publicIngest })).resolves.toBe(
1,
);
expect(ingest).not.toHaveBeenCalled();
expect(testIo.stderr()).toMatch(/conflict|cannot be used/i);
expect(publicIngest).not.toHaveBeenCalled();
});
it('does not expose root init after setup owns project creation', async () => {

View file

@ -311,7 +311,7 @@ describe('runKtxIngest', () => {
expect(runIo.stdout()).toBe('');
expect(runIo.stderr()).toContain(
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
);
expect(runIo.stderr()).toContain(
`ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
@ -854,7 +854,7 @@ describe('runKtxIngest', () => {
).resolves.toBe(1);
expect(io.stderr()).toContain('source-dir uploads are not supported for the Metabase fan-out adapter');
expect(io.stderr()).not.toContain('ktx ingest run requires llm.provider.backend');
expect(io.stderr()).not.toContain('ktx ingest requires llm.provider.backend');
expect(io.stdout()).toBe('');
});

View file

@ -35,7 +35,7 @@ import { profileMark } from './startup-profile.js';
profileMark('module:ingest');
export type KtxIngestOutputMode = 'plain' | 'json' | 'viz';
type KtxIngestOutputMode = 'plain' | 'json' | 'viz';
type KtxIngestInputMode = 'auto' | 'disabled';
export type KtxIngestArgs =

View file

@ -192,7 +192,7 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stdout()).toBe('');
expect(searchIo.stderr()).toContain('No local wiki pages found');
expect(searchIo.stderr()).toContain('ktx wiki write');
expect(searchIo.stderr()).toContain('ktx ingest <connectionId>');
});
it('uses configured embeddings for semantic wiki search', async () => {

View file

@ -113,7 +113,7 @@ export async function runKtxKnowledge(
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
if (pages.length === 0) {
io.stderr.write(
`No local wiki pages found in ${project.projectDir}. Create one with \`ktx wiki write <key> --summary <summary> --content <content>\` or run ingest.\n`,
`No local wiki pages found in ${project.projectDir}. Add Markdown files under wiki/ or run \`ktx ingest <connectionId>\`.\n`,
);
} else {
io.stderr.write(

View file

@ -12,7 +12,7 @@ describe('renderKtxCommandTree', () => {
.filter((line) => /^ {2}[├└]── \S/.test(line))
.map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]);
for (const expected of ['setup', 'connection', 'ingest', 'sl']) {
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) {
expect(topLevel).toContain(expected);
}
@ -24,9 +24,15 @@ describe('renderKtxCommandTree', () => {
expect(output).not.toContain('│ ├── metabase');
expect(output).not.toContain('│ ├── notion');
expect(output).not.toContain('scan <connectionId>');
expect(output).not.toContain('│ ├── status');
expect(output).not.toContain('│ ├── replay');
expect(output).not.toContain('│ └── replay');
expect(output).not.toContain('│ ├── run');
expect(output).not.toContain('│ ├── watch');
expect(output).not.toContain('│ └── watch');
expect(output).not.toContain('│ ├── read');
expect(output).not.toContain('│ ├── write');
expect(output).not.toContain('│ └── write');
});
it('ends with a single trailing newline', () => {

View file

@ -32,10 +32,9 @@ describe('project directory defaults', () => {
const connection = vi.fn(async () => 0);
const doctor = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
const setup = vi.fn(async () => 0);
const deps: KtxCliDeps = { connection, doctor, ingest, publicIngest, setup };
const deps: KtxCliDeps = { connection, doctor, publicIngest, setup };
const cases: Array<{
argv: string[];
@ -55,12 +54,6 @@ describe('project directory defaults', () => {
expected: { command: 'project', projectDir: '/tmp/ktx-env-project' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['ingest', 'status', 'run-1'],
spy: ingest,
expected: { command: 'status', projectDir: '/tmp/ktx-env-project', runId: 'run-1', outputMode: 'plain' },
expectedStderr: 'Project: /tmp/ktx-env-project\n',
},
{
argv: ['setup', '--no-input'],
spy: setup,
@ -87,31 +80,32 @@ describe('project directory defaults', () => {
process.env.KTX_PROJECT_DIR = '/tmp/ktx-env-project';
const publicIngest = vi.fn(async () => 0);
const ingest = vi.fn(async () => 0);
const publicIngestIo = makeIo();
const ingestIo = makeIo();
const beforeCommandIo = makeIo();
const afterCommandIo = makeIo();
await expect(
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'ingest', 'warehouse', '--no-input'], publicIngestIo.io, {
runKtxCli(['--project-dir', '/tmp/ktx-explicit-project', 'ingest', 'warehouse', '--no-input'], beforeCommandIo.io, {
publicIngest,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['ingest', 'status', 'run-1', '--project-dir=/tmp/ktx-explicit-project'], ingestIo.io, {
ingest,
runKtxCli(['ingest', 'warehouse', '--project-dir=/tmp/ktx-explicit-project', '--no-input'], afterCommandIo.io, {
publicIngest,
}),
).resolves.toBe(0);
expect(publicIngest).toHaveBeenCalledWith(
expect(publicIngest).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }),
publicIngestIo.io,
beforeCommandIo.io,
);
expect(ingest).toHaveBeenCalledWith(
expect.objectContaining({ command: 'status', projectDir: '/tmp/ktx-explicit-project' }),
ingestIo.io,
expect(publicIngest).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ command: 'run', projectDir: '/tmp/ktx-explicit-project' }),
afterCommandIo.io,
);
expect(publicIngestIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
expect(ingestIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
expect(beforeCommandIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
expect(afterCommandIo.stderr()).toBe('Project: /tmp/ktx-explicit-project\n');
});
it('uses nearest ancestor containing ktx.yaml when no explicit or environment project-dir exists', async () => {

View file

@ -984,46 +984,4 @@ describe('runKtxPublicIngest', () => {
);
});
it('routes public status and watch to the ingest status renderer', async () => {
const runIngest = vi.fn(async () => 0);
const statusIo = makeIo();
const watchIo = makeIo();
await expect(
runKtxPublicIngest(
{ command: 'status', projectDir: '/tmp/ktx', json: false, inputMode: 'disabled' },
statusIo.io,
{ runIngest },
),
).resolves.toBe(0);
await expect(
runKtxPublicIngest(
{ command: 'watch', projectDir: '/tmp/ktx', runId: 'run-1', json: false, inputMode: 'auto' },
watchIo.io,
{ runIngest },
),
).resolves.toBe(0);
expect(runIngest).toHaveBeenNthCalledWith(
1,
{
command: 'status',
projectDir: '/tmp/ktx',
outputMode: 'plain',
inputMode: 'disabled',
},
statusIo.io,
);
expect(runIngest).toHaveBeenNthCalledWith(
2,
{
command: 'watch',
projectDir: '/tmp/ktx',
runId: 'run-1',
outputMode: 'viz',
inputMode: 'auto',
},
watchIo.io,
);
});
});

View file

@ -23,26 +23,19 @@ type KtxPublicIngestQueryHistoryFlag = 'default' | 'enabled' | 'disabled';
type HistoricSqlDialect = 'postgres' | 'bigquery' | 'snowflake';
export type KtxPublicIngestArgs =
| {
command: 'run';
projectDir: string;
targetConnectionId?: string;
all: boolean;
json: boolean;
inputMode: KtxPublicIngestInputMode;
depth?: KtxPublicIngestDepth;
queryHistory?: KtxPublicIngestQueryHistoryFlag;
queryHistoryWindowDays?: number;
scanMode?: Extract<KtxScanArgs, { command: 'run' }>['mode'];
detectRelationships?: boolean;
}
| {
command: 'status' | 'watch';
projectDir: string;
runId?: string;
json: boolean;
inputMode: KtxPublicIngestInputMode;
};
{
command: 'run';
projectDir: string;
targetConnectionId?: string;
all: boolean;
json: boolean;
inputMode: KtxPublicIngestInputMode;
depth?: KtxPublicIngestDepth;
queryHistory?: KtxPublicIngestQueryHistoryFlag;
queryHistoryWindowDays?: number;
scanMode?: Extract<KtxScanArgs, { command: 'run' }>['mode'];
detectRelationships?: boolean;
};
export interface KtxPublicIngestPlanTarget {
connectionId: string;
@ -775,20 +768,6 @@ export async function runKtxPublicIngest(
io: KtxCliIo,
deps: KtxPublicIngestDeps = {},
): Promise<number> {
if (args.command !== 'run') {
const { runKtxIngest } = await import('./ingest.js');
return await (deps.runIngest ?? runKtxIngest)(
{
command: args.command,
projectDir: args.projectDir,
...(args.runId ? { runId: args.runId } : {}),
outputMode: args.json ? 'json' : args.command === 'watch' ? 'viz' : 'plain',
inputMode: args.inputMode,
},
io,
);
}
const loadProject = deps.loadProject ?? loadKtxProject;
const project = await loadProject({ projectDir: args.projectDir });
if (shouldUseForegroundContextBuildView(args, io)) {

View file

@ -1839,7 +1839,9 @@ describe('setup databases step', () => {
);
expect(result.status).toBe('failed');
expect(io.stderr()).toContain('"replay" is reserved for ktx ingest replay; choose a different connection id.');
expect(io.stderr()).toContain(
'"replay" is reserved for the KTX ingest command namespace; choose a different connection id.',
);
});
it('leaves setup incomplete when databases are skipped', async () => {

View file

@ -276,7 +276,9 @@ describe('setup sources step', () => {
);
expect(result.status).toBe('failed');
expect(io.stderr()).toContain('"status" is reserved for ktx ingest status; choose a different connection id.');
expect(io.stderr()).toContain(
'"status" is reserved for the KTX ingest command namespace; choose a different connection id.',
);
});
it('uses selected Notion roots when root page ids are provided even if crawl mode says all accessible', async () => {

View file

@ -50,28 +50,6 @@ async function runBuiltCli(args: string[], options: { env?: NodeJS.ProcessEnv }
}
}
async function writeWarehouseConfig(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
'ingest:',
' adapters:',
' - fake',
'',
].join('\n'),
'utf-8',
);
}
async function writeSourceFixture(sourceDir: string): Promise<void> {
await mkdir(join(sourceDir, 'orders'), { recursive: true });
await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8');
}
function createSqliteWarehouse(dbPath: string): void {
const db = new Database(dbPath);
try {
@ -157,33 +135,23 @@ describe('standalone built ktx CLI smoke', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('reports missing local ingest LLM config through the built binary', async () => {
it('rejects removed low-level ingest run through the built binary', async () => {
const projectDir = join(tempDir, 'project');
const sourceDir = join(tempDir, 'source');
const init = await runSetupNewProject(projectDir);
expectProjectStderr(init, projectDir);
expect(init.stdout).toContain(`Project: ${projectDir}`);
await writeWarehouseConfig(projectDir);
await writeSourceFixture(sourceDir);
const run = await runBuiltCli([
'ingest',
'run',
'--project-dir',
projectDir,
'--connection-id',
'warehouse',
'--adapter',
'fake',
'--source-dir',
sourceDir,
]);
expect(run).toMatchObject({ code: 1, stdout: '' });
expect(run.stderr).toContain(
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner',
);
expect(run.stderr).toContain("unknown command 'run'");
});
it('rejects the removed agent command through the built binary', async () => {

View file

@ -228,7 +228,7 @@ export async function localPullConfigForAdapter(
): Promise<unknown> {
if (adapter.source === 'metabase') {
throw new Error(
'Metabase scheduled pulls fan out by mapping. Call runLocalMetabaseIngest() or use `ktx ingest run --adapter metabase --connection-id <metabase-source-id>` from the CLI.',
'Metabase scheduled pulls fan out by mapping. Call runLocalMetabaseIngest() or use `ktx ingest <metabase-source-id>` from the CLI.',
);
}
const connection = project.config.connections[connectionId];

View file

@ -56,7 +56,7 @@ describe('createLocalBundleIngestRuntime', () => {
}),
).toThrow(
[
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
`Configure an Anthropic provider, then rerun ingest:`,
` ktx setup --project-dir ${project.projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
].join('\n'),

View file

@ -571,7 +571,7 @@ function nextLocalJobId(): string {
function localIngestLlmProviderGuardMessage(projectDir: string): string {
return [
'ktx ingest run requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.',
'Configure an Anthropic provider, then rerun ingest:',
` ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
].join('\n');

View file

@ -10,7 +10,7 @@ connections:
${connectionId}:
driver: postgres
`),
).toThrow(`"${connectionId}" is reserved for ktx ingest ${connectionId}`);
).toThrow(`"${connectionId}" is reserved for the KTX ingest command namespace`);
});
it('builds the default standalone project config', () => {

View file

@ -113,10 +113,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
const RESERVED_INGEST_CONNECTION_IDS = new Map([
['status', 'ktx ingest status'],
['replay', 'ktx ingest replay'],
['run', 'ktx ingest run'],
['watch', 'ktx ingest watch'],
['status', 'the KTX ingest command namespace'],
['replay', 'the KTX ingest command namespace'],
['run', 'the KTX ingest command namespace'],
['watch', 'the KTX ingest command namespace'],
]);
export function reservedKtxIngestConnectionIdMessage(connectionId: string): string | null {

View file

@ -120,6 +120,22 @@ async function writeLiveDatabaseConfig(projectDir: string): Promise<void> {
);
}
async function writeDatabaseConfigWithoutIngestAdapters(projectDir: string): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' readonly: true',
'',
].join('\n'),
'utf-8',
);
}
function fetchOnlyAdapter(options: { extractedAt?: () => string } = {}): SourceAdapter {
return {
source: 'live-database',
@ -244,6 +260,27 @@ describe('local scan', () => {
});
});
it('runs a structural database scan when live-database is not listed in ktx.yaml', async () => {
await writeDatabaseConfigWithoutIngestAdapters(project.projectDir);
project = await loadKtxProject({ projectDir: project.projectDir });
const result = await runLocalScan({
project,
adapters: [fetchOnlyAdapter()],
connectionId: 'warehouse',
jobId: 'scan-run-without-public-adapter',
now: () => new Date('2026-04-29T09:10:00.000Z'),
});
expect(result.report).toMatchObject({
connectionId: 'warehouse',
runId: 'scan-run-without-public-adapter',
artifactPaths: {
reportPath: 'raw-sources/warehouse/live-database/2026-04-29-091000-scan-run-without-public-adapter/scan-report.json',
},
});
});
it('reuses scan report and raw-source paths when the same local scan run id is retried', async () => {
const first = await runLocalScan({
project,

View file

@ -342,6 +342,22 @@ function createFilteredConnector(connector: KtxScanConnector, enabledTables: Set
};
}
function withInternalLiveDatabaseAdapter(project: KtxLocalProject): KtxLocalProject {
if (project.config.ingest.adapters.includes(LIVE_DATABASE_ADAPTER)) {
return project;
}
return {
...project,
config: {
...project.config,
ingest: {
...project.config.ingest,
adapters: [...project.config.ingest.adapters, LIVE_DATABASE_ADAPTER],
},
},
};
}
export async function runLocalScan(options: RunLocalScanOptions): Promise<LocalScanRunResult> {
const mode = options.mode ?? 'structural';
assertSupportedMode(mode);
@ -367,7 +383,7 @@ export async function runLocalScan(options: RunLocalScanOptions): Promise<LocalS
await options.progress?.update(0.15, 'Inspecting database schema');
const record = await runLocalStageOnlyIngest({
project: options.project,
project: withInternalLiveDatabaseAdapter(options.project),
adapters,
adapter: LIVE_DATABASE_ADAPTER,
connectionId: options.connectionId,

View file

@ -257,7 +257,6 @@ describe('standalone example docs', () => {
assert.match(ingestReference, /--query-history-window-days <days>/);
assert.match(buildingContext, /ktx ingest <connection-id>/);
assert.match(buildingContext, /ktx ingest --all/);
assert.match(buildingContext, /ktx ingest replay <run-id>/);
assert.match(contextSources, /ktx ingest <connectionId>/);
assert.match(contextAsCode, /ktx ingest --all --no-input/);
assert.match(quickstart, /schema context/);
@ -272,11 +271,15 @@ describe('standalone example docs', () => {
assert.doesNotMatch(cliMeta, /ktx-scan/);
assert.doesNotMatch(ingestReference, /ktx ingest run/);
assert.doesNotMatch(ingestReference, /ktx ingest status/);
assert.doesNotMatch(ingestReference, /ktx ingest replay/);
assert.doesNotMatch(ingestReference, /--adapter/);
assert.doesNotMatch(ingestReference, /ktx ingest watch/);
assert.doesNotMatch(ingestReference, /live-database/);
assert.doesNotMatch(devReference, /ktx scan/);
assert.doesNotMatch(buildingContext, /ktx ingest watch/);
assert.doesNotMatch(buildingContext, /ktx ingest status/);
assert.doesNotMatch(buildingContext, /ktx ingest replay/);
assert.doesNotMatch(buildingContext, /historic-sql/);
assert.doesNotMatch(buildingContext, /live-database/);
assert.doesNotMatch(contextSources, /ktx ingest run --connection-id/);

View file

@ -113,10 +113,6 @@ export function buildLiveDatabaseIngestArgs(projectDir, _databaseIntrospectionUr
];
}
export function buildLiveDatabaseStatusArgs(projectDir, runId) {
return ['exec', 'ktx', 'ingest', 'status', '--project-dir', projectDir, runId];
}
async function run(command, args, options = {}) {
process.stdout.write(`$ ${command} ${args.join(' ')}\n`);
return new Promise((resolve) => {
@ -167,7 +163,7 @@ function requireOutput(label, result, pattern) {
function getRunId(stdout) {
const match = stdout.match(/^Run: (.+)$/m);
if (!match) {
throw new Error(`ingest run output did not include a run id\nstdout:\n${stdout}`);
throw new Error(`ingest output did not include a run id\nstdout:\n${stdout}`);
}
return match[1];
}
@ -322,16 +318,6 @@ async function main() {
requireOutput('ktx ingest warehouse --fast', ingestRun, /Database schema/);
const runId = getRunId(ingestRun.stdout);
const ingestStatus = await run('pnpm', buildLiveDatabaseStatusArgs(projectDir, runId), {
cwd: cleanInstallDir,
env: managedRuntimeEnv(cleanInstallDir),
timeout: 30_000,
});
requireSuccess('ktx ingest status live-database', ingestStatus);
requireOutput('ktx ingest status live-database', ingestStatus, new RegExp(`Run: ${runId}`));
requireOutput('ktx ingest status live-database', ingestStatus, /Status: done/);
requireOutput('ktx ingest status live-database', ingestStatus, /Raw files: 4/);
requireOutput('ktx ingest status live-database', ingestStatus, /Work units: 2/);
await assertPathExists(join(projectDir, '.ktx', 'db.sqlite'), 'SQLite local ingest state');
process.stdout.write(`Installed live-database artifact smoke passed: ${runId}\n`);
} finally {

View file

@ -5,7 +5,6 @@ import {
buildDockerRunArgs,
buildKtxYaml,
buildLiveDatabaseIngestArgs,
buildLiveDatabaseStatusArgs,
buildPostgresUrl,
buildPostgresReadyArgs,
buildSeedSql,
@ -95,7 +94,7 @@ describe('installed live-database artifact smoke helpers', () => {
]);
});
it('builds installed CLI public database ingest and status commands', () => {
it('builds the installed CLI public database ingest command', () => {
assert.deepEqual(buildLiveDatabaseIngestArgs('/tmp/project', 'http://127.0.0.1:8765'), [
'exec',
'ktx',
@ -107,14 +106,5 @@ describe('installed live-database artifact smoke helpers', () => {
'--no-input',
]);
assert.deepEqual(buildLiveDatabaseStatusArgs('/tmp/project', 'local-run-1'), [
'exec',
'ktx',
'ingest',
'status',
'--project-dir',
'/tmp/project',
'local-run-1',
]);
});
});

View file

@ -561,7 +561,7 @@ function requireIncludes(values, expected, label) {
function getRunId(stdout) {
const match = stdout.match(/^Run: (.+)$/m);
assert.ok(match, 'ingest run output did not include a run id');
assert.ok(match, 'ingest output did not include a run id');
return match[1];
}
@ -588,7 +588,6 @@ process.env.KTX_RUNTIME_ROOT = join(root, 'managed-runtime');
let daemonStarted = false;
try {
const projectDir = join(root, 'project');
const sourceDir = join(root, 'source');
const version = await run('pnpm', ['exec', 'ktx', '--version']);
requireSuccess('ktx public package version', version);
@ -842,27 +841,8 @@ try {
const enrichedScanRunId = getRunId(enrichedScan.stdout);
process.stdout.write('ktx ingest deep verified: ' + enrichedScanRunId + '\\n');
await mkdir(join(sourceDir, 'orders'), { recursive: true });
await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\\n', 'utf-8');
const ingestRun = await run('pnpm', ['exec', 'ktx', 'ingest', 'run',
'--project-dir',
projectDir,
'--connection-id',
'warehouse',
'--adapter',
'fake',
'--source-dir',
sourceDir,
]);
assert.equal(ingestRun.code, 1, 'ktx ingest run without an LLM provider must fail');
assert.match(
ingestRun.stderr,
/ktx ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway, or an injected agentRunner/,
);
await access(join(projectDir, '.ktx', 'db.sqlite'));
process.stdout.write('ktx ingest provider guard verified\\n');
process.stdout.write('ktx ingest state verified\\n');
} finally {
if (daemonStarted) {
await run('pnpm', ['exec', 'ktx', 'dev', 'runtime', 'stop']);

View file

@ -495,11 +495,11 @@ describe('verification snippets', () => {
assert.match(source, /ktx ingest deep verified/);
assert.match(source, /enrichment:/);
assert.match(source, /mode: deterministic/);
assert.match(source, /run\('pnpm', \['exec', 'ktx', 'ingest', 'run'/);
assert.doesNotMatch(source, /run\('pnpm', \['exec', 'ktx', 'ingest', 'run'/);
assert.match(source, /access\(join\(projectDir, '\.ktx', 'db\.sqlite'\)\)/);
assert.match(source, /SQLite wiki index/);
assert.match(source, /ktx ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway/);
assert.match(source, /ktx ingest provider guard verified/);
assert.doesNotMatch(source, /ktx ingest run requires llm\\.provider\\.backend: anthropic, vertex, or gateway/);
assert.match(source, /ktx ingest state verified/);
});
describe('npmCliSmokeSource', () => {