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