feat(cli): shell completion for commands, flags, and entity names (#244)

* feat(completion): complete known argument values

* fix(completion): hide Commander-hidden subcommands from completions

Replace the `__`-prefix name heuristic with Commander's `_hidden` flag so
internal subcommands registered with { hidden: true } (e.g. `mcp serve-internal`)
are excluded from completions, mirroring `ktx --help`.

* test: cover wiki and sl read command routing

* test: cover raw wiki and sl reads

* feat: add wiki read command

* feat: add sl read command

* feat: complete read command entity names

* docs: document wiki and sl read commands

* test: include read commands in command tree

* feat(sl): read and validate unique sources by name

* feat(sl): make read and validate connection id optional

* fix(completion): dedupe semantic source names

* docs(sl): document connection-optional read and validate

* fix(sl): require connection id for query command

* docs(sl): clarify query connection requirement

* fix(completion): don't resolve option values as subcommands

resolveCommand skipped flag tokens but not the value consumed by a
value-taking option in the `--flag value` form, so a connection id like
`query` was matched as the `sl query` subcommand and yielded no `sl`
completions. Track value-taking options and skip their consumed value
before matching subcommands.

* test(telemetry): assert first-run notice via TELEMETRY_NOTICE constant

CI (which tests this branch merged with main) failed because #243 changed
the first-run notice wording in identity.ts (dropped "anonymous") but left
this test grepping for the old literal 'ktx collects anonymous usage data',
so indexOf returned -1. Assert against the exported TELEMETRY_NOTICE
constant instead so the test tracks the source of truth and cannot drift
when the notice text changes again.
This commit is contained in:
Andrey Avtomonov 2026-05-31 23:44:33 +02:00 committed by GitHub
parent c196d1f192
commit d320d54ab2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1596 additions and 54 deletions

View file

@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings';
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
import { registerCompletionCommands } from './commands/completion-commands.js';
import { registerConnectionCommands } from './commands/connection-commands.js';
import { registerIngestCommands } from './commands/ingest-commands.js';
import { registerWikiCommands } from './commands/knowledge-commands.js';
@ -431,6 +432,11 @@ export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record<
export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
const program = createBaseProgram(options.packageInfo, options.io);
program.hook('preAction', async (_thisCommand, actionCommand) => {
// The hidden completion command must stay silent and side-effect free: skip
// the telemetry notice, command span, and project checks entirely.
if (commandPath(actionCommand as CommandPathNode).includes('__complete')) {
return;
}
const telemetry = await import('./telemetry/index.js');
options.setTelemetryModule?.(telemetry);
await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo);
@ -476,6 +482,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
registerStatusCommands(program, context);
registerMcpCommands(program, context);
registerAdminCommands(program, context);
registerCompletionCommands(program, context);
return program;
}

View file

@ -16,7 +16,11 @@ export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode {
description: command.description(),
aliases: command.aliases(),
arguments: command.registeredArguments.map(formatArgumentDeclaration),
children: command.commands.map((child) => walkCommandTree(child)),
// Internal commands (e.g. the shell-completion helper `__complete`) use a
// `__` prefix and are omitted from the human-facing command tree.
children: command.commands
.filter((child) => !child.name().startsWith('__'))
.map((child) => walkCommandTree(child)),
};
}

View file

@ -0,0 +1,44 @@
import { Argument, type Command } from '@commander-js/extra-typings';
import type { KtxCliCommandContext } from '../cli-program.js';
import { computeCompletions } from '../completion/complete-engine.js';
import { completionScript } from '../completion/completion-scripts.js';
import { createProjectCompletionProviders } from '../completion/dynamic-candidates.js';
import { profileMark } from '../startup-profile.js';
profileMark('module:commands/completion-commands');
export function registerCompletionCommands(program: Command, context: KtxCliCommandContext): void {
program
.command('completion')
.description('Print a shell completion script for ktx')
.addArgument(new Argument('<shell>', 'Target shell').choices(['zsh', 'bash']))
.addHelpText(
'after',
'\nEnable completion by adding the matching line to your shell startup file:\n' +
' zsh: eval "$(ktx completion zsh)"\n' +
' bash: eval "$(ktx completion bash)"\n',
)
.action((shell) => {
context.io.stdout.write(completionScript(shell));
});
// Hidden command invoked by the generated shell scripts. It must only ever
// print newline-separated candidates to stdout and exit 0, so a TAB press is
// never disrupted by an error, a telemetry notice, or a parse failure.
program
.command('__complete', { hidden: true })
.argument('[words...]')
.allowUnknownOption(true)
.helpOption(false)
.action(async (words: string[]) => {
try {
const candidates = await computeCompletions(program, words, createProjectCompletionProviders());
if (candidates.length > 0) {
context.io.stdout.write(`${candidates.join('\n')}\n`);
}
} catch {
// Swallow: completion must never break the shell.
}
context.setExitCode(0);
});
}

View file

@ -21,9 +21,9 @@ function isDebugEnabled(command: CommandWithGlobalOptions): boolean {
}
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
program
const wiki = program
.command('wiki')
.description('List or search local wiki pages')
.description('List, search, or read local wiki pages')
.usage('[options] [query...]')
.argument('[query...]', 'Search query; omit to list all pages')
.option('--user-id <id>', 'Local user id', 'local')
@ -76,4 +76,18 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
});
},
);
wiki
.command('read')
.description('Read a wiki page file by key')
.argument('<key>', 'Wiki page key')
.action(async (key: string, _options, command) => {
const parentOpts = command.parent?.opts() as { userId?: string } | undefined;
await runKnowledgeArgs(context, {
command: 'read',
projectDir: resolveCommandProjectDir(command),
key,
userId: parentOpts?.userId ?? 'local',
});
});
}

View file

@ -94,19 +94,28 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
},
);
sl.command('validate')
.description('Validate a semantic-layer source (set --connection-id on `ktx sl`)')
sl.command('read')
.description('Read a semantic-layer source YAML file')
.argument('<sourceName>', 'Semantic-layer source name')
.action(async (sourceName: string, _options, command) => {
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
await runSlArgs(context, {
command: 'read',
projectDir: resolveCommandProjectDir(command),
connectionId: parentOpts?.connectionId,
sourceName,
});
});
sl.command('validate')
.description('Validate a semantic-layer source')
.argument('<sourceName>', 'Semantic-layer source name')
.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: connectionId as string,
connectionId: parentOpts?.connectionId,
sourceName,
});
});
@ -131,10 +140,14 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
throw new Error('sl query requires at least one --measure');
}
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");
}
const args = slQueryCommandSchema.parse({
command: 'query',
projectDir: resolveCommandProjectDir(command),
connectionId: parentOpts?.connectionId,
connectionId,
...(options.queryFile
? { queryFile: options.queryFile }
: {

View file

@ -0,0 +1,172 @@
import type { CommandUnknownOpts, Option } from '@commander-js/extra-typings';
/**
* Dynamic completion candidates that depend on project state (semantic-layer
* source names, wiki page keys, connection ids). Injected so the engine stays
* pure and unit-testable without touching the filesystem.
*/
export interface CompletionProviders {
/** Candidate operands for a positional argument of the active command path. */
positionalCandidates(commandPath: string[], typedTokens: string[]): Promise<string[]>;
/** Candidate values for an option that has no static `choices` (e.g. `--connection-id`). */
optionValueCandidates(commandPath: string[], optionFlag: string, typedTokens: string[]): Promise<string[]>;
}
interface ResolvedCommand {
command: CommandUnknownOpts;
/** Subcommand names from the root down to the active command (root name excluded). */
commandPath: string[];
}
function isHiddenCommand(command: CommandUnknownOpts): boolean {
// Completion mirrors `ktx --help`: commands registered with `{ hidden: true }`
// (the `__complete` helper and `mcp serve-internal`) are internal and must not
// surface. Commander exposes this only through the private `_hidden` field its
// own help renderer reads, so a name heuristic like a `__` prefix is not enough.
return (command as { _hidden?: boolean })._hidden === true;
}
function resolveCommand(program: CommandUnknownOpts, typedTokens: string[]): ResolvedCommand {
let command: CommandUnknownOpts = program;
const commandPath: string[] = [];
for (let index = 0; index < typedTokens.length; index += 1) {
const token = typedTokens[index];
if (token.startsWith('-')) {
// A value-taking option in the `--flag value` form consumes the next token
// as its value, so skip that value before matching subcommands. Otherwise a
// connection id like `query` would be resolved as the `sl query` subcommand
// instead of being treated as the `--connection-id` value. The `--flag=value`
// form carries its own value and consumes nothing extra.
if (!token.includes('=')) {
const option = findOption(command, token);
if (option && !option.isBoolean()) {
index += 1;
}
}
continue;
}
const sub = command.commands.find((candidate) => candidate.name() === token || candidate.aliases().includes(token));
if (sub) {
command = sub;
commandPath.push(sub.name());
}
}
return { command, commandPath };
}
function collectOptions(command: CommandUnknownOpts): Option[] {
const options: Option[] = [];
let current: CommandUnknownOpts | null = command;
while (current) {
options.push(...current.options);
current = current.parent;
}
return options;
}
function findOption(command: CommandUnknownOpts, flag: string): Option | undefined {
return collectOptions(command).find((option) => option.long === flag || option.short === flag);
}
function isRepeatableOption(option: Option): boolean {
// Variadic options, and options backed by a collector with an array default
// (e.g. `--measure`/`--dimension`), may be supplied more than once.
return option.variadic || Array.isArray(option.defaultValue);
}
function flagCandidates(command: CommandUnknownOpts, typedTokens: string[]): string[] {
const present = new Set(typedTokens.filter((token) => token.startsWith('-')));
const candidates: string[] = [];
for (const option of collectOptions(command)) {
if (option.hidden || !option.long) {
continue;
}
if (present.has(option.long) && !isRepeatableOption(option)) {
continue;
}
candidates.push(option.long);
}
return candidates;
}
async function optionValueCandidates(
resolved: ResolvedCommand,
option: Option,
typedTokens: string[],
providers: CompletionProviders,
): Promise<string[]> {
if (option.argChoices && option.argChoices.length > 0) {
return option.argChoices;
}
return providers.optionValueCandidates(resolved.commandPath, option.long ?? option.name(), typedTokens);
}
function dedupeSortFilter(candidates: string[], partial: string): string[] {
const seen = new Set<string>();
const matches: string[] = [];
for (const candidate of candidates) {
if (!candidate.startsWith(partial) || seen.has(candidate)) {
continue;
}
seen.add(candidate);
matches.push(candidate);
}
return matches.sort();
}
/**
* Compute completion candidates for the partial last element of `words`
* (everything the shell has on the line after `ktx`). The active command and
* its flags are derived by walking the live Commander tree, so completion never
* drifts from the real command structure.
*/
export async function computeCompletions(
program: CommandUnknownOpts,
words: string[],
providers: CompletionProviders,
): Promise<string[]> {
const partial = words.length > 0 ? (words[words.length - 1] ?? '') : '';
const typedTokens = words.slice(0, -1);
const resolved = resolveCommand(program, typedTokens);
// (a) Option value via the `--opt=value` form.
const equalsMatch = /^(--[^=]+)=(.*)$/.exec(partial);
if (equalsMatch) {
const [, flag, valuePartial] = equalsMatch;
const option = findOption(resolved.command, flag);
if (!option || option.isBoolean()) {
return [];
}
const values = await optionValueCandidates(resolved, option, typedTokens, providers);
return dedupeSortFilter(
values.map((value) => `${flag}=${value}`),
`${flag}=${valuePartial}`,
);
}
// (b) Option value via the `--opt value` form (previous token is a value-taking option).
const previous = typedTokens[typedTokens.length - 1];
if (previous && previous.startsWith('-') && !partial.startsWith('-')) {
const option = findOption(resolved.command, previous);
if (option && !option.isBoolean()) {
return dedupeSortFilter(await optionValueCandidates(resolved, option, typedTokens, providers), partial);
}
}
// (c) Flag completion.
if (partial.startsWith('-')) {
return dedupeSortFilter(flagCandidates(resolved.command, typedTokens), partial);
}
// (d) Positional: subcommand names union static argument choices union dynamic operand candidates.
const candidates: string[] = resolved.command.commands
.filter((sub) => !isHiddenCommand(sub))
.map((sub) => sub.name());
for (const argument of resolved.command.registeredArguments) {
if (argument.argChoices) {
candidates.push(...argument.argChoices);
}
}
candidates.push(...(await providers.positionalCandidates(resolved.commandPath, typedTokens)));
return dedupeSortFilter(candidates, partial);
}

View file

@ -0,0 +1,39 @@
// Static shell completion scripts emitted by `ktx completion <shell>`.
//
// Both scripts gather the words on the current command line (excluding the
// leading `ktx`), append the partial word under the cursor, and delegate to the
// hidden `ktx __complete` command, which prints newline-separated candidates.
// All command/flag/entity knowledge lives in `ktx __complete` so these scripts
// never have to encode the command tree.
//
// Lines are single-quoted JS strings so the shell `${...}` expansions are
// emitted verbatim (a template literal would try to interpolate them).
const ZSH_SCRIPT = [
'#compdef ktx',
'_ktx() {',
' local -a candidates',
' local out',
' out="$(ktx __complete -- "${words[@]:1:$((CURRENT-1))}" 2>/dev/null)" || return 0',
' candidates=("${(@f)out}")',
' compadd -- $candidates',
'}',
'compdef _ktx ktx',
'',
].join('\n');
const BASH_SCRIPT = [
'_ktx() {',
' local cur out',
' cur="${COMP_WORDS[COMP_CWORD]}"',
' out="$(ktx __complete -- "${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null)" || { COMPREPLY=(); return 0; }',
" local IFS=$'\\n'",
' COMPREPLY=($(compgen -W "${out}" -- "$cur"))',
'}',
'complete -F _ktx ktx',
'',
].join('\n');
export function completionScript(shell: 'zsh' | 'bash'): string {
return shell === 'zsh' ? ZSH_SCRIPT : BASH_SCRIPT;
}

View file

@ -0,0 +1,103 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { KtxLocalProject } from '../context/project/project.js';
import { resolveKtxProjectDir } from '../project-resolver.js';
import type { CompletionProviders } from './complete-engine.js';
/** Extract an option value from already-typed tokens (`--flag value` or `--flag=value`). */
function extractOptionValue(tokens: string[], flag: string): string | undefined {
const prefix = `${flag}=`;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token === flag) {
const next = tokens[index + 1];
if (next !== undefined && !next.startsWith('-')) {
return next;
}
} else if (token.startsWith(prefix)) {
return token.slice(prefix.length);
}
}
return undefined;
}
/**
* Resolve and load the project the user is completing against. Honors a
* `--project-dir` typed on the line, then `KTX_PROJECT_DIR`, then the nearest
* `ktx.yaml`. Returns null (no completions) when there is no project, without
* creating any files.
*/
async function loadCompletionProject(typedTokens: string[]): Promise<KtxLocalProject | null> {
const explicitProjectDir = extractOptionValue(typedTokens, '--project-dir');
const projectDir = resolveKtxProjectDir(explicitProjectDir !== undefined ? { explicitProjectDir } : {});
if (!existsSync(join(projectDir, 'ktx.yaml'))) {
return null;
}
const { loadKtxProject } = await import('../context/project/project.js');
return loadKtxProject({ projectDir });
}
async function sourceNames(typedTokens: string[]): Promise<string[]> {
const project = await loadCompletionProject(typedTokens);
if (!project) {
return [];
}
const connectionId = extractOptionValue(typedTokens, '--connection-id');
const { listLocalSlSources } = await import('../context/sl/local-sl.js');
const summaries = await listLocalSlSources(project, connectionId !== undefined ? { connectionId } : {});
return [...new Set(summaries.map((summary) => summary.name))];
}
async function wikiPageKeys(typedTokens: string[]): Promise<string[]> {
const project = await loadCompletionProject(typedTokens);
if (!project) {
return [];
}
const userId = extractOptionValue(typedTokens, '--user-id');
const { listLocalKnowledgePageKeys } = await import('../context/wiki/local-knowledge.js');
return listLocalKnowledgePageKeys(project, userId !== undefined ? { userId } : {});
}
async function connectionIds(typedTokens: string[]): Promise<string[]> {
const project = await loadCompletionProject(typedTokens);
if (!project) {
return [];
}
return Object.keys(project.config.connections).sort();
}
/**
* Project-backed completion providers. Every entry swallows its own errors so a
* failed lookup never breaks the shell completion degrades to commands/flags.
*/
export function createProjectCompletionProviders(): CompletionProviders {
return {
async positionalCandidates(commandPath, typedTokens) {
try {
const key = commandPath.join(' ');
if (key === 'sl read' || key === 'sl validate') {
return await sourceNames(typedTokens);
}
if (key === 'wiki read') {
return await wikiPageKeys(typedTokens);
}
if (key === 'connection test' || key === 'ingest') {
return await connectionIds(typedTokens);
}
return [];
} catch {
return [];
}
},
async optionValueCandidates(_commandPath, optionFlag, typedTokens) {
try {
if (optionFlag === '--connection-id' || optionFlag === '--connection') {
return await connectionIds(typedTokens);
}
return [];
} catch {
return [];
}
},
};
}

View file

@ -50,6 +50,7 @@ export interface LocalSlSearchInput {
pglite?: PgliteSlSearchPrototypeOwnerOptions;
}
/** @internal */
export interface LocalSlSource extends LocalSlSourceSummary {
yaml: string;
}
@ -63,6 +64,11 @@ export interface LocalSlValidationResult {
errors: string[];
}
export type ResolvedSlSource =
| { kind: 'found'; source: LocalSlSource }
| { kind: 'not-found' }
| { kind: 'ambiguous'; connectionIds: string[] };
const LOCAL_AUTHOR = 'ktx';
const LOCAL_AUTHOR_EMAIL = 'ktx@example.com';
@ -311,6 +317,7 @@ export async function writeLocalSlSource(
);
}
/** @internal */
export async function readLocalSlSource(
project: KtxLocalProject,
input: { connectionId: string; sourceName: string },
@ -331,6 +338,41 @@ export async function readLocalSlSource(
}
}
export async function resolveLocalSlSource(
project: KtxLocalProject,
input: { sourceName: string; connectionId?: string },
): Promise<ResolvedSlSource> {
if (input.connectionId !== undefined) {
const source = await readLocalSlSource(project, {
connectionId: input.connectionId,
sourceName: input.sourceName,
});
return source ? { kind: 'found', source } : { kind: 'not-found' };
}
const summaries = await listLocalSlSources(project, {});
const matches = summaries.filter((summary) => summary.name === input.sourceName);
if (matches.length === 0) {
return { kind: 'not-found' };
}
if (matches.length > 1) {
return {
kind: 'ambiguous',
connectionIds: [...new Set(matches.map((match) => match.connectionId))].sort(),
};
}
const match = matches[0];
if (match === undefined) {
return { kind: 'not-found' };
}
const source = await readLocalSlSource(project, {
connectionId: match.connectionId,
sourceName: input.sourceName,
});
return source ? { kind: 'found', source } : { kind: 'not-found' };
}
export async function listLocalSlSources(
project: KtxLocalProject,
input: { connectionId?: string } = {},

View file

@ -201,6 +201,32 @@ export async function listLocalKnowledgePages(
return pages.sort((left, right) => left.path.localeCompare(right.path));
}
/**
* List wiki page keys without reading or parsing file contents.
*
* Keys are derived purely from file paths, so this stays cheap enough for
* shell tab-completion (unlike `listLocalKnowledgePages`, which reads every
* page to populate summaries).
*/
export async function listLocalKnowledgePageKeys(
project: KtxLocalProject,
input: { userId?: string } = {},
): Promise<string[]> {
const userId = input.userId ?? 'local';
const keys = new Set<string>();
for (const scope of ['GLOBAL', 'USER'] as const) {
const root = scope === 'GLOBAL' ? 'wiki/global' : `wiki/user/${assertSafePathToken('user id', userId)}`;
const listed = await project.fileStore.listFiles(root);
for (const path of listed.files.filter((file) => file.endsWith('.md'))) {
const key = keyFromKnowledgePath(path, scope, userId);
if (key) {
keys.add(key);
}
}
}
return [...keys].sort();
}
function scorePage(page: LocalKnowledgePage, terms: string[]): number {
const haystack = buildKnowledgeSearchText(page.key, page.summary, page.content, page.tags).toLowerCase();
return terms.some((term) => haystack.includes(term)) ? 3 : 0;

View file

@ -1,7 +1,13 @@
import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js';
import type { KtxEmbeddingPort } from './context/core/embedding.js';
import { loadKtxProject } from './context/project/project.js';
import { type LocalKnowledgeSearchResult, type LocalKnowledgeSummary, listLocalKnowledgePages, searchLocalKnowledgePages as defaultSearchLocalKnowledgePages } from './context/wiki/local-knowledge.js';
import {
type LocalKnowledgeSearchResult,
type LocalKnowledgeSummary,
listLocalKnowledgePages,
readLocalKnowledgePage,
searchLocalKnowledgePages as defaultSearchLocalKnowledgePages,
} from './context/wiki/local-knowledge.js';
import {
resolveProjectEmbeddingProvider,
type EmbeddingProviderResolution,
@ -22,7 +28,8 @@ export type KtxKnowledgeArgs =
limit?: number;
debug?: boolean;
cliVersion: string;
};
}
| { command: 'read'; projectDir: string; key: string; userId: string };
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
@ -128,6 +135,15 @@ export async function runKtxKnowledge(
});
return 0;
}
if (args.command === 'read') {
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
if (!page) {
throw new Error(`No wiki page found for key '${args.key}'`);
}
const raw = await project.fileStore.readFile(page.path);
io.stdout.write(raw.content);
return 0;
}
if (args.command === 'search') {
const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io);
const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages;

View file

@ -7,7 +7,14 @@ import type { KtxEmbeddingPort } from './context/core/embedding.js';
import type { KtxSemanticLayerComputePort } from './context/daemon/semantic-layer-compute.js';
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
import { compileLocalSlQuery } from './context/sl/local-query.js';
import { listLocalSlSources, readLocalSlSource, searchLocalSlSources as defaultSearchLocalSlSources, validateLocalSlSource, type LocalSlSourceSearchResult, type LocalSlSourceSummary } from './context/sl/local-sl.js';
import {
listLocalSlSources,
resolveLocalSlSource,
searchLocalSlSources as defaultSearchLocalSlSources,
validateLocalSlSource,
type LocalSlSourceSearchResult,
type LocalSlSourceSummary,
} from './context/sl/local-sl.js';
import type { SemanticLayerQueryInput } from './context/sl/types.js';
import {
resolveProjectEmbeddingProvider,
@ -45,7 +52,8 @@ export type KtxSlArgs =
json?: boolean;
cliVersion: string;
}
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
| { command: 'read'; projectDir: string; connectionId?: string; sourceName: string }
| { command: 'validate'; projectDir: string; connectionId?: string; sourceName: string }
| {
command: 'query';
projectDir: string;
@ -185,6 +193,12 @@ async function readSlQueryFile(path: string): Promise<SemanticLayerQueryInput> {
return parsed as SemanticLayerQueryInput;
}
function ambiguousSourceMessage(sourceName: string, connectionIds: readonly string[]): string {
return `Source '${sourceName}' exists in multiple connections: ${connectionIds.join(
', ',
)}. Re-run with --connection-id <id>.`;
}
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
const startedAt = performance.now();
let queryForTelemetry: SemanticLayerQueryInput | undefined;
@ -232,25 +246,50 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
});
return 0;
}
if (args.command === 'validate') {
const source = await readLocalSlSource(project, {
if (args.command === 'read') {
const resolved = await resolveLocalSlSource(project, {
connectionId: args.connectionId,
sourceName: args.sourceName,
});
if (!source) {
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
if (resolved.kind === 'not-found') {
throw new Error(
args.connectionId !== undefined
? `No semantic-layer source '${args.sourceName}' for connection '${args.connectionId}'`
: `No semantic-layer source '${args.sourceName}'`,
);
}
const result = await validateLocalSlSource(source.yaml, {
project,
if (resolved.kind === 'ambiguous') {
throw new Error(ambiguousSourceMessage(args.sourceName, resolved.connectionIds));
}
io.stdout.write(resolved.source.yaml);
return 0;
}
if (args.command === 'validate') {
const resolved = await resolveLocalSlSource(project, {
connectionId: args.connectionId,
sourceName: args.sourceName,
});
if (resolved.kind === 'not-found') {
throw new Error(
args.connectionId !== undefined
? `Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`
: `Semantic-layer source "${args.sourceName}" was not found`,
);
}
if (resolved.kind === 'ambiguous') {
throw new Error(ambiguousSourceMessage(args.sourceName, resolved.connectionIds));
}
const result = await validateLocalSlSource(resolved.source.yaml, {
project,
connectionId: resolved.source.connectionId,
sourceName: args.sourceName,
});
await emitTelemetryEvent({
name: 'sl_validate_completed',
projectDir: args.projectDir,
io,
fields: {
sourceCount: source ? 1 : 0,
sourceCount: 1,
modelCount: 0,
validationErrorCount: result.valid ? 0 : result.errors.length,
outcome: result.valid ? 'ok' : 'error',
@ -263,7 +302,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
}
return 1;
}
io.stdout.write(`Valid semantic-layer source: ${args.connectionId}/${args.sourceName}\n`);
io.stdout.write(`Valid semantic-layer source: ${resolved.source.connectionId}/${args.sourceName}\n`);
return 0;
}
if (args.command === 'query') {

View file

@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runCommanderKtxCli } from '../src/cli-program.js';
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js';
import { TELEMETRY_NOTICE } from '../src/telemetry/identity.js';
function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } {
let stdout = '';
@ -85,7 +86,7 @@ describe('runCommanderKtxCli telemetry', () => {
expect(statusIo.stderr()).toContain('"connectionCount"');
expect(statusIo.stderr()).not.toContain(tempDir);
const noticeIndex = statusIo.stderr().indexOf('ktx collects anonymous usage data');
const noticeIndex = statusIo.stderr().indexOf(TELEMETRY_NOTICE);
const firstTelemetryIndex = statusIo.stderr().indexOf('[telemetry]');
expect(noticeIndex).toBeGreaterThanOrEqual(0);
expect(firstTelemetryIndex).toBeGreaterThan(noticeIndex);

View file

@ -0,0 +1,157 @@
import { Command } from '@commander-js/extra-typings';
import { describe, expect, it, vi } from 'vitest';
import type { KtxCliCommandContext } from '../../src/cli-program.js';
import { registerWikiCommands } from '../../src/commands/knowledge-commands.js';
import { registerSlCommands } from '../../src/commands/sl-commands.js';
function makeContext(overrides: Partial<KtxCliCommandContext> = {}): KtxCliCommandContext {
let exitCode = 0;
return {
io: {
stdout: { write: vi.fn() },
stderr: { write: vi.fn() },
},
deps: {},
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
setExitCode: (code) => {
exitCode = code;
},
runInit: vi.fn(),
writeDebug: vi.fn(),
...overrides,
get exitCode() {
return exitCode;
},
} as KtxCliCommandContext;
}
describe('wiki and sl read command routing', () => {
it('routes wiki read through the knowledge runner', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const knowledge = vi.fn(async () => 0);
const context = makeContext({ deps: { knowledge } });
registerWikiCommands(program, context);
await expect(
program.parseAsync(['--project-dir', '/tmp/ktx-project', 'wiki', 'read', 'metrics-revenue'], {
from: 'user',
}),
).resolves.toBe(program);
expect(knowledge).toHaveBeenCalledWith(
{
command: 'read',
projectDir: '/tmp/ktx-project',
key: 'metrics-revenue',
userId: 'local',
},
context.io,
);
});
it('routes wiki read with the parent --user-id option', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const knowledge = vi.fn(async () => 0);
const context = makeContext({ deps: { knowledge } });
registerWikiCommands(program, context);
await expect(
program.parseAsync(
['--project-dir', '/tmp/ktx-project', 'wiki', '--user-id', 'alex', 'read', 'handoff'],
{ from: 'user' },
),
).resolves.toBe(program);
expect(knowledge).toHaveBeenCalledWith(
{
command: 'read',
projectDir: '/tmp/ktx-project',
key: 'handoff',
userId: 'alex',
},
context.io,
);
});
it('routes sl read through the semantic-layer runner', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const sl = vi.fn(async () => 0);
const context = makeContext({ deps: { sl } });
registerSlCommands(program, context);
await expect(
program.parseAsync(
['--project-dir', '/tmp/ktx-project', 'sl', '--connection-id', 'warehouse', 'read', 'orders'],
{ from: 'user' },
),
).resolves.toBe(program);
expect(sl).toHaveBeenCalledWith(
{
command: 'read',
projectDir: '/tmp/ktx-project',
connectionId: 'warehouse',
sourceName: 'orders',
},
context.io,
);
});
it('routes sl read without --connection-id through the semantic-layer runner', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const sl = vi.fn(async () => 0);
const context = makeContext({ deps: { sl } });
registerSlCommands(program, context);
await expect(
program.parseAsync(['--project-dir', '/tmp/ktx-project', 'sl', 'read', 'orders'], { from: 'user' }),
).resolves.toBe(program);
expect(sl).toHaveBeenCalledWith(
{
command: 'read',
projectDir: '/tmp/ktx-project',
connectionId: undefined,
sourceName: 'orders',
},
context.io,
);
});
it('routes sl validate without --connection-id through the semantic-layer runner', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const sl = vi.fn(async () => 0);
const context = makeContext({ deps: { sl } });
registerSlCommands(program, context);
await expect(
program.parseAsync(['--project-dir', '/tmp/ktx-project', 'sl', 'validate', 'orders'], { from: 'user' }),
).resolves.toBe(program);
expect(sl).toHaveBeenCalledWith(
{
command: 'validate',
projectDir: '/tmp/ktx-project',
connectionId: undefined,
sourceName: 'orders',
},
context.io,
);
});
it('keeps sl query requiring --connection-id before invoking the runner', async () => {
const program = new Command().exitOverride().option('--project-dir <path>');
const sl = vi.fn(async () => 0);
const context = makeContext({ deps: { sl } });
registerSlCommands(program, context);
await expect(
program.parseAsync(
['--project-dir', '/tmp/ktx-project', 'sl', 'query', '--measure', 'orders.count'],
{ from: 'user' },
),
).rejects.toThrow("error: required option '--connection-id <id>' not specified");
expect(sl).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,137 @@
import type { Command } from '@commander-js/extra-typings';
import { describe, expect, it } from 'vitest';
import { buildKtxProgram } from '../../src/cli-program.js';
import type { KtxCliIo, KtxCliPackageInfo } from '../../src/cli-runtime.js';
import { type CompletionProviders, computeCompletions } from '../../src/completion/complete-engine.js';
function stubIo(): KtxCliIo {
return { stdout: { isTTY: false, columns: 80, write: () => {} }, stderr: { write: () => {} } };
}
function stubPackageInfo(): KtxCliPackageInfo {
return { name: '@kaelio/ktx', version: '0.0.0-test' };
}
function buildProgram(): Command {
return buildKtxProgram({ io: stubIo(), deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 });
}
const SOURCES = ['orders', 'customers'];
const WIKI_KEYS = ['revenue', 'churn'];
const CONNECTIONS = ['warehouse'];
function fakeProviders(overrides: Partial<CompletionProviders> = {}): CompletionProviders {
return {
async positionalCandidates(commandPath) {
const key = commandPath.join(' ');
if (key === 'sl read' || key === 'sl validate') {
return SOURCES;
}
if (key === 'wiki read') {
return WIKI_KEYS;
}
return [];
},
async optionValueCandidates(_commandPath, optionFlag) {
return optionFlag === '--connection-id' ? CONNECTIONS : [];
},
...overrides,
};
}
function complete(words: string[], providers: CompletionProviders = fakeProviders()): Promise<string[]> {
return computeCompletions(buildProgram(), words, providers);
}
describe('computeCompletions', () => {
it('lists top-level commands and hides internal ones', async () => {
const result = await complete(['']);
expect(result).toContain('sl');
expect(result).toContain('wiki');
expect(result).toContain('completion');
expect(result).not.toContain('__complete');
});
it('filters top-level commands by prefix', async () => {
expect(await complete(['co'])).toEqual(['completion', 'connection']);
});
it('hides Commander-hidden subcommands such as `mcp serve-internal`', async () => {
const result = await complete(['mcp', '']);
expect(result).not.toContain('serve-internal');
expect(result).toEqual(['logs', 'start', 'status', 'stdio', 'stop']);
});
it('offers only sl subcommands at the bare sl positional', async () => {
expect(await complete(['sl', ''])).toEqual(['query', 'read', 'validate']);
});
it('offers source names for sl read and sl validate', async () => {
expect(await complete(['sl', 'read', ''])).toEqual(['customers', 'orders']);
expect(await complete(['sl', 'validate', ''])).toEqual(['customers', 'orders']);
});
it('offers only the wiki read subcommand at the bare wiki positional', async () => {
expect(await complete(['wiki', ''])).toEqual(['read']);
});
it('offers wiki page keys for wiki read', async () => {
expect(await complete(['wiki', 'read', ''])).toEqual(['churn', 'revenue']);
});
it('does not complete entity names for bare search positionals', async () => {
expect(await complete(['sl', 'o'])).toEqual([]);
expect(await complete(['wiki', 'r'])).toEqual(['read']);
});
it('completes flags (own + inherited globals) when the partial starts with a dash', async () => {
const result = await complete(['sl', '-']);
expect(result).toContain('--connection-id');
expect(result).toContain('--output');
expect(result).toContain('--json');
expect(result).toContain('--debug');
expect(result).toContain('--project-dir');
});
it('completes option choices for the `--opt value` form', async () => {
expect(await complete(['sl', '--output', ''])).toEqual(['json', 'plain', 'pretty']);
});
it('completes option choices for the `--opt=value` form', async () => {
expect(await complete(['sl', '--output=pr'])).toEqual(['--output=pretty']);
});
it('completes option values from a provider for options without static choices', async () => {
expect(await complete(['sl', '--connection-id', ''])).toEqual(['warehouse']);
});
it('falls through to positional completion after a boolean flag', async () => {
const result = await complete(['sl', '--json', '']);
expect(result).toEqual(['query', 'read', 'validate']);
});
it('does not treat a value-taking option value as a subcommand', async () => {
// A connection id that happens to match a subcommand name (`query`, `read`)
// is the `--connection-id` value, not a subcommand: the next positional must
// still offer the `sl` subcommands rather than resolving into `sl query`/`sl read`.
expect(await complete(['sl', '--connection-id', 'query', ''])).toEqual(['query', 'read', 'validate']);
expect(await complete(['sl', '--connection-id', 'read', ''])).toEqual(['query', 'read', 'validate']);
});
it('still returns subcommands/flags when dynamic providers yield nothing (no project)', async () => {
const empty = fakeProviders({
positionalCandidates: async () => [],
optionValueCandidates: async () => [],
});
expect(await complete(['sl', ''], empty)).toEqual(['query', 'read', 'validate']);
expect(await complete(['-'], empty)).toContain('--debug');
});
it('completes the completion command shell positional from its static choices', async () => {
expect(await complete(['completion', ''])).toEqual(['bash', 'zsh']);
});
it('filters positional argument choices by prefix', async () => {
expect(await complete(['completion', 'z'])).toEqual(['zsh']);
});
});

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { completionScript } from '../../src/completion/completion-scripts.js';
describe('completionScript', () => {
it('emits a zsh script that registers _ktx and delegates to ktx __complete', () => {
const script = completionScript('zsh');
expect(script).toContain('#compdef ktx');
expect(script).toContain('compdef _ktx ktx');
expect(script).toContain('ktx __complete --');
expect(script).toContain('compadd -- $candidates');
});
it('emits a bash script that registers _ktx and preserves newline-split candidates', () => {
const script = completionScript('bash');
expect(script).toContain('complete -F _ktx ktx');
expect(script).toContain('ktx __complete --');
expect(script).toContain("local IFS=$'\\n'");
expect(script).toContain('COMPREPLY=($(compgen -W "${out}" -- "$cur"))');
});
});

View file

@ -0,0 +1,103 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createProjectCompletionProviders } from '../../src/completion/dynamic-candidates.js';
const KTX_YAML = ['connections:', ' warehouse:', ' driver: sqlite', ' analytics:', ' driver: sqlite', ''].join(
'\n',
);
describe('createProjectCompletionProviders', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = await mkdtemp(join(tmpdir(), 'ktx-completion-'));
await writeFile(join(projectDir, 'ktx.yaml'), KTX_YAML, 'utf-8');
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
async function seedProjectEntities(): Promise<void> {
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
await writeFile(
join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'),
['name: orders', 'table: public.orders', 'grain: [order_id]', 'columns: []', ''].join('\n'),
'utf-8',
);
await mkdir(join(projectDir, 'semantic-layer', 'analytics'), { recursive: true });
await writeFile(
join(projectDir, 'semantic-layer', 'analytics', 'orders.yaml'),
['name: orders', 'table: public.analytics_orders', 'grain: [order_id]', 'columns: []', ''].join('\n'),
'utf-8',
);
await writeFile(
join(projectDir, 'semantic-layer', 'analytics', 'tickets.yaml'),
['name: tickets', 'table: public.tickets', 'grain: [ticket_id]', 'columns: []', ''].join('\n'),
'utf-8',
);
await mkdir(join(projectDir, 'wiki', 'global'), { recursive: true });
await writeFile(
join(projectDir, 'wiki', 'global', 'revenue.md'),
['---', 'summary: Revenue', 'tags: []', 'refs: []', 'sl_refs: []', '---', '', 'Revenue rules.', ''].join('\n'),
'utf-8',
);
}
it('completes connection ids for the `connection test` positional', async () => {
const providers = createProjectCompletionProviders();
const result = await providers.positionalCandidates(['connection', 'test'], ['--project-dir', projectDir]);
expect(result).toEqual(['analytics', 'warehouse']);
});
it('completes connection ids for the `ingest` positional', async () => {
const providers = createProjectCompletionProviders();
const result = await providers.positionalCandidates(['ingest'], ['--project-dir', projectDir]);
expect(result).toEqual(['analytics', 'warehouse']);
});
it('completes entity names only for read and validate subcommands', async () => {
await seedProjectEntities();
const providers = createProjectCompletionProviders();
await expect(providers.positionalCandidates(['sl'], ['--project-dir', projectDir])).resolves.toEqual([]);
await expect(providers.positionalCandidates(['sl', 'read'], ['--project-dir', projectDir])).resolves.toEqual([
'orders',
'tickets',
]);
await expect(providers.positionalCandidates(['sl', 'validate'], ['--project-dir', projectDir])).resolves.toEqual([
'orders',
'tickets',
]);
await expect(
providers.positionalCandidates(['sl', 'read'], ['--project-dir', projectDir, '--connection-id', 'warehouse']),
).resolves.toEqual(['orders']);
await expect(
providers.positionalCandidates(['sl', 'validate'], ['--project-dir', projectDir, '--connection-id', 'analytics']),
).resolves.toEqual(['orders', 'tickets']);
await expect(providers.positionalCandidates(['wiki'], ['--project-dir', projectDir])).resolves.toEqual([]);
await expect(providers.positionalCandidates(['wiki', 'read'], ['--project-dir', projectDir])).resolves.toEqual([
'revenue',
]);
});
it('returns no positional candidates outside a project', async () => {
const providers = createProjectCompletionProviders();
const result = await providers.positionalCandidates(['connection', 'test'], ['--project-dir', join(projectDir, 'nope')]);
expect(result).toEqual([]);
});
it('completes connection ids for the sql --connection option', async () => {
const providers = createProjectCompletionProviders();
const result = await providers.optionValueCandidates(['sql'], '--connection', ['--project-dir', projectDir]);
expect(result).toEqual(['analytics', 'warehouse']);
});
it('still completes connection ids for the --connection-id option', async () => {
const providers = createProjectCompletionProviders();
const result = await providers.optionValueCandidates(['ingest'], '--connection-id', ['--project-dir', projectDir]);
expect(result).toEqual(['analytics', 'warehouse']);
});
});

View file

@ -6,6 +6,7 @@ import { initKtxProject, type KtxLocalProject } from '../../../src/context/proje
import {
listLocalSlSources,
readLocalSlSource,
resolveLocalSlSource,
searchLocalSlSources,
validateLocalSlSource,
writeLocalSlSource,
@ -90,6 +91,101 @@ describe('local semantic-layer helpers', () => {
await expect(validateLocalSlSource(ORDERS_YAML)).resolves.toEqual({ valid: true, errors: [] });
});
it('resolves a scoped source by connection id', async () => {
await writeLocalSlSource(project, {
connectionId: 'warehouse',
sourceName: 'orders',
yaml: ORDERS_YAML,
});
await expect(
resolveLocalSlSource(project, {
connectionId: 'warehouse',
sourceName: 'orders',
}),
).resolves.toEqual({
kind: 'found',
source: expect.objectContaining({
connectionId: 'warehouse',
name: 'orders',
path: 'semantic-layer/warehouse/orders.yaml',
yaml: ORDERS_YAML,
}),
});
});
it('returns not-found for a missing scoped source', async () => {
await writeLocalSlSource(project, {
connectionId: 'warehouse',
sourceName: 'orders',
yaml: ORDERS_YAML,
});
await expect(
resolveLocalSlSource(project, {
connectionId: 'warehouse',
sourceName: 'missing_orders',
}),
).resolves.toEqual({ kind: 'not-found' });
});
it('resolves a unique source name across all connections', async () => {
await writeLocalSlSource(project, {
connectionId: 'warehouse',
sourceName: 'orders',
yaml: ORDERS_YAML,
});
await writeLocalSlSource(project, {
connectionId: 'analytics',
sourceName: 'tickets',
yaml: SUPPORT_YAML,
});
await expect(
resolveLocalSlSource(project, {
sourceName: 'tickets',
}),
).resolves.toEqual({
kind: 'found',
source: expect.objectContaining({
connectionId: 'analytics',
name: 'tickets',
path: 'semantic-layer/analytics/tickets.yaml',
yaml: SUPPORT_YAML,
}),
});
});
it('returns not-found for a missing unscoped source', async () => {
await writeLocalSlSource(project, {
connectionId: 'warehouse',
sourceName: 'orders',
yaml: ORDERS_YAML,
});
await expect(resolveLocalSlSource(project, { sourceName: 'missing_orders' })).resolves.toEqual({
kind: 'not-found',
});
});
it('reports sorted ambiguous connection ids for duplicate source names', async () => {
await writeLocalSlSource(project, {
connectionId: 'warehouse',
sourceName: 'orders',
yaml: ORDERS_YAML,
});
await writeLocalSlSource(project, {
connectionId: 'analytics',
sourceName: 'orders',
yaml: ORDERS_YAML,
});
await expect(resolveLocalSlSource(project, { sourceName: 'orders' })).resolves.toEqual({
kind: 'ambiguous',
connectionIds: ['analytics', 'warehouse'],
});
});
it('validates table-backed sources against matching physical manifests when project context is provided', async () => {
await project.fileStore.writeFile(
'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml',

View file

@ -4,6 +4,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js';
import {
listLocalKnowledgePageKeys,
listLocalKnowledgePages,
readLocalKnowledgePage,
searchLocalKnowledgePages,
@ -102,6 +103,35 @@ describe('local knowledge helpers', () => {
await expect(access(join(project.projectDir, '.ktx', 'db.sqlite'))).resolves.toBeUndefined();
});
it('lists page keys across scopes, deduped and sorted, for completion', async () => {
await writeLocalKnowledgePage(project, {
key: 'metrics-revenue',
scope: 'GLOBAL',
summary: 'Revenue metric definition',
content: 'Revenue is recognized when an order is paid.',
});
await writeLocalKnowledgePage(project, {
key: 'metrics-churn',
scope: 'USER',
userId: 'local',
summary: 'Churn metric definition',
content: 'Churn is measured monthly.',
});
// Same key in both scopes must collapse to a single completion candidate.
await writeLocalKnowledgePage(project, {
key: 'metrics-revenue',
scope: 'USER',
userId: 'local',
summary: 'User override of revenue',
content: 'Local revenue note.',
});
await expect(listLocalKnowledgePageKeys(project, { userId: 'local' })).resolves.toEqual([
'metrics-churn',
'metrics-revenue',
]);
});
it('adds the token lane alongside lexical wiki matches', async () => {
await writeLocalKnowledgePage(project, {
key: 'metrics-revenue',

View file

@ -132,9 +132,12 @@ describe('runKtxCli', () => {
}
expect(testIo.stdout()).not.toMatch(/^ dev\s/m);
expect(testIo.stdout()).not.toMatch(/^ scan\s/m);
for (const removed of ['demo', 'init', 'connect', 'ask', 'knowledge', 'agent', 'completion', 'serve']) {
for (const removed of ['demo', 'init', 'connect', 'ask', 'knowledge', 'agent', 'serve']) {
expect(testIo.stdout()).not.toMatch(new RegExp(`^\\s+${removed}(?:\\s|\\[|$)`, 'm'));
}
// `completion` is a public command; the internal `__complete` helper is hidden.
expect(testIo.stdout()).toMatch(/^\s+completion <shell>/m);
expect(testIo.stdout()).not.toContain('__complete');
expect(testIo.stdout()).toContain('--project-dir <path>');
expect(testIo.stdout()).toContain('KTX_PROJECT_DIR');
expect(testIo.stdout()).toContain('--debug');
@ -414,12 +417,17 @@ describe('runKtxCli', () => {
const promptIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count'], promptIo.io, { sl }),
runKtxCli(
['--project-dir', tempDir, 'sl', 'query', '--connection-id', 'warehouse', '--measure', 'orders.order_count'],
promptIo.io,
{ sl },
),
).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith(
expect.objectContaining({
command: 'query',
projectDir: tempDir,
connectionId: 'warehouse',
cliVersion,
runtimeInstallPolicy: 'prompt',
query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }),
@ -429,9 +437,21 @@ describe('runKtxCli', () => {
const autoIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--yes'], autoIo.io, {
sl,
}),
runKtxCli(
[
'--project-dir',
tempDir,
'sl',
'query',
'--connection-id',
'warehouse',
'--measure',
'orders.order_count',
'--yes',
],
autoIo.io,
{ sl },
),
).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith(
expect.objectContaining({
@ -444,7 +464,17 @@ describe('runKtxCli', () => {
const noInputIo = makeIo();
await expect(
runKtxCli(
['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--no-input'],
[
'--project-dir',
tempDir,
'sl',
'query',
'--connection-id',
'warehouse',
'--measure',
'orders.order_count',
'--no-input',
],
noInputIo.io,
{ sl },
),
@ -464,7 +494,18 @@ describe('runKtxCli', () => {
await expect(
runKtxCli(
['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--yes', '--no-input'],
[
'--project-dir',
tempDir,
'sl',
'query',
'--connection-id',
'warehouse',
'--measure',
'orders.order_count',
'--yes',
'--no-input',
],
io.io,
{ sl },
),

View file

@ -98,6 +98,46 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stdout()).toContain('metrics-revenue');
});
it('reads a wiki page as raw markdown with frontmatter', async () => {
const projectDir = join(tempDir, 'read-project');
await initKtxProject({ projectDir });
await seedWikiPage(projectDir, {
key: 'metrics-revenue',
summary: 'Revenue',
content: 'Revenue is paid order value.',
tags: ['finance'],
slRefs: ['orders'],
});
const readIo = makeIo();
await expect(
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io),
).resolves.toBe(0);
expect(readIo.stdout()).toContain('---\n');
expect(readIo.stdout()).toContain('summary: Revenue');
expect(readIo.stdout()).toContain('tags:');
expect(readIo.stdout()).toContain('- finance');
expect(readIo.stdout()).toContain('sl_refs:');
expect(readIo.stdout()).toContain('- orders');
expect(readIo.stdout()).toContain('usage_mode: auto');
expect(readIo.stdout()).toContain('Revenue is paid order value.');
expect(readIo.stderr()).toBe('');
});
it('reports a clear error when a wiki page key is missing', async () => {
const projectDir = join(tempDir, 'missing-read-project');
await initKtxProject({ projectDir });
const readIo = makeIo();
await expect(
runKtxKnowledge({ command: 'read', projectDir, key: 'missing-page', userId: 'local' }, readIo.io),
).resolves.toBe(1);
expect(readIo.stdout()).toBe('');
expect(readIo.stderr()).toBe("No wiki page found for key 'missing-page'\n");
});
it('emits debug telemetry for wiki search without query text', async () => {
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('CI', '');

View file

@ -12,10 +12,14 @@ describe('renderKtxCommandTree', () => {
.filter((line) => /^ {2}[├└]── \S/.test(line))
.map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]);
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'mcp', 'admin']) {
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'mcp', 'admin', 'completion']) {
expect(topLevel).toContain(expected);
}
// The internal completion helper is hidden and must not appear in the tree.
expect(topLevel).not.toContain('__complete');
expect(output).not.toContain('__complete');
expect(output).toContain('│ └── test [connectionId]');
expect(output).toContain('│ ├── status Show KTX MCP daemon status');
expect(output).not.toContain('│ ├── add');
@ -27,10 +31,14 @@ describe('renderKtxCommandTree', () => {
expect(output).not.toContain('scan <connectionId>');
expect(output).not.toContain('│ ├── replay');
expect(output).not.toContain('│ └── replay');
expect(output).not.toContain('│ ├── run');
// Match `run` as a whole command name, not the `run` prefix of `runtime`.
expect(output).not.toMatch(/[├└]── run(\s|$)/m);
expect(output).not.toContain('│ ├── watch');
expect(output).not.toContain('│ └── watch');
expect(output).not.toContain('│ ├── read');
expect(output).toContain('│ └── read <key> Read a wiki page file by key');
expect(output).toContain(
'│ ├── read <sourceName> Read a semantic-layer source YAML file',
);
expect(output).not.toContain('│ ├── write');
expect(output).not.toContain('│ └── write');
});

View file

@ -113,6 +113,267 @@ describe('runKtxSl', () => {
});
});
it('reads a semantic-layer source as raw YAML', async () => {
const projectDir = join(tempDir, 'read-project');
await seedSlSource({ projectDir });
const readIo = makeIo();
await expect(
runKtxSl(
{
command: 'read',
projectDir,
connectionId: 'warehouse',
sourceName: 'orders',
},
readIo.io,
),
).resolves.toBe(0);
expect(readIo.stdout()).toBe(ORDERS_YAML);
expect(readIo.stderr()).toBe('');
});
it('reads a unique semantic-layer source without a connection id', async () => {
const projectDir = join(tempDir, 'read-unique-project');
const project = await initKtxProject({ projectDir });
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
ORDERS_YAML,
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
await project.fileStore.writeFile(
'semantic-layer/analytics/tickets.yaml',
[
'name: tickets',
'table: public.tickets',
'grain:',
' - ticket_id',
'columns:',
' - name: ticket_id',
' type: string',
'',
].join('\n'),
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
const readIo = makeIo();
await expect(
runKtxSl(
{
command: 'read',
projectDir,
sourceName: 'tickets',
},
readIo.io,
),
).resolves.toBe(0);
expect(readIo.stdout()).toContain('name: tickets');
expect(readIo.stderr()).toBe('');
});
it('reports ambiguous unscoped reads with sorted connection ids', async () => {
const projectDir = join(tempDir, 'read-ambiguous-project');
const project = await initKtxProject({ projectDir });
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
ORDERS_YAML,
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
await project.fileStore.writeFile(
'semantic-layer/analytics/orders.yaml',
ORDERS_YAML,
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
const readIo = makeIo();
await expect(
runKtxSl(
{
command: 'read',
projectDir,
sourceName: 'orders',
},
readIo.io,
),
).resolves.toBe(1);
expect(readIo.stdout()).toBe('');
expect(readIo.stderr()).toBe(
"Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id <id>.\n",
);
});
it('reports a clear error when an unscoped semantic-layer source is missing', async () => {
const projectDir = join(tempDir, 'missing-unscoped-read-project');
await seedSlSource({ projectDir });
const readIo = makeIo();
await expect(
runKtxSl(
{
command: 'read',
projectDir,
sourceName: 'missing_orders',
},
readIo.io,
),
).resolves.toBe(1);
expect(readIo.stdout()).toBe('');
expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders'\n");
});
it('reports a clear error when a semantic-layer source is missing', async () => {
const projectDir = join(tempDir, 'missing-read-project');
await seedSlSource({ projectDir });
const readIo = makeIo();
await expect(
runKtxSl(
{
command: 'read',
projectDir,
connectionId: 'warehouse',
sourceName: 'missing_orders',
},
readIo.io,
),
).resolves.toBe(1);
expect(readIo.stdout()).toBe('');
expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders' for connection 'warehouse'\n");
});
it('validates a unique semantic-layer source without a connection id', async () => {
const projectDir = join(tempDir, 'validate-unique-project');
const project = await initKtxProject({ projectDir });
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
ORDERS_YAML,
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
await project.fileStore.writeFile(
'semantic-layer/analytics/tickets.yaml',
[
'name: tickets',
'table: public.tickets',
'grain:',
' - ticket_id',
'columns:',
' - name: ticket_id',
' type: string',
'',
].join('\n'),
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
const validateIo = makeIo();
await expect(
runKtxSl(
{
command: 'validate',
projectDir,
sourceName: 'tickets',
},
validateIo.io,
),
).resolves.toBe(0);
expect(validateIo.stdout()).toBe('Valid semantic-layer source: analytics/tickets\n');
expect(validateIo.stderr()).toBe('');
});
it('reports ambiguous unscoped validation with sorted connection ids', async () => {
const projectDir = join(tempDir, 'validate-ambiguous-project');
const project = await initKtxProject({ projectDir });
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
ORDERS_YAML,
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
await project.fileStore.writeFile(
'semantic-layer/analytics/orders.yaml',
ORDERS_YAML,
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
const validateIo = makeIo();
await expect(
runKtxSl(
{
command: 'validate',
projectDir,
sourceName: 'orders',
},
validateIo.io,
),
).resolves.toBe(1);
expect(validateIo.stdout()).toBe('');
expect(validateIo.stderr()).toBe(
"Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id <id>.\n",
);
});
it('reports a clear error when an unscoped semantic-layer source validation target is missing', async () => {
const projectDir = join(tempDir, 'missing-unscoped-validate-project');
await seedSlSource({ projectDir });
const validateIo = makeIo();
await expect(
runKtxSl(
{
command: 'validate',
projectDir,
sourceName: 'missing_orders',
},
validateIo.io,
),
).resolves.toBe(1);
expect(validateIo.stdout()).toBe('');
expect(validateIo.stderr()).toBe('Semantic-layer source "missing_orders" was not found\n');
});
it('keeps scoped validation not-found wording', async () => {
const projectDir = join(tempDir, 'missing-scoped-validate-project');
await seedSlSource({ projectDir });
const validateIo = makeIo();
await expect(
runKtxSl(
{
command: 'validate',
projectDir,
connectionId: 'warehouse',
sourceName: 'missing_orders',
},
validateIo.io,
),
).resolves.toBe(1);
expect(validateIo.stdout()).toBe('');
expect(validateIo.stderr()).toBe('Semantic-layer source "warehouse/missing_orders" was not found\n');
});
it('prints semantic-layer search rank badges in pretty output', async () => {
const projectDir = join(tempDir, 'rank-project');
await seedSlSource({ projectDir });