diff --git a/docs-site/content/docs/cli-reference/ktx-completion.mdx b/docs-site/content/docs/cli-reference/ktx-completion.mdx
new file mode 100644
index 00000000..94f1c383
--- /dev/null
+++ b/docs-site/content/docs/cli-reference/ktx-completion.mdx
@@ -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 Tab
+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
+```
+
+`` 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 ` | Top-level commands (`setup`, `sl`, `wiki`, `ingest`, …) |
+| `ktx sl ` | The `read` / `validate` / `query` subcommands |
+| `ktx sl read ` | Existing semantic-layer source names |
+| `ktx sl validate ` | Existing semantic-layer source names |
+| `ktx wiki ` | The `read` subcommand |
+| `ktx wiki read ` | Existing wiki page keys |
+| `ktx connection test ` | Configured connection ids |
+| `ktx ingest ` | Configured connection ids |
+| `ktx sql --connection ` | Configured connection ids |
+| `ktx completion ` | `zsh` or `bash` |
+| `ktx --` | The command's flags and inherited global flags |
+| `ktx sl --output ` | An option's allowed values (here `pretty`, `plain`, `json`) |
+| `ktx sl --connection-id ` | 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 ` and
+`ktx wiki ` 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 '' 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` |
diff --git a/docs-site/content/docs/cli-reference/ktx-sl.mdx b/docs-site/content/docs/cli-reference/ktx-sl.mdx
index 2dfba7ab..9e957d4e 100644
--- a/docs-site/content/docs/cli-reference/ktx-sl.mdx
+++ b/docs-site/content/docs/cli-reference/ktx-sl.mdx
@@ -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 [options]
+ktx sl read
+ktx sl validate
ktx sl query [options]
```
- Bare `ktx sl` lists semantic sources.
-- `ktx sl ` searches semantic sources (multi-word queries are
- joined with a space).
+- `ktx sl ` searches semantic sources. Multi-word queries are joined
+ with a space.
+- `ktx sl read ` 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 ` | Print the YAML for one semantic source |
| `validate ` | Validate a semantic source against the database schema |
| `query` | Compile or execute a semantic query |
@@ -40,17 +44,23 @@ ktx sl query [options]
| `--output ` | 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 ` | Optional **ktx** connection id for disambiguation | - |
+
### `sl validate`
| Flag | Description | Default |
|------|-------------|---------|
-| `--connection-id ` | **ktx** connection id (required) | - |
+| `--connection-id ` | Optional **ktx** connection id for disambiguation | - |
### `sl query`
| Flag | Description | Default |
|------|-------------|---------|
-| `--connection-id ` | **ktx** connection id | - |
+| `--connection-id ` | Required **ktx** connection id | - |
| `--query-file ` | JSON semantic query file | - |
| `--measure ` | Measure to query; repeatable (at least one required) | - |
| `--dimension ` | Dimension to include; repeatable | - |
@@ -65,8 +75,9 @@ ktx sl query [options]
| `--no-input` | Disable interactive managed runtime installation | - |
| `--max-rows ` | 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 ` 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 `. 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 ` or `ktx sl --connection-id ` to find the exact source name, then retry `ktx sl read ` or `ktx sl validate ` |
+| Source name is ambiguous | The same source name exists in multiple connections | Rerun with `--connection-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 `, 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 |
diff --git a/docs-site/content/docs/cli-reference/ktx-wiki.mdx b/docs-site/content/docs/cli-reference/ktx-wiki.mdx
index 2d52d5af..7887a463 100644
--- a/docs-site/content/docs/cli-reference/ktx-wiki.mdx
+++ b/docs-site/content/docs/cli-reference/ktx-wiki.mdx
@@ -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
```
- Bare `ktx wiki` lists local wiki pages.
-- `ktx wiki ` searches local wiki pages (multi-word queries are
- joined with a space).
+- `ktx wiki ` searches local wiki pages. Multi-word queries are
+ joined with a space.
+- `ktx wiki read ` 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 ` 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 ` |
+| A page is missing | No Markdown file exists for that business context or `ktx wiki read ` used the wrong key | Run `ktx wiki ` to find the page key, then retry `ktx wiki read ` |
diff --git a/docs-site/content/docs/cli-reference/ktx.mdx b/docs-site/content/docs/cli-reference/ktx.mdx
index 010100d8..8b9a2cc5 100644
--- a/docs-site/content/docs/cli-reference/ktx.mdx
+++ b/docs-site/content/docs/cli-reference/ktx.mdx
@@ -36,9 +36,11 @@ ktx
wiki
list
search
+ read
sl
list
search
+ read
validate
query
sql
@@ -57,6 +59,7 @@ ktx
stop
status
reindex
+ completion
```
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"
diff --git a/docs-site/content/docs/cli-reference/meta.json b/docs-site/content/docs/cli-reference/meta.json
index 49eb8ba7..2902f2c6 100644
--- a/docs-site/content/docs/cli-reference/meta.json
+++ b/docs-site/content/docs/cli-reference/meta.json
@@ -11,6 +11,7 @@
"ktx-wiki",
"ktx-status",
"ktx-mcp",
- "ktx-admin"
+ "ktx-admin",
+ "ktx-completion"
]
}
diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts
index a3c27375..31ab8a03 100644
--- a/packages/cli/src/cli-program.ts
+++ b/packages/cli/src/cli-program.ts
@@ -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;
}
diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts
index 2eeb24e8..9b5bf729 100644
--- a/packages/cli/src/command-tree.ts
+++ b/packages/cli/src/command-tree.ts
@@ -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)),
};
}
diff --git a/packages/cli/src/commands/completion-commands.ts b/packages/cli/src/commands/completion-commands.ts
new file mode 100644
index 00000000..332f103b
--- /dev/null
+++ b/packages/cli/src/commands/completion-commands.ts
@@ -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('', '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);
+ });
+}
diff --git a/packages/cli/src/commands/knowledge-commands.ts b/packages/cli/src/commands/knowledge-commands.ts
index c7b7c8d7..b601b688 100644
--- a/packages/cli/src/commands/knowledge-commands.ts
+++ b/packages/cli/src/commands/knowledge-commands.ts
@@ -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 ', '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('', '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',
+ });
+ });
}
diff --git a/packages/cli/src/commands/sl-commands.ts b/packages/cli/src/commands/sl-commands.ts
index a4cb644c..8f2f05a3 100644
--- a/packages/cli/src/commands/sl-commands.ts
+++ b/packages/cli/src/commands/sl-commands.ts
@@ -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('', '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('', '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 ' 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 ' not specified");
+ }
const args = slQueryCommandSchema.parse({
command: 'query',
projectDir: resolveCommandProjectDir(command),
- connectionId: parentOpts?.connectionId,
+ connectionId,
...(options.queryFile
? { queryFile: options.queryFile }
: {
diff --git a/packages/cli/src/completion/complete-engine.ts b/packages/cli/src/completion/complete-engine.ts
new file mode 100644
index 00000000..7268d397
--- /dev/null
+++ b/packages/cli/src/completion/complete-engine.ts
@@ -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;
+ /** Candidate values for an option that has no static `choices` (e.g. `--connection-id`). */
+ optionValueCandidates(commandPath: string[], optionFlag: string, typedTokens: string[]): Promise;
+}
+
+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 {
+ 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();
+ 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 {
+ 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);
+}
diff --git a/packages/cli/src/completion/completion-scripts.ts b/packages/cli/src/completion/completion-scripts.ts
new file mode 100644
index 00000000..5761c6e0
--- /dev/null
+++ b/packages/cli/src/completion/completion-scripts.ts
@@ -0,0 +1,39 @@
+// Static shell completion scripts emitted by `ktx completion `.
+//
+// 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;
+}
diff --git a/packages/cli/src/completion/dynamic-candidates.ts b/packages/cli/src/completion/dynamic-candidates.ts
new file mode 100644
index 00000000..2be512c9
--- /dev/null
+++ b/packages/cli/src/completion/dynamic-candidates.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 [];
+ }
+ },
+ };
+}
diff --git a/packages/cli/src/context/sl/local-sl.ts b/packages/cli/src/context/sl/local-sl.ts
index 243ba94d..fb573392 100644
--- a/packages/cli/src/context/sl/local-sl.ts
+++ b/packages/cli/src/context/sl/local-sl.ts
@@ -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 {
+ 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 } = {},
diff --git a/packages/cli/src/context/wiki/local-knowledge.ts b/packages/cli/src/context/wiki/local-knowledge.ts
index b7132b50..dd9b9ad7 100644
--- a/packages/cli/src/context/wiki/local-knowledge.ts
+++ b/packages/cli/src/context/wiki/local-knowledge.ts
@@ -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 {
+ const userId = input.userId ?? 'local';
+ const keys = new Set();
+ 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;
diff --git a/packages/cli/src/knowledge.ts b/packages/cli/src/knowledge.ts
index d6246fef..346d3d9a 100644
--- a/packages/cli/src/knowledge.ts
+++ b/packages/cli/src/knowledge.ts
@@ -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;
diff --git a/packages/cli/src/sl.ts b/packages/cli/src/sl.ts
index 76e1092a..f3eeb33e 100644
--- a/packages/cli/src/sl.ts
+++ b/packages/cli/src/sl.ts
@@ -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 {
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 .`;
+}
+
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise {
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') {
diff --git a/packages/cli/test/cli-program-telemetry.test.ts b/packages/cli/test/cli-program-telemetry.test.ts
index 8088e7f2..4e7130b3 100644
--- a/packages/cli/test/cli-program-telemetry.test.ts
+++ b/packages/cli/test/cli-program-telemetry.test.ts
@@ -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);
diff --git a/packages/cli/test/commands/wiki-sl-read-commands.test.ts b/packages/cli/test/commands/wiki-sl-read-commands.test.ts
new file mode 100644
index 00000000..69e3c51a
--- /dev/null
+++ b/packages/cli/test/commands/wiki-sl-read-commands.test.ts
@@ -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 {
+ 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 ');
+ 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 ');
+ 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 ');
+ 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 ');
+ 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 ');
+ 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 ');
+ 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 ' not specified");
+
+ expect(sl).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/cli/test/completion/complete-engine.test.ts b/packages/cli/test/completion/complete-engine.test.ts
new file mode 100644
index 00000000..f3893340
--- /dev/null
+++ b/packages/cli/test/completion/complete-engine.test.ts
@@ -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 {
+ 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 {
+ 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']);
+ });
+});
diff --git a/packages/cli/test/completion/completion-scripts.test.ts b/packages/cli/test/completion/completion-scripts.test.ts
new file mode 100644
index 00000000..24723a95
--- /dev/null
+++ b/packages/cli/test/completion/completion-scripts.test.ts
@@ -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"))');
+ });
+});
diff --git a/packages/cli/test/completion/dynamic-candidates.test.ts b/packages/cli/test/completion/dynamic-candidates.test.ts
new file mode 100644
index 00000000..560f38f0
--- /dev/null
+++ b/packages/cli/test/completion/dynamic-candidates.test.ts
@@ -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 {
+ 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']);
+ });
+});
diff --git a/packages/cli/test/context/sl/local-sl.test.ts b/packages/cli/test/context/sl/local-sl.test.ts
index 1115f387..b3a9b7d6 100644
--- a/packages/cli/test/context/sl/local-sl.test.ts
+++ b/packages/cli/test/context/sl/local-sl.test.ts
@@ -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',
diff --git a/packages/cli/test/context/wiki/local-knowledge.test.ts b/packages/cli/test/context/wiki/local-knowledge.test.ts
index fa70bcc5..cda5ca1a 100644
--- a/packages/cli/test/context/wiki/local-knowledge.test.ts
+++ b/packages/cli/test/context/wiki/local-knowledge.test.ts
@@ -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',
diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts
index bd17e641..57ac4901 100644
--- a/packages/cli/test/index.test.ts
+++ b/packages/cli/test/index.test.ts
@@ -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 /m);
+ expect(testIo.stdout()).not.toContain('__complete');
expect(testIo.stdout()).toContain('--project-dir ');
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 },
),
diff --git a/packages/cli/test/knowledge.test.ts b/packages/cli/test/knowledge.test.ts
index 339eb659..94e4bb63 100644
--- a/packages/cli/test/knowledge.test.ts
+++ b/packages/cli/test/knowledge.test.ts
@@ -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', '');
diff --git a/packages/cli/test/print-command-tree.test.ts b/packages/cli/test/print-command-tree.test.ts
index 688818ab..387874b3 100644
--- a/packages/cli/test/print-command-tree.test.ts
+++ b/packages/cli/test/print-command-tree.test.ts
@@ -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 ');
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 Read a wiki page file by key');
+ expect(output).toContain(
+ '│ ├── read Read a semantic-layer source YAML file',
+ );
expect(output).not.toContain('│ ├── write');
expect(output).not.toContain('│ └── write');
});
diff --git a/packages/cli/test/sl.test.ts b/packages/cli/test/sl.test.ts
index 7b4b7795..ff9c1489 100644
--- a/packages/cli/test/sl.test.ts
+++ b/packages/cli/test/sl.test.ts
@@ -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 .\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 .\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 });