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

@ -0,0 +1,86 @@
---
title: "ktx completion"
description: "Print a shell completion script for tab completion."
---
Print a shell completion script for **ktx**. Once installed, pressing <kbd>Tab</kbd>
completes commands, subcommands, and flags, and - inside a **ktx** project - the
names of things that already exist: semantic-layer source names for
`ktx sl read` and `ktx sl validate`, wiki page keys for `ktx wiki read`, and
configured connection ids for `ktx connection test`, `ktx ingest`, and
`ktx sql`. This saves you from remembering exact source, page, or connection
names.
## Command signature
```bash
ktx completion <shell>
```
`<shell>` must be `zsh` or `bash`. The command writes the script to stdout; it
does not modify any files. Enable completion by evaluating the script in your
shell startup file.
## Installation
Add the matching line to your shell startup file, then restart your shell (or
`source` the file). `ktx` must be on your `PATH`.
```bash
# zsh — add to ~/.zshrc
eval "$(ktx completion zsh)"
```
```bash
# bash — add to ~/.bashrc
eval "$(ktx completion bash)"
```
To try it for the current session only, run the same `eval` line directly in
your terminal.
## What gets completed
| Position | Completions |
|----------|-------------|
| `ktx <Tab>` | Top-level commands (`setup`, `sl`, `wiki`, `ingest`, …) |
| `ktx sl <Tab>` | The `read` / `validate` / `query` subcommands |
| `ktx sl read <Tab>` | Existing semantic-layer source names |
| `ktx sl validate <Tab>` | Existing semantic-layer source names |
| `ktx wiki <Tab>` | The `read` subcommand |
| `ktx wiki read <Tab>` | Existing wiki page keys |
| `ktx connection test <Tab>` | Configured connection ids |
| `ktx ingest <Tab>` | Configured connection ids |
| `ktx sql --connection <Tab>` | Configured connection ids |
| `ktx completion <Tab>` | `zsh` or `bash` |
| `ktx <command> --<Tab>` | The command's flags and inherited global flags |
| `ktx sl --output <Tab>` | An option's allowed values (here `pretty`, `plain`, `json`) |
| `ktx sl --connection-id <Tab>` | Configured connection ids |
Source names, wiki page keys, and connection ids are read from the **ktx**
project resolved from your current directory (or `--project-dir` /
`KTX_PROJECT_DIR`). Outside a **ktx** project, completion still suggests
commands and flags but no project entities. Bare `ktx sl <Tab>` and
`ktx wiki <Tab>` complete subcommands instead of entity names because their
positional arguments are free-text search queries.
## Examples
```bash
# Print the zsh completion script
ktx completion zsh
# Print the bash completion script
ktx completion bash
# Install for zsh
echo 'eval "$(ktx completion zsh)"' >> ~/.zshrc
```
## Common errors
| Error | Cause | Recovery |
|-------|-------|----------|
| `error: command-argument value '<name>' is invalid for argument 'shell'. Allowed choices are zsh, bash.` | A shell other than `zsh` or `bash` was requested | Re-run with `ktx completion zsh` or `ktx completion bash` |
| Tab completion does nothing | The script was not evaluated, or `ktx` is not on `PATH` | Confirm the `eval` line is in your startup file, restart the shell, and verify `ktx --version` runs |
| Source, page, or connection names are missing | The current directory is not inside a **ktx** project | Run from the project directory, or pass `--project-dir`, or set `KTX_PROJECT_DIR` |

View file

@ -11,13 +11,16 @@ the vocabulary agents use to generate correct SQL.
```bash
ktx sl [options] [query...] # list (bare) or search (with query)
ktx sl validate <sourceName> [options]
ktx sl read <sourceName>
ktx sl validate <sourceName>
ktx sl query [options]
```
- Bare `ktx sl` lists semantic sources.
- `ktx sl <query...>` searches semantic sources (multi-word queries are
joined with a space).
- `ktx sl <query...>` searches semantic sources. Multi-word queries are joined
with a space.
- `ktx sl read <sourceName>` prints the YAML for one source. Add
`--connection-id` only when the source name exists in multiple connections.
- `ktx sl validate` and `ktx sl query` remain as explicit subcommands.
## Subcommands
@ -26,6 +29,7 @@ ktx sl query [options]
|-----------|-------------|
| (none, no query) | List semantic sources |
| (none, with query) | Search semantic sources |
| `read <sourceName>` | Print the YAML for one semantic source |
| `validate <sourceName>` | Validate a semantic source against the database schema |
| `query` | Compile or execute a semantic query |
@ -40,17 +44,23 @@ ktx sl query [options]
| `--output <mode>` | Output mode: `pretty` (default in TTY), `plain` (TSV), or `json` | `pretty` |
| `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` |
### `sl read`
| Flag | Description | Default |
|------|-------------|---------|
| `--connection-id <id>` | Optional **ktx** connection id for disambiguation | - |
### `sl validate`
| Flag | Description | Default |
|------|-------------|---------|
| `--connection-id <id>` | **ktx** connection id (required) | - |
| `--connection-id <id>` | Optional **ktx** connection id for disambiguation | - |
### `sl query`
| Flag | Description | Default |
|------|-------------|---------|
| `--connection-id <id>` | **ktx** connection id | - |
| `--connection-id <id>` | Required **ktx** connection id | - |
| `--query-file <path>` | JSON semantic query file | - |
| `--measure <measure>` | Measure to query; repeatable (at least one required) | - |
| `--dimension <dimension>` | Dimension to include; repeatable | - |
@ -65,8 +75,9 @@ ktx sl query [options]
| `--no-input` | Disable interactive managed runtime installation | - |
| `--max-rows <n>` | Maximum rows to return when executing | - |
`sl query` requires at least one `--measure` unless `--query-file` is set.
`--query-file` should point to a JSON semantic query object.
`sl query` requires `--connection-id` and at least one `--measure` unless
`--query-file` is set. `--query-file` must point to a JSON semantic query
object.
## Examples
@ -83,7 +94,16 @@ ktx sl --json
# Search sources as JSON
ktx sl "revenue" --json
# Validate a source against the live schema
# Print the YAML for a source name that is unique across connections
ktx sl read orders
# Print the YAML for a source name that exists in multiple connections
ktx sl --connection-id my-warehouse read orders
# Validate a source name that is unique across connections
ktx sl validate orders
# Validate a source name that exists in multiple connections
ktx sl validate orders --connection-id my-warehouse
# Compile a query and view the generated SQL
@ -144,6 +164,12 @@ shows `#1`, `#2`, and later rank badges for the displayed results. Plain and
JSON output keep the raw `score` value, which is a ranking score rather than a
percentage.
`ktx sl read <sourceName>` prints the source YAML directly to stdout when the
source name is unique across connections. If the name exists in multiple
connections, rerun the command with `--connection-id <id>`. The command does
not wrap output in pretty, plain, or JSON formatting, so it can be piped to
other tools.
```json
{
"sql": "SELECT orders.status, SUM(orders.total_amount) AS total_revenue FROM public.orders GROUP BY orders.status",
@ -160,7 +186,8 @@ percentage.
| Error | Cause | Recovery |
|-------|-------|----------|
| Source not found | Source name or connection id is wrong | Run `ktx sl --json` and retry with an exact source name and connection id |
| Source not found | Source name or connection id is wrong | Run `ktx sl <query>` or `ktx sl --connection-id <id>` to find the exact source name, then retry `ktx sl read <sourceName>` or `ktx sl validate <sourceName>` |
| Source name is ambiguous | The same source name exists in multiple connections | Rerun with `--connection-id <id>` from the error message |
| Validation fails | YAML references missing columns, invalid joins, or invalid SQL expressions | Fix the source YAML and rerun `ktx sl validate` |
| Query compile fails | Measure, dimension, filter, or segment name is invalid | Search sources with `ktx sl <query>`, inspect the source YAML in your project files, then retry using declared fields |
| Execution returns too many rows | `--max-rows` is missing or too high | Add `--max-rows` with a bounded value before executing |

View file

@ -1,21 +1,24 @@
---
title: "ktx wiki"
description: "List or search wiki pages."
description: "List, search, or read wiki pages."
---
List and search wiki pages in your **ktx** project. Wiki pages are Markdown
documents that capture business definitions, rules, and gotchas. Agents search
them for context when answering questions about your data.
List, search, and read wiki pages in your **ktx** project. Wiki pages are
Markdown documents that capture business definitions, rules, and gotchas.
Agents search them for context when answering questions about your data.
## Command signature
```bash
ktx wiki [options] [query...]
ktx wiki [options] [query...] # list (bare) or search (with query)
ktx wiki read <key>
```
- Bare `ktx wiki` lists local wiki pages.
- `ktx wiki <query...>` searches local wiki pages (multi-word queries are
joined with a space).
- `ktx wiki <query...>` searches local wiki pages. Multi-word queries are
joined with a space.
- `ktx wiki read <key>` prints the whole Markdown file for one wiki page,
including YAML frontmatter.
Edit the Markdown files under `wiki/` directly, or ingest source content with
`ktx ingest`, when you need to add or update wiki knowledge.
@ -50,6 +53,9 @@ ktx wiki "monthly recurring revenue"
# Search wiki pages as JSON
ktx wiki "monthly recurring revenue" --json --limit 10
# Print the exact Markdown file for a known page key
ktx wiki read revenue-definitions
# Print search results as TSV
ktx wiki "monthly recurring revenue" --output plain
@ -62,8 +68,10 @@ ktx --debug wiki "monthly recurring revenue" --json
Wiki commands print clack-style pretty output in a TTY and TSV-style plain
output when requested. JSON output wraps the items with a command metadata
envelope. Search results include `matchReasons` and `lanes` metadata so you can
see whether lexical, token, or semantic search contributed to the ranking. Open
the matching Markdown files directly when you need the full page contents.
see whether lexical, token, or semantic search contributed to the ranking. Use
`ktx wiki read <key>` when you need the full page contents. Read output is the
exact Markdown file stored on disk, including YAML frontmatter, and is not
wrapped in pretty, plain, or JSON formatting.
Pretty search output shows `#1`, `#2`, and later rank badges for the displayed
results. Plain and JSON output keep the raw `score` value, which is a ranking
score rather than a percentage.
@ -121,4 +129,4 @@ stays machine-readable:
| Error | Cause | Recovery |
|-------|-------|----------|
| Search returns no results | The query terms do not match summaries, tags, or content, and the semantic lane is unavailable or has no positive matches | Run with `--debug`, check the semantic lane status, retry with business synonyms, then create a page if the knowledge is missing |
| A page is missing | No Markdown file exists for that business context | Add a file under `wiki/` or run `ktx ingest <connectionId>` |
| A page is missing | No Markdown file exists for that business context or `ktx wiki read <key>` used the wrong key | Run `ktx wiki <query>` to find the page key, then retry `ktx wiki read <key>` |

View file

@ -36,9 +36,11 @@ ktx
wiki
list
search <query>
read <key>
sl
list
search <query>
read <sourceName>
validate <sourceName>
query
sql
@ -57,6 +59,7 @@ ktx
stop
status
reindex
completion <shell>
```
The public context-build entrypoint is `ktx ingest [connectionId]` or
@ -97,6 +100,10 @@ ktx ingest
ktx sl "revenue"
ktx wiki "revenue recognition"
# Print a known wiki page or semantic source
ktx wiki read revenue-definitions
ktx sl --connection-id warehouse read orders
# Execute read-only SQL
ktx sql --connection warehouse "select count(*) from public.orders"

View file

@ -11,6 +11,7 @@
"ktx-wiki",
"ktx-status",
"ktx-mcp",
"ktx-admin"
"ktx-admin",
"ktx-completion"
]
}

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 });