feat(cli): smart defaults and flatter command surface for ktx (#177)

Bare invocations now do the obvious thing instead of erroring out, and mode-as-subcommand patterns collapse into flags on the parent. No new top-level commands.

- `ktx ingest` (bare) ingests every configured connection. The `text` subcommand is gone; capture inline notes with `ktx ingest --text "..."` and files with `ktx ingest --file path` (use `-` for stdin). `--text`/`--file` reject a positional connection id; pass `--connection-id` to tag captured notes.
- `ktx connection` (bare) lists; `ktx connection test` (bare) tests every configured connection.
- `ktx wiki` and `ktx sl` flatten `list`/`search`: bare lists, with a `[query...]` positional searches (multi-word joined with spaces). `sl validate` and `sl query` stay as distinct verbs and now read `--connection-id` from the parent.
- `ktx mcp` (bare) prints daemon status.

Adds a shared `resolveConnectionSelection` helper consumed by ingest and connection test. Updates README, docs-site cli-reference and guides, next-steps strings, agent SKILL templates, and all affected tests. Per-package type-check, unit tests (605), smoke tests, and dead-code checks all pass.
This commit is contained in:
Andrey Avtomonov 2026-05-20 01:52:37 +02:00 committed by GitHub
parent 14626c294b
commit 2c9a58bb56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 438 additions and 380 deletions

View file

@ -2,6 +2,7 @@ import { type Command } from '@commander-js/extra-typings';
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
import type { KtxConnectionArgs } from '../connection.js';
import { profileMark } from '../startup-profile.js';
import { resolveConnectionSelection } from './connection-selection.js';
profileMark('module:commands/connection-commands');
@ -18,7 +19,10 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
.addHelpText(
'after',
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the nearest ktx.yaml or current working directory.\n',
);
)
.action(async (_options: unknown, command) => {
await runConnectionArgs(context, { command: 'list', projectDir: resolveCommandProjectDir(command) });
});
connection.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.(commandName, actionCommand);
});
@ -32,25 +36,22 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
connection
.command('test')
.description('Test a configured connection')
.argument('[connectionId]', 'KTX connection id (omit when --all is set)')
.description('Test one or all configured connections (default: all)')
.argument('[connectionId]', 'KTX connection id to test (omit to test all)')
.option('--all', 'Test every configured connection and print a summary list')
.action(async (connectionId: string | undefined, options: { all?: boolean }, command) => {
const all = options.all === true;
if (all && connectionId !== undefined) {
if (options.all === true && connectionId !== undefined) {
command.error('error: --all cannot be combined with a connection id argument');
}
if (!all && connectionId === undefined) {
command.error('error: missing required argument <connectionId> (or pass --all)');
}
if (all) {
const selection = resolveConnectionSelection({ connectionId, all: options.all === true });
if (selection.kind === 'all') {
await runConnectionArgs(context, { command: 'test-all', projectDir: resolveCommandProjectDir(command) });
return;
}
await runConnectionArgs(context, {
command: 'test',
projectDir: resolveCommandProjectDir(command),
connectionId: connectionId as string,
connectionId: selection.connectionId,
});
});
}

View file

@ -0,0 +1,18 @@
export type ConnectionSelection =
| { kind: 'all' }
| { kind: 'single'; connectionId: string };
export interface ResolveConnectionSelectionInput {
connectionId?: string | undefined;
all: boolean;
}
export function resolveConnectionSelection(input: ResolveConnectionSelectionInput): ConnectionSelection {
if (input.all && input.connectionId !== undefined) {
throw new Error('--all cannot be combined with a connection id argument');
}
if (input.connectionId !== undefined) {
return { kind: 'single', connectionId: input.connectionId };
}
return { kind: 'all' };
}

View file

@ -10,6 +10,7 @@ import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
import type { KtxPublicIngestArgs } from '../public-ingest.js';
import { profileMark } from '../startup-profile.js';
import type { KtxTextIngestArgs } from '../text-ingest.js';
import { resolveConnectionSelection } from './connection-selection.js';
profileMark('module:commands/ingest-commands');
@ -24,15 +25,20 @@ export function registerIngestCommands(
): void {
const ingest = program
.command('ingest')
.description('Build or inspect KTX context')
.description('Build or inspect KTX context, or capture text into memory')
.usage('[options] [connectionId]')
.argument('[connectionId]', 'Configured connection id to ingest')
.argument('[connectionId]', 'Configured connection id to ingest (omit to ingest all)')
.option('--all', 'Ingest all configured connections', false)
.addOption(new Option('--fast', 'Use deterministic database schema ingest').conflicts('deep'))
.addOption(new Option('--deep', 'Use AI-enriched database ingest').conflicts('fast'))
.addOption(new Option('--query-history', 'Include database query-history usage patterns').conflicts('noQueryHistory'))
.addOption(new Option('--no-query-history', 'Skip database query-history usage patterns'))
.option('--query-history-window-days <days>', 'Query-history lookback window for this run', parsePositiveIntegerOption)
.option('--text <content>', 'Capture inline text into KTX memory; repeatable', collectOption, [])
.option('--file <path>', 'Capture a text file into KTX memory; use - for stdin; repeatable', collectOption, [])
.option('--connection-id <connectionId>', 'KTX connection id to tag captured text/file notes')
.option('--user-id <id>', 'Memory user id for text/file capture attribution', 'local-cli')
.option('--fail-fast', 'Stop after the first failed text/file item', false)
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json']))
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain']))
.option('--yes', 'Install required managed runtime features without prompting')
@ -40,14 +46,45 @@ export function registerIngestCommands(
.showHelpAfterError();
ingest.action(async (connectionId: string | undefined, options, command) => {
const projectDir = resolveCommandProjectDir(command);
const hasTextCapture = options.text.length > 0 || options.file.length > 0;
if (hasTextCapture) {
if (connectionId !== undefined) {
command.error(
'error: --text/--file does not accept a positional connection id; use --connection-id <id> to tag captured notes',
);
}
if (options.all === true) {
command.error('error: --all cannot be combined with --text or --file');
}
context.setExitCode(
await commandOptions.runTextIngest(
{
projectDir,
texts: options.text,
files: options.file,
...(options.connectionId ? { connectionId: options.connectionId } : {}),
userId: options.userId,
json: options.json === true,
failFast: options.failFast === true,
},
context.io,
context.deps,
),
);
return;
}
const selection = resolveConnectionSelection({ connectionId, all: options.all === true });
const { runKtxPublicIngest } = await import('../public-ingest.js');
const queryHistory =
options.queryHistory === true ? 'enabled' : options.queryHistory === false ? 'disabled' : 'default';
const args: KtxPublicIngestArgs = {
command: 'run',
projectDir: resolveCommandProjectDir(command),
...(connectionId ? { targetConnectionId: connectionId } : {}),
all: options.all === true,
projectDir,
...(selection.kind === 'single' ? { targetConnectionId: selection.connectionId } : {}),
all: selection.kind === 'all',
json: options.json === true,
inputMode: options.input === false ? 'disabled' : 'auto',
...(options.fast === true ? { depth: 'fast' as const } : {}),
@ -63,32 +100,4 @@ export function registerIngestCommands(
ingest.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.('ingest', actionCommand);
});
ingest
.command('text')
.description('Ingest free-form text artifacts into KTX memory')
.argument('[files...]', 'Files to ingest; use - to read one item from stdin')
.option('--text <content>', 'Text content to ingest; repeat for a batch', collectOption, [])
.option('--connection-id <connectionId>', 'Optional KTX connection id for semantic-layer capture')
.option('--user-id <id>', 'Memory user id for capture attribution', 'local-cli')
.option('--json', 'Print JSON output')
.option('--fail-fast', 'Stop after the first failed text item', false)
.action(async (files: string[], options, command) => {
const parentOptions = command.parent?.opts() as { json?: boolean } | undefined;
context.setExitCode(
await commandOptions.runTextIngest(
{
projectDir: resolveCommandProjectDir(command),
texts: options.text,
files,
...(options.connectionId ? { connectionId: options.connectionId } : {}),
userId: options.userId,
json: options.json === true || parentOptions?.json === true,
failFast: options.failFast === true,
},
context.io,
context.deps,
),
);
});
}

View file

@ -21,59 +21,29 @@ function isDebugEnabled(command: CommandWithGlobalOptions): boolean {
}
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
const wiki = program
program
.command('wiki')
.description('List or search local wiki pages')
.usage('[options] [query...]')
.argument('[query...]', 'Search query; omit to list all pages')
.option('--user-id <id>', 'Local user id', 'local')
.option('--limit <number>', 'Maximum search results (search mode only)', parsePositiveIntegerOption)
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
'pretty',
'plain',
'json',
]),
)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.showHelpAfterError()
.addHelpText(
'after',
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.\n',
);
wiki
.command('list')
.description('List local wiki pages')
.option('--user-id <id>', 'Local user id', 'local')
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
'pretty',
'plain',
'json',
]),
)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.action(
async (
options: { userId: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
command,
) => {
await runKnowledgeArgs(context, {
command: 'list',
projectDir: resolveCommandProjectDir(command),
userId: options.userId,
output: options.output,
json: options.json,
});
},
);
wiki
.command('search')
.description('Search local wiki pages')
.argument('<query>', 'Search query')
.option('--user-id <id>', 'Local user id', 'local')
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
'pretty',
'plain',
'json',
]),
)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.action(
async (
query: string,
query: string[],
options: {
userId: string;
limit?: number;
@ -82,10 +52,20 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
},
command,
) => {
if (query.length === 0) {
await runKnowledgeArgs(context, {
command: 'list',
projectDir: resolveCommandProjectDir(command),
userId: options.userId,
output: options.output,
json: options.json,
});
return;
}
await runKnowledgeArgs(context, {
command: 'search',
projectDir: resolveCommandProjectDir(command),
query,
query: query.join(' '),
userId: options.userId,
output: options.output,
json: options.json,

View file

@ -36,8 +36,24 @@ function formatMcpStartResultMessage(input: { status: 'started' | 'already-runni
].join('\n');
}
async function printMcpStatus(context: KtxCliCommandContext, projectDir: string): Promise<void> {
const status = await (context.deps.mcp?.readStatus ?? readKtxMcpDaemonStatus)({ projectDir });
context.io.stdout.write(`${status.detail}\n`);
if (status.kind === 'running') {
context.io.stdout.write(`URL: ${status.url}\n`);
context.io.stdout.write(`PID: ${status.state.pid}\n`);
context.io.stdout.write(`Token auth: ${status.state.tokenAuth ? 'enabled' : 'disabled'}\n`);
context.io.stdout.write(`Project: ${status.state.projectDir}\n`);
}
}
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
const mcp = program.command('mcp').description('Run the KTX MCP HTTP server');
const mcp = program
.command('mcp')
.description('Manage the KTX MCP HTTP server (bare command: show status)')
.action(async (_options, command) => {
await printMcpStatus(context, resolveCommandProjectDir(command));
});
mcp
.command('stdio')
@ -110,16 +126,7 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
.command('status')
.description('Show KTX MCP daemon status')
.action(async (_options, command) => {
const status = await (context.deps.mcp?.readStatus ?? readKtxMcpDaemonStatus)({
projectDir: resolveCommandProjectDir(command),
});
context.io.stdout.write(`${status.detail}\n`);
if (status.kind === 'running') {
context.io.stdout.write(`URL: ${status.url}\n`);
context.io.stdout.write(`PID: ${status.state.pid}\n`);
context.io.stdout.write(`Token auth: ${status.state.tokenAuth ? 'enabled' : 'disabled'}\n`);
context.io.stdout.write(`Project: ${status.state.projectDir}\n`);
}
await printMcpStatus(context, resolveCommandProjectDir(command));
});
mcp

View file

@ -42,59 +42,49 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
const sl = program
.command(commandName)
.description('List, search, validate, or query local semantic-layer sources')
.usage('[options] [query...]')
.argument('[query...]', 'Search query; omit to list all sources')
.option('--connection-id <id>', 'KTX connection id')
.option('--limit <number>', 'Maximum search results (search mode only)', parsePositiveIntegerOption)
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
'pretty',
'plain',
'json',
]),
)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.showHelpAfterError()
.addHelpText(
'after',
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.\n',
);
sl.command('list')
.description('List semantic-layer sources')
.option('--connection-id <id>', 'KTX connection id')
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
'pretty',
'plain',
'json',
]),
)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.action(
async (options: { connectionId?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean }, command) => {
await runSlArgs(context, {
command: 'list',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
output: options.output,
json: options.json,
});
},
);
sl.command('search')
.description('Search semantic-layer sources')
.argument('<query>', 'Search query')
.option('--connection-id <id>', 'KTX connection id')
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
'pretty',
'plain',
'json',
]),
)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.action(
async (
query: string,
options: { connectionId?: string; limit?: number; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
query: string[],
options: {
connectionId?: string;
limit?: number;
output?: 'pretty' | 'plain' | 'json';
json?: boolean;
},
command,
) => {
if (query.length === 0) {
await runSlArgs(context, {
command: 'list',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
output: options.output,
json: options.json,
});
return;
}
await runSlArgs(context, {
command: 'search',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
query,
query: query.join(' '),
...(options.limit !== undefined ? { limit: options.limit } : {}),
output: options.output,
json: options.json,
@ -103,21 +93,24 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
);
sl.command('validate')
.description('Validate a semantic-layer source')
.description('Validate a semantic-layer source (set --connection-id on `ktx sl`)')
.argument('<sourceName>', 'Semantic-layer source name')
.requiredOption('--connection-id <id>', 'KTX connection id')
.action(async (sourceName: string, options: { connectionId: string }, command) => {
.action(async (sourceName: string, _options, command) => {
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
const connectionId = parentOpts?.connectionId;
if (connectionId === undefined) {
command.error("error: required option '--connection-id <id>' not specified");
}
await runSlArgs(context, {
command: 'validate',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
connectionId: connectionId as string,
sourceName,
});
});
sl.command('query')
.description('Compile or execute a semantic-layer query')
.option('--connection-id <id>', 'KTX connection id')
.description('Compile or execute a semantic-layer query (set --connection-id on `ktx sl`)')
.option('--query-file <path>', 'JSON semantic-layer query file')
.option('--measure <measure>', 'Measure to query; repeatable', collectOption, [])
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
@ -135,10 +128,11 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
if (options.measure.length === 0 && !options.queryFile) {
throw new Error('sl query requires at least one --measure');
}
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
const args = slQueryCommandSchema.parse({
command: 'query',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
connectionId: parentOpts?.connectionId,
...(options.queryFile
? { queryFile: options.queryFile }
: {

View file

@ -65,8 +65,8 @@ describe('formatDoctorReport', () => {
expect(output).not.toContain('v22.16.0');
expect(output).toContain('Everything ready.');
expect(output).toContain('ktx status --json');
expect(output).toContain('ktx sl list');
expect(output).toContain('ktx wiki list');
expect(output).toContain('ktx sl');
expect(output).toContain('ktx wiki');
expect(output).not.toContain('ktx scan');
expect(output).not.toContain('ktx sl ask');
});
@ -561,8 +561,8 @@ describe('runKtxDoctor', () => {
expect(out).toContain('info: pg_stat_statements.max is 1000');
expect(out).not.toContain('Update the Postgres parameter group or config');
expect(out).toContain('ktx status --json');
expect(out).toContain('ktx sl list');
expect(out).toContain('ktx wiki list');
expect(out).toContain('ktx sl');
expect(out).toContain('ktx wiki');
expect(out).not.toContain('ktx scan');
expect(out).not.toContain('ktx sl ask');
delete process.env.ANTHROPIC_API_KEY;

View file

@ -72,13 +72,13 @@ describe('standalone local warehouse example', () => {
it('runs local CLI commands against the copied example project', async () => {
const projectDir = await copyExampleProject(tempDir);
const knowledgeList = await runBuiltCli(['wiki', 'search', 'revenue', '--json', '--project-dir', projectDir]);
const knowledgeList = await runBuiltCli(['wiki', 'revenue', '--json', '--project-dir', projectDir]);
expect(knowledgeList).toMatchObject({ code: 0, stderr: '' });
expect(
parseJsonOutput<{ data: { items: Array<{ key: string; summary: string }> } }>(knowledgeList.stdout).data.items,
).toContainEqual(expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }));
const slList = await runBuiltCli(['sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
const slList = await runBuiltCli(['sl', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
expect(slList).toMatchObject({ code: 0, stderr: '' });
expect(
parseJsonOutput<{ data: { items: Array<{ connectionId: string; name: string; columnCount: number }> } }>(
@ -110,7 +110,7 @@ describe('standalone local warehouse example', () => {
'fake',
]);
expect(ingest).toMatchObject({ code: 1, stdout: '' });
expect(ingest.stderr).toContain("unknown option '--connection-id'");
expect(ingest.stderr).toContain("unknown option '--adapter'");
}, 30_000);
});

View file

@ -149,7 +149,7 @@ describe('runKtxCli', () => {
const knowledge = vi.fn(async () => 0);
const listIo = makeIo();
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', 'list', '--json'], listIo.io, { knowledge }))
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', '--json'], listIo.io, { knowledge }))
.resolves.toBe(0);
expect(knowledge).toHaveBeenCalledWith(
{
@ -163,7 +163,7 @@ describe('runKtxCli', () => {
const searchIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'wiki', 'search', 'revenue', '--limit', '5'], searchIo.io, { knowledge }),
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', '--limit', '5'], searchIo.io, { knowledge }),
).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith(
{
@ -179,7 +179,7 @@ describe('runKtxCli', () => {
const debugSearchIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, '--debug', 'wiki', 'search', 'revenue'], debugSearchIo.io, { knowledge }),
runKtxCli(['--project-dir', tempDir, '--debug', 'wiki', 'revenue'], debugSearchIo.io, { knowledge }),
).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith(
{
@ -192,47 +192,57 @@ describe('runKtxCli', () => {
},
debugSearchIo.io,
);
const multiWordIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', 'policy'], multiWordIo.io, { knowledge }),
).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith(
{
command: 'search',
projectDir: tempDir,
query: 'revenue policy',
userId: 'local',
json: false,
},
multiWordIo.io,
);
});
it('rejects removed public wiki read and write commands', async () => {
it('rejects unknown write-style flags on the flattened wiki and sl 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);
for (const argv of [
['--project-dir', tempDir, 'sl', 'read', 'orders', '--connection-id', 'warehouse'],
['--project-dir', tempDir, 'sl', 'write', 'orders', '--connection-id', 'warehouse', '--yaml', 'name: orders'],
]) {
const io = makeIo();
await expect(runKtxCli(argv, io.io, { sl })).resolves.toBe(1);
expect(io.stderr()).toMatch(/unknown command|error:/);
}
const wikiIo = makeIo();
await expect(
runKtxCli(
['--project-dir', tempDir, 'wiki', 'revenue', '--summary', 'Revenue', '--content', 'Revenue.'],
wikiIo.io,
{ knowledge },
),
).resolves.toBe(1);
expect(wikiIo.stderr()).toMatch(/unknown option|error:/);
expect(knowledge).not.toHaveBeenCalled();
const slIo = makeIo();
await expect(
runKtxCli(
['--project-dir', tempDir, 'sl', 'orders', '--yaml', 'name: orders'],
slIo.io,
{ sl },
),
).resolves.toBe(1);
expect(slIo.stderr()).toMatch(/unknown option|error:/);
expect(sl).not.toHaveBeenCalled();
});
it('routes sl search and rejects the old sl list --query flag', async () => {
it('routes sl search via the flattened query positional and rejects unknown flags', async () => {
const sl = vi.fn(async () => 0);
const searchIo = makeIo();
await expect(
runKtxCli(
['--project-dir', tempDir, 'sl', 'search', 'revenue', '--connection-id', 'warehouse', '--limit', '5', '--json'],
['--project-dir', tempDir, 'sl', 'revenue', '--connection-id', 'warehouse', '--limit', '5', '--json'],
searchIo.io,
{ sl },
),
@ -250,11 +260,26 @@ describe('runKtxCli', () => {
searchIo.io,
);
const listIo = makeIo();
const bareIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'sl', 'list', '--query', 'revenue'], listIo.io, { sl }),
runKtxCli(['--project-dir', tempDir, 'sl', '--connection-id', 'warehouse', '--json'], bareIo.io, { sl }),
).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith(
{
command: 'list',
projectDir: tempDir,
connectionId: 'warehouse',
json: true,
output: undefined,
},
bareIo.io,
);
const unknownIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'sl', '--query', 'revenue'], unknownIo.io, { sl }),
).resolves.toBe(1);
expect(listIo.stderr()).toContain("unknown option '--query'");
expect(unknownIo.stderr()).toContain("unknown option '--query'");
});
it('routes runtime management commands with the release runtime version', async () => {
@ -524,7 +549,7 @@ describe('runKtxCli', () => {
await initKtxProject({ projectDir });
const commands = [
['--project-dir', projectDir, 'status', '--json'],
['--project-dir', projectDir, 'sl', 'list', '--json'],
['--project-dir', projectDir, 'sl', '--json'],
];
for (const argv of commands) {
@ -872,7 +897,8 @@ 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('text');
expect(testIo.stdout()).toContain('--text');
expect(testIo.stdout()).toContain('--file');
expect(testIo.stdout()).not.toMatch(/^ status\s/m);
expect(testIo.stdout()).not.toMatch(/^ replay\s/m);
expect(testIo.stdout()).not.toMatch(/^ run\s/m);
@ -892,7 +918,6 @@ describe('runKtxCli', () => {
'--project-dir',
tempDir,
'ingest',
'text',
'--text',
'Revenue means gross receipts.',
'--text',
@ -924,19 +949,42 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toBe('');
});
it('documents text ingest inputs without a manifest option', async () => {
it('rejects a positional connection id when --text is supplied', async () => {
const textIngest = vi.fn(async () => 0);
const publicIngest = vi.fn(async () => 0);
const testIo = makeIo();
await expect(runKtxCli(['ingest', 'text', '--help'], testIo.io, { textIngest })).resolves.toBe(0);
await expect(
runKtxCli(
['--project-dir', tempDir, 'ingest', 'warehouse', '--text', 'hello'],
testIo.io,
{ textIngest, publicIngest },
),
).resolves.toBe(1);
expect(testIo.stdout()).toContain('Usage: ktx ingest text [options] [files...]');
expect(testIo.stdout()).toContain('--text <content>');
expect(testIo.stdout()).toContain('--connection-id <connectionId>');
expect(testIo.stdout()).toContain('--user-id <id>');
expect(testIo.stdout()).toContain('--fail-fast');
expect(testIo.stdout()).not.toContain('--manifest');
expect(textIngest).not.toHaveBeenCalled();
expect(publicIngest).not.toHaveBeenCalled();
expect(testIo.stderr()).toMatch(/--text\/--file does not accept a positional connection id/);
});
it('treats bare ingest as ingest --all', async () => {
const publicIngest = vi.fn().mockResolvedValue(0);
const testIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'ingest', '--no-input'], testIo.io, { publicIngest }),
).resolves.toBe(0);
expect(publicIngest).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
projectDir: tempDir,
all: true,
}),
testIo.io,
);
const args = publicIngest.mock.calls[0]?.[0] as { targetConnectionId?: string };
expect(args.targetConnectionId).toBeUndefined();
});
it('rejects old adapter-backed ingest flags at the top level and under admin', async () => {

View file

@ -78,14 +78,14 @@ describe('printList — plain mode', () => {
mode: 'plain',
command: 'sl search',
emptyMessage: 'No sources matched "foo"',
emptyHint: 'Run `ktx sl list` to see available sources.',
emptyHint: 'Run `ktx sl` to see available sources.',
unit: 'source',
io: r.io,
});
expect(r.out()).toBe('');
expect(r.err()).toBe(
'No sources matched "foo"\n' +
'Run `ktx sl list` to see available sources.\n',
'Run `ktx sl` to see available sources.\n',
);
});
});
@ -188,13 +188,13 @@ describe('printList — pretty mode', () => {
mode: 'pretty',
command: 'sl search',
emptyMessage: 'No sources matched "foo"',
emptyHint: 'Run `ktx sl list` to see available sources.',
emptyHint: 'Run `ktx sl` to see available sources.',
unit: 'source',
io: r.io,
});
const out = stripAnsi(r.out());
expect(out).toContain('No sources matched "foo"');
expect(out).toContain('Run `ktx sl list` to see available sources.');
expect(out).toContain('Run `ktx sl` to see available sources.');
});
it('singularizes the footer when there is one row', () => {

View file

@ -130,7 +130,7 @@ export async function runKtxKnowledge(
}
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
let emptyMessage = `No local wiki pages matched "${args.query}"`;
let emptyHint = 'Run `ktx wiki list` to inspect available pages.';
let emptyHint = 'Run `ktx wiki` to inspect available pages.';
if (results.length === 0 && mode !== 'json') {
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
if (pages.length === 0) {

View file

@ -198,8 +198,8 @@ describe('MemoryFlowTuiApp', () => {
expect(frame).toContain('order lifecycle');
expect(frame).toContain('customer metrics');
expect(frame).toContain('KTX finished ingesting your data');
expect(frame).toContain('ktx sl list');
expect(frame).toContain('ktx wiki list');
expect(frame).toContain('ktx sl');
expect(frame).toContain('ktx wiki');
expect(frame).not.toContain('ktx serve --mcp stdio --user-id local');
expect(frame).not.toContain(['ktx', 'ask'].join(' '));
expect(frame).not.toContain(['ktx', 'mcp'].join(' '));

View file

@ -10,8 +10,8 @@ describe('KTX demo next steps', () => {
it('uses supported context-build commands before agent usage', () => {
expect(KTX_CONTEXT_BUILD_COMMANDS).toEqual([
{
command: 'ktx ingest --all',
description: 'Build or refresh agent-ready context from configured connections',
command: 'ktx ingest',
description: 'Build or refresh agent-ready context from all configured connections',
},
{
command: 'ktx status',
@ -27,11 +27,11 @@ describe('KTX demo next steps', () => {
description: 'Verify project setup and context readiness',
},
{
command: 'ktx sl list',
command: 'ktx sl',
description: 'Inspect generated semantic-layer sources',
},
{
command: 'ktx wiki list',
command: 'ktx wiki',
description: 'Inspect generated wiki pages',
},
]);
@ -67,7 +67,7 @@ describe('KTX demo next steps', () => {
expect(rendered).toContain('Build KTX context next.');
expect(rendered).toContain('Run ingest to build database schema context before context-source ingest.');
expect(rendered).toContain('ktx ingest --all');
expect(rendered).toContain('ktx ingest');
expect(rendered).not.toContain('resume');
expect(rendered).not.toContain('scan');
expect(rendered).toContain('ktx status');

View file

@ -1,7 +1,7 @@
export const KTX_CONTEXT_BUILD_COMMANDS = [
{
command: 'ktx ingest --all',
description: 'Build or refresh agent-ready context from configured connections',
command: 'ktx ingest',
description: 'Build or refresh agent-ready context from all configured connections',
},
{
command: 'ktx status',
@ -15,11 +15,11 @@ export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
description: 'Verify project setup and context readiness',
},
{
command: 'ktx sl list',
command: 'ktx sl',
description: 'Inspect generated semantic-layer sources',
},
{
command: 'ktx wiki list',
command: 'ktx wiki',
description: 'Inspect generated wiki pages',
},
] as const;

View file

@ -124,12 +124,15 @@ describe('buildPublicIngestPlan', () => {
});
});
it('rejects bare non-interactive ingest until the interactive confirmation slice exists', () => {
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
it('treats a bare invocation (no connection id, no --all) as all configured connections', () => {
const project = projectWithConnections({
warehouse: { driver: 'postgres' },
docs: { driver: 'notion' },
});
expect(() => buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: false })).toThrow(
'Context build requires a connection id or all targets',
);
const plan = buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: false });
expect(plan.targets.map((target) => target.connectionId).sort()).toEqual(['docs', 'warehouse']);
});
it('resolves database depth from flags, stored context, and defaults', () => {

View file

@ -469,14 +469,11 @@ export function buildPublicIngestPlan(
scanMode?: Extract<KtxScanArgs, { command: 'run' }>['mode'];
},
): KtxPublicIngestPlan {
if (!args.all && !args.targetConnectionId) {
throw new Error('Context build requires a connection id or all targets');
}
const allConnections = args.all || !args.targetConnectionId;
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
const selected = args.all ? entries : entries.filter(([connectionId]) => connectionId === args.targetConnectionId);
const selected = allConnections ? entries : entries.filter(([connectionId]) => connectionId === args.targetConnectionId);
if (!args.all && selected.length === 0) {
if (!allConnections && selected.length === 0) {
throw new Error(`Connection "${args.targetConnectionId}" is not configured in ktx.yaml`);
}
if (selected.length === 0) {

View file

@ -169,7 +169,7 @@ describe('setup agents', () => {
expect(skill).toContain(`--project-dir ${tempDir}`);
expect(skill).toContain('must not print secrets');
expect(skill).toContain('status --json');
expect(skill).toContain('sl list --json');
expect(skill).toContain('sl --json');
expect(skill).toContain('sl query');
expect(skill).toContain('--format json');
expect(skill).not.toContain('sl query --json');

View file

@ -569,8 +569,8 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'Available commands:',
'',
`- \`${ktxCommandLine(input.launcher, ['status', ...jsonProjectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...jsonProjectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'search', '<text>', ...jsonProjectDirArgs, '--connection-id', '<id>'])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', ...jsonProjectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', '<text>', ...jsonProjectDirArgs, '--connection-id', '<id>'])}\``,
`- \`${ktxCommandLine(input.launcher, [
'sl',
'query',
@ -585,7 +585,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'--max-rows',
'100',
])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...jsonProjectDirArgs, '--limit', '10'])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', '<query>', ...jsonProjectDirArgs, '--limit', '10'])}\``,
'',
'Use semantic-layer queries before direct database access. Do not print secrets or credential references.',
'',

View file

@ -197,7 +197,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
await printSlSources({
rows: sources,
emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`,
emptyHint: 'Run `ktx sl list` to inspect available sources.',
emptyHint: 'Run `ktx sl` to inspect available sources.',
command: 'sl search',
output: args.output,
json: args.json,

View file

@ -158,7 +158,7 @@ describe('standalone built ktx CLI smoke', () => {
'fake',
]);
expect(run).toMatchObject({ code: 1, stdout: '' });
expect(run.stderr).toContain("unknown option '--connection-id'");
expect(run.stderr).toContain("unknown option '--adapter'");
});
it('rejects the removed agent command through the built binary', async () => {
@ -285,7 +285,7 @@ describe('standalone built ktx CLI smoke', () => {
expect(add.code).toBe(1);
expect(add.stdout).toBe('');
expect(add.stderr).toContain("unknown command 'add'");
expect(add.stderr).toMatch(/unknown (command|option)|too many arguments/);
const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(yaml).not.toContain('driver: notion');