mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat(cli): shell completion for commands, flags, and entity names (#244)
* feat(completion): complete known argument values
* fix(completion): hide Commander-hidden subcommands from completions
Replace the `__`-prefix name heuristic with Commander's `_hidden` flag so
internal subcommands registered with { hidden: true } (e.g. `mcp serve-internal`)
are excluded from completions, mirroring `ktx --help`.
* test: cover wiki and sl read command routing
* test: cover raw wiki and sl reads
* feat: add wiki read command
* feat: add sl read command
* feat: complete read command entity names
* docs: document wiki and sl read commands
* test: include read commands in command tree
* feat(sl): read and validate unique sources by name
* feat(sl): make read and validate connection id optional
* fix(completion): dedupe semantic source names
* docs(sl): document connection-optional read and validate
* fix(sl): require connection id for query command
* docs(sl): clarify query connection requirement
* fix(completion): don't resolve option values as subcommands
resolveCommand skipped flag tokens but not the value consumed by a
value-taking option in the `--flag value` form, so a connection id like
`query` was matched as the `sl query` subcommand and yielded no `sl`
completions. Track value-taking options and skip their consumed value
before matching subcommands.
* test(telemetry): assert first-run notice via TELEMETRY_NOTICE constant
CI (which tests this branch merged with main) failed because #243 changed
the first-run notice wording in identity.ts (dropped "anonymous") but left
this test grepping for the old literal 'ktx collects anonymous usage data',
so indexOf returned -1. Assert against the exported TELEMETRY_NOTICE
constant instead so the test tracks the source of truth and cannot drift
when the notice text changes again.
This commit is contained in:
parent
c196d1f192
commit
d320d54ab2
28 changed files with 1596 additions and 54 deletions
86
docs-site/content/docs/cli-reference/ktx-completion.mdx
Normal file
86
docs-site/content/docs/cli-reference/ktx-completion.mdx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
title: "ktx completion"
|
||||||
|
description: "Print a shell completion script for tab completion."
|
||||||
|
---
|
||||||
|
|
||||||
|
Print a shell completion script for **ktx**. Once installed, pressing <kbd>Tab</kbd>
|
||||||
|
completes commands, subcommands, and flags, and - inside a **ktx** project - the
|
||||||
|
names of things that already exist: semantic-layer source names for
|
||||||
|
`ktx sl read` and `ktx sl validate`, wiki page keys for `ktx wiki read`, and
|
||||||
|
configured connection ids for `ktx connection test`, `ktx ingest`, and
|
||||||
|
`ktx sql`. This saves you from remembering exact source, page, or connection
|
||||||
|
names.
|
||||||
|
|
||||||
|
## Command signature
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ktx completion <shell>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<shell>` must be `zsh` or `bash`. The command writes the script to stdout; it
|
||||||
|
does not modify any files. Enable completion by evaluating the script in your
|
||||||
|
shell startup file.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add the matching line to your shell startup file, then restart your shell (or
|
||||||
|
`source` the file). `ktx` must be on your `PATH`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# zsh — add to ~/.zshrc
|
||||||
|
eval "$(ktx completion zsh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bash — add to ~/.bashrc
|
||||||
|
eval "$(ktx completion bash)"
|
||||||
|
```
|
||||||
|
|
||||||
|
To try it for the current session only, run the same `eval` line directly in
|
||||||
|
your terminal.
|
||||||
|
|
||||||
|
## What gets completed
|
||||||
|
|
||||||
|
| Position | Completions |
|
||||||
|
|----------|-------------|
|
||||||
|
| `ktx <Tab>` | Top-level commands (`setup`, `sl`, `wiki`, `ingest`, …) |
|
||||||
|
| `ktx sl <Tab>` | The `read` / `validate` / `query` subcommands |
|
||||||
|
| `ktx sl read <Tab>` | Existing semantic-layer source names |
|
||||||
|
| `ktx sl validate <Tab>` | Existing semantic-layer source names |
|
||||||
|
| `ktx wiki <Tab>` | The `read` subcommand |
|
||||||
|
| `ktx wiki read <Tab>` | Existing wiki page keys |
|
||||||
|
| `ktx connection test <Tab>` | Configured connection ids |
|
||||||
|
| `ktx ingest <Tab>` | Configured connection ids |
|
||||||
|
| `ktx sql --connection <Tab>` | Configured connection ids |
|
||||||
|
| `ktx completion <Tab>` | `zsh` or `bash` |
|
||||||
|
| `ktx <command> --<Tab>` | The command's flags and inherited global flags |
|
||||||
|
| `ktx sl --output <Tab>` | An option's allowed values (here `pretty`, `plain`, `json`) |
|
||||||
|
| `ktx sl --connection-id <Tab>` | Configured connection ids |
|
||||||
|
|
||||||
|
Source names, wiki page keys, and connection ids are read from the **ktx**
|
||||||
|
project resolved from your current directory (or `--project-dir` /
|
||||||
|
`KTX_PROJECT_DIR`). Outside a **ktx** project, completion still suggests
|
||||||
|
commands and flags but no project entities. Bare `ktx sl <Tab>` and
|
||||||
|
`ktx wiki <Tab>` complete subcommands instead of entity names because their
|
||||||
|
positional arguments are free-text search queries.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Print the zsh completion script
|
||||||
|
ktx completion zsh
|
||||||
|
|
||||||
|
# Print the bash completion script
|
||||||
|
ktx completion bash
|
||||||
|
|
||||||
|
# Install for zsh
|
||||||
|
echo 'eval "$(ktx completion zsh)"' >> ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common errors
|
||||||
|
|
||||||
|
| Error | Cause | Recovery |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| `error: command-argument value '<name>' is invalid for argument 'shell'. Allowed choices are zsh, bash.` | A shell other than `zsh` or `bash` was requested | Re-run with `ktx completion zsh` or `ktx completion bash` |
|
||||||
|
| Tab completion does nothing | The script was not evaluated, or `ktx` is not on `PATH` | Confirm the `eval` line is in your startup file, restart the shell, and verify `ktx --version` runs |
|
||||||
|
| Source, page, or connection names are missing | The current directory is not inside a **ktx** project | Run from the project directory, or pass `--project-dir`, or set `KTX_PROJECT_DIR` |
|
||||||
|
|
@ -11,13 +11,16 @@ the vocabulary agents use to generate correct SQL.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ktx sl [options] [query...] # list (bare) or search (with query)
|
ktx sl [options] [query...] # list (bare) or search (with query)
|
||||||
ktx sl validate <sourceName> [options]
|
ktx sl read <sourceName>
|
||||||
|
ktx sl validate <sourceName>
|
||||||
ktx sl query [options]
|
ktx sl query [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
- Bare `ktx sl` lists semantic sources.
|
- Bare `ktx sl` lists semantic sources.
|
||||||
- `ktx sl <query...>` searches semantic sources (multi-word queries are
|
- `ktx sl <query...>` searches semantic sources. Multi-word queries are joined
|
||||||
joined with a space).
|
with a space.
|
||||||
|
- `ktx sl read <sourceName>` prints the YAML for one source. Add
|
||||||
|
`--connection-id` only when the source name exists in multiple connections.
|
||||||
- `ktx sl validate` and `ktx sl query` remain as explicit subcommands.
|
- `ktx sl validate` and `ktx sl query` remain as explicit subcommands.
|
||||||
|
|
||||||
## Subcommands
|
## Subcommands
|
||||||
|
|
@ -26,6 +29,7 @@ ktx sl query [options]
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| (none, no query) | List semantic sources |
|
| (none, no query) | List semantic sources |
|
||||||
| (none, with query) | Search semantic sources |
|
| (none, with query) | Search semantic sources |
|
||||||
|
| `read <sourceName>` | Print the YAML for one semantic source |
|
||||||
| `validate <sourceName>` | Validate a semantic source against the database schema |
|
| `validate <sourceName>` | Validate a semantic source against the database schema |
|
||||||
| `query` | Compile or execute a semantic query |
|
| `query` | Compile or execute a semantic query |
|
||||||
|
|
||||||
|
|
@ -40,17 +44,23 @@ ktx sl query [options]
|
||||||
| `--output <mode>` | Output mode: `pretty` (default in TTY), `plain` (TSV), or `json` | `pretty` |
|
| `--output <mode>` | Output mode: `pretty` (default in TTY), `plain` (TSV), or `json` | `pretty` |
|
||||||
| `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` |
|
| `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` |
|
||||||
|
|
||||||
|
### `sl read`
|
||||||
|
|
||||||
|
| Flag | Description | Default |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `--connection-id <id>` | Optional **ktx** connection id for disambiguation | - |
|
||||||
|
|
||||||
### `sl validate`
|
### `sl validate`
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `--connection-id <id>` | **ktx** connection id (required) | - |
|
| `--connection-id <id>` | Optional **ktx** connection id for disambiguation | - |
|
||||||
|
|
||||||
### `sl query`
|
### `sl query`
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `--connection-id <id>` | **ktx** connection id | - |
|
| `--connection-id <id>` | Required **ktx** connection id | - |
|
||||||
| `--query-file <path>` | JSON semantic query file | - |
|
| `--query-file <path>` | JSON semantic query file | - |
|
||||||
| `--measure <measure>` | Measure to query; repeatable (at least one required) | - |
|
| `--measure <measure>` | Measure to query; repeatable (at least one required) | - |
|
||||||
| `--dimension <dimension>` | Dimension to include; repeatable | - |
|
| `--dimension <dimension>` | Dimension to include; repeatable | - |
|
||||||
|
|
@ -65,8 +75,9 @@ ktx sl query [options]
|
||||||
| `--no-input` | Disable interactive managed runtime installation | - |
|
| `--no-input` | Disable interactive managed runtime installation | - |
|
||||||
| `--max-rows <n>` | Maximum rows to return when executing | - |
|
| `--max-rows <n>` | Maximum rows to return when executing | - |
|
||||||
|
|
||||||
`sl query` requires at least one `--measure` unless `--query-file` is set.
|
`sl query` requires `--connection-id` and at least one `--measure` unless
|
||||||
`--query-file` should point to a JSON semantic query object.
|
`--query-file` is set. `--query-file` must point to a JSON semantic query
|
||||||
|
object.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|
@ -83,7 +94,16 @@ ktx sl --json
|
||||||
# Search sources as JSON
|
# Search sources as JSON
|
||||||
ktx sl "revenue" --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
|
ktx sl validate orders --connection-id my-warehouse
|
||||||
|
|
||||||
# Compile a query and view the generated SQL
|
# 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
|
JSON output keep the raw `score` value, which is a ranking score rather than a
|
||||||
percentage.
|
percentage.
|
||||||
|
|
||||||
|
`ktx sl read <sourceName>` prints the source YAML directly to stdout when the
|
||||||
|
source name is unique across connections. If the name exists in multiple
|
||||||
|
connections, rerun the command with `--connection-id <id>`. The command does
|
||||||
|
not wrap output in pretty, plain, or JSON formatting, so it can be piped to
|
||||||
|
other tools.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sql": "SELECT orders.status, SUM(orders.total_amount) AS total_revenue FROM public.orders GROUP BY orders.status",
|
"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 |
|
| Error | Cause | Recovery |
|
||||||
|-------|-------|----------|
|
|-------|-------|----------|
|
||||||
| Source not found | Source name or connection id is wrong | Run `ktx sl --json` and retry with an exact source name and connection id |
|
| Source not found | Source name or connection id is wrong | Run `ktx sl <query>` or `ktx sl --connection-id <id>` to find the exact source name, then retry `ktx sl read <sourceName>` or `ktx sl validate <sourceName>` |
|
||||||
|
| Source name is ambiguous | The same source name exists in multiple connections | Rerun with `--connection-id <id>` from the error message |
|
||||||
| Validation fails | YAML references missing columns, invalid joins, or invalid SQL expressions | Fix the source YAML and rerun `ktx sl validate` |
|
| Validation fails | YAML references missing columns, invalid joins, or invalid SQL expressions | Fix the source YAML and rerun `ktx sl validate` |
|
||||||
| Query compile fails | Measure, dimension, filter, or segment name is invalid | Search sources with `ktx sl <query>`, inspect the source YAML in your project files, then retry using declared fields |
|
| Query compile fails | Measure, dimension, filter, or segment name is invalid | Search sources with `ktx sl <query>`, inspect the source YAML in your project files, then retry using declared fields |
|
||||||
| Execution returns too many rows | `--max-rows` is missing or too high | Add `--max-rows` with a bounded value before executing |
|
| Execution returns too many rows | `--max-rows` is missing or too high | Add `--max-rows` with a bounded value before executing |
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
---
|
---
|
||||||
title: "ktx wiki"
|
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
|
List, search, and read wiki pages in your **ktx** project. Wiki pages are
|
||||||
documents that capture business definitions, rules, and gotchas. Agents search
|
Markdown documents that capture business definitions, rules, and gotchas.
|
||||||
them for context when answering questions about your data.
|
Agents search them for context when answering questions about your data.
|
||||||
|
|
||||||
## Command signature
|
## Command signature
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ktx wiki [options] [query...]
|
ktx wiki [options] [query...] # list (bare) or search (with query)
|
||||||
|
ktx wiki read <key>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Bare `ktx wiki` lists local wiki pages.
|
- Bare `ktx wiki` lists local wiki pages.
|
||||||
- `ktx wiki <query...>` searches local wiki pages (multi-word queries are
|
- `ktx wiki <query...>` searches local wiki pages. Multi-word queries are
|
||||||
joined with a space).
|
joined with a space.
|
||||||
|
- `ktx wiki read <key>` prints the whole Markdown file for one wiki page,
|
||||||
|
including YAML frontmatter.
|
||||||
|
|
||||||
Edit the Markdown files under `wiki/` directly, or ingest source content with
|
Edit the Markdown files under `wiki/` directly, or ingest source content with
|
||||||
`ktx ingest`, when you need to add or update wiki knowledge.
|
`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
|
# Search wiki pages as JSON
|
||||||
ktx wiki "monthly recurring revenue" --json --limit 10
|
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
|
# Print search results as TSV
|
||||||
ktx wiki "monthly recurring revenue" --output plain
|
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
|
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
|
output when requested. JSON output wraps the items with a command metadata
|
||||||
envelope. Search results include `matchReasons` and `lanes` metadata so you can
|
envelope. Search results include `matchReasons` and `lanes` metadata so you can
|
||||||
see whether lexical, token, or semantic search contributed to the ranking. Open
|
see whether lexical, token, or semantic search contributed to the ranking. Use
|
||||||
the matching Markdown files directly when you need the full page contents.
|
`ktx wiki read <key>` when you need the full page contents. Read output is the
|
||||||
|
exact Markdown file stored on disk, including YAML frontmatter, and is not
|
||||||
|
wrapped in pretty, plain, or JSON formatting.
|
||||||
Pretty search output shows `#1`, `#2`, and later rank badges for the displayed
|
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
|
results. Plain and JSON output keep the raw `score` value, which is a ranking
|
||||||
score rather than a percentage.
|
score rather than a percentage.
|
||||||
|
|
@ -121,4 +129,4 @@ stays machine-readable:
|
||||||
| Error | Cause | Recovery |
|
| 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 |
|
| Search returns no results | The query terms do not match summaries, tags, or content, and the semantic lane is unavailable or has no positive matches | Run with `--debug`, check the semantic lane status, retry with business synonyms, then create a page if the knowledge is missing |
|
||||||
| A page is missing | No Markdown file exists for that business context | Add a file under `wiki/` or run `ktx ingest <connectionId>` |
|
| A page is missing | No Markdown file exists for that business context or `ktx wiki read <key>` used the wrong key | Run `ktx wiki <query>` to find the page key, then retry `ktx wiki read <key>` |
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,11 @@ ktx
|
||||||
wiki
|
wiki
|
||||||
list
|
list
|
||||||
search <query>
|
search <query>
|
||||||
|
read <key>
|
||||||
sl
|
sl
|
||||||
list
|
list
|
||||||
search <query>
|
search <query>
|
||||||
|
read <sourceName>
|
||||||
validate <sourceName>
|
validate <sourceName>
|
||||||
query
|
query
|
||||||
sql
|
sql
|
||||||
|
|
@ -57,6 +59,7 @@ ktx
|
||||||
stop
|
stop
|
||||||
status
|
status
|
||||||
reindex
|
reindex
|
||||||
|
completion <shell>
|
||||||
```
|
```
|
||||||
|
|
||||||
The public context-build entrypoint is `ktx ingest [connectionId]` or
|
The public context-build entrypoint is `ktx ingest [connectionId]` or
|
||||||
|
|
@ -97,6 +100,10 @@ ktx ingest
|
||||||
ktx sl "revenue"
|
ktx sl "revenue"
|
||||||
ktx wiki "revenue recognition"
|
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
|
# Execute read-only SQL
|
||||||
ktx sql --connection warehouse "select count(*) from public.orders"
|
ktx sql --connection warehouse "select count(*) from public.orders"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"ktx-wiki",
|
"ktx-wiki",
|
||||||
"ktx-status",
|
"ktx-status",
|
||||||
"ktx-mcp",
|
"ktx-mcp",
|
||||||
"ktx-admin"
|
"ktx-admin",
|
||||||
|
"ktx-completion"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings';
|
import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
||||||
|
import { registerCompletionCommands } from './commands/completion-commands.js';
|
||||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||||
import { registerWikiCommands } from './commands/knowledge-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 {
|
export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
||||||
const program = createBaseProgram(options.packageInfo, options.io);
|
const program = createBaseProgram(options.packageInfo, options.io);
|
||||||
program.hook('preAction', async (_thisCommand, actionCommand) => {
|
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');
|
const telemetry = await import('./telemetry/index.js');
|
||||||
options.setTelemetryModule?.(telemetry);
|
options.setTelemetryModule?.(telemetry);
|
||||||
await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo);
|
await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo);
|
||||||
|
|
@ -476,6 +482,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
||||||
registerStatusCommands(program, context);
|
registerStatusCommands(program, context);
|
||||||
registerMcpCommands(program, context);
|
registerMcpCommands(program, context);
|
||||||
registerAdminCommands(program, context);
|
registerAdminCommands(program, context);
|
||||||
|
registerCompletionCommands(program, context);
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode {
|
||||||
description: command.description(),
|
description: command.description(),
|
||||||
aliases: command.aliases(),
|
aliases: command.aliases(),
|
||||||
arguments: command.registeredArguments.map(formatArgumentDeclaration),
|
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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
44
packages/cli/src/commands/completion-commands.ts
Normal file
44
packages/cli/src/commands/completion-commands.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Argument, type Command } from '@commander-js/extra-typings';
|
||||||
|
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||||
|
import { computeCompletions } from '../completion/complete-engine.js';
|
||||||
|
import { completionScript } from '../completion/completion-scripts.js';
|
||||||
|
import { createProjectCompletionProviders } from '../completion/dynamic-candidates.js';
|
||||||
|
import { profileMark } from '../startup-profile.js';
|
||||||
|
|
||||||
|
profileMark('module:commands/completion-commands');
|
||||||
|
|
||||||
|
export function registerCompletionCommands(program: Command, context: KtxCliCommandContext): void {
|
||||||
|
program
|
||||||
|
.command('completion')
|
||||||
|
.description('Print a shell completion script for ktx')
|
||||||
|
.addArgument(new Argument('<shell>', 'Target shell').choices(['zsh', 'bash']))
|
||||||
|
.addHelpText(
|
||||||
|
'after',
|
||||||
|
'\nEnable completion by adding the matching line to your shell startup file:\n' +
|
||||||
|
' zsh: eval "$(ktx completion zsh)"\n' +
|
||||||
|
' bash: eval "$(ktx completion bash)"\n',
|
||||||
|
)
|
||||||
|
.action((shell) => {
|
||||||
|
context.io.stdout.write(completionScript(shell));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hidden command invoked by the generated shell scripts. It must only ever
|
||||||
|
// print newline-separated candidates to stdout and exit 0, so a TAB press is
|
||||||
|
// never disrupted by an error, a telemetry notice, or a parse failure.
|
||||||
|
program
|
||||||
|
.command('__complete', { hidden: true })
|
||||||
|
.argument('[words...]')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.helpOption(false)
|
||||||
|
.action(async (words: string[]) => {
|
||||||
|
try {
|
||||||
|
const candidates = await computeCompletions(program, words, createProjectCompletionProviders());
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
context.io.stdout.write(`${candidates.join('\n')}\n`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Swallow: completion must never break the shell.
|
||||||
|
}
|
||||||
|
context.setExitCode(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -21,9 +21,9 @@ function isDebugEnabled(command: CommandWithGlobalOptions): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
|
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
|
||||||
program
|
const wiki = program
|
||||||
.command('wiki')
|
.command('wiki')
|
||||||
.description('List or search local wiki pages')
|
.description('List, search, or read local wiki pages')
|
||||||
.usage('[options] [query...]')
|
.usage('[options] [query...]')
|
||||||
.argument('[query...]', 'Search query; omit to list all pages')
|
.argument('[query...]', 'Search query; omit to list all pages')
|
||||||
.option('--user-id <id>', 'Local user id', 'local')
|
.option('--user-id <id>', 'Local user id', 'local')
|
||||||
|
|
@ -76,4 +76,18 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
wiki
|
||||||
|
.command('read')
|
||||||
|
.description('Read a wiki page file by key')
|
||||||
|
.argument('<key>', 'Wiki page key')
|
||||||
|
.action(async (key: string, _options, command) => {
|
||||||
|
const parentOpts = command.parent?.opts() as { userId?: string } | undefined;
|
||||||
|
await runKnowledgeArgs(context, {
|
||||||
|
command: 'read',
|
||||||
|
projectDir: resolveCommandProjectDir(command),
|
||||||
|
key,
|
||||||
|
userId: parentOpts?.userId ?? 'local',
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,19 +94,28 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
sl.command('validate')
|
sl.command('read')
|
||||||
.description('Validate a semantic-layer source (set --connection-id on `ktx sl`)')
|
.description('Read a semantic-layer source YAML file')
|
||||||
|
.argument('<sourceName>', 'Semantic-layer source name')
|
||||||
|
.action(async (sourceName: string, _options, command) => {
|
||||||
|
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||||
|
await runSlArgs(context, {
|
||||||
|
command: 'read',
|
||||||
|
projectDir: resolveCommandProjectDir(command),
|
||||||
|
connectionId: parentOpts?.connectionId,
|
||||||
|
sourceName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sl.command('validate')
|
||||||
|
.description('Validate a semantic-layer source')
|
||||||
.argument('<sourceName>', 'Semantic-layer source name')
|
.argument('<sourceName>', 'Semantic-layer source name')
|
||||||
.action(async (sourceName: string, _options, command) => {
|
.action(async (sourceName: string, _options, command) => {
|
||||||
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||||
const connectionId = parentOpts?.connectionId;
|
|
||||||
if (connectionId === undefined) {
|
|
||||||
command.error("error: required option '--connection-id <id>' not specified");
|
|
||||||
}
|
|
||||||
await runSlArgs(context, {
|
await runSlArgs(context, {
|
||||||
command: 'validate',
|
command: 'validate',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
connectionId: connectionId as string,
|
connectionId: parentOpts?.connectionId,
|
||||||
sourceName,
|
sourceName,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -131,10 +140,14 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
||||||
throw new Error('sl query requires at least one --measure');
|
throw new Error('sl query requires at least one --measure');
|
||||||
}
|
}
|
||||||
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||||
|
const connectionId = parentOpts?.connectionId;
|
||||||
|
if (connectionId === undefined) {
|
||||||
|
command.error("error: required option '--connection-id <id>' not specified");
|
||||||
|
}
|
||||||
const args = slQueryCommandSchema.parse({
|
const args = slQueryCommandSchema.parse({
|
||||||
command: 'query',
|
command: 'query',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
projectDir: resolveCommandProjectDir(command),
|
||||||
connectionId: parentOpts?.connectionId,
|
connectionId,
|
||||||
...(options.queryFile
|
...(options.queryFile
|
||||||
? { queryFile: options.queryFile }
|
? { queryFile: options.queryFile }
|
||||||
: {
|
: {
|
||||||
|
|
|
||||||
172
packages/cli/src/completion/complete-engine.ts
Normal file
172
packages/cli/src/completion/complete-engine.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import type { CommandUnknownOpts, Option } from '@commander-js/extra-typings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic completion candidates that depend on project state (semantic-layer
|
||||||
|
* source names, wiki page keys, connection ids). Injected so the engine stays
|
||||||
|
* pure and unit-testable without touching the filesystem.
|
||||||
|
*/
|
||||||
|
export interface CompletionProviders {
|
||||||
|
/** Candidate operands for a positional argument of the active command path. */
|
||||||
|
positionalCandidates(commandPath: string[], typedTokens: string[]): Promise<string[]>;
|
||||||
|
/** Candidate values for an option that has no static `choices` (e.g. `--connection-id`). */
|
||||||
|
optionValueCandidates(commandPath: string[], optionFlag: string, typedTokens: string[]): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedCommand {
|
||||||
|
command: CommandUnknownOpts;
|
||||||
|
/** Subcommand names from the root down to the active command (root name excluded). */
|
||||||
|
commandPath: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHiddenCommand(command: CommandUnknownOpts): boolean {
|
||||||
|
// Completion mirrors `ktx --help`: commands registered with `{ hidden: true }`
|
||||||
|
// (the `__complete` helper and `mcp serve-internal`) are internal and must not
|
||||||
|
// surface. Commander exposes this only through the private `_hidden` field its
|
||||||
|
// own help renderer reads, so a name heuristic like a `__` prefix is not enough.
|
||||||
|
return (command as { _hidden?: boolean })._hidden === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCommand(program: CommandUnknownOpts, typedTokens: string[]): ResolvedCommand {
|
||||||
|
let command: CommandUnknownOpts = program;
|
||||||
|
const commandPath: string[] = [];
|
||||||
|
for (let index = 0; index < typedTokens.length; index += 1) {
|
||||||
|
const token = typedTokens[index];
|
||||||
|
if (token.startsWith('-')) {
|
||||||
|
// A value-taking option in the `--flag value` form consumes the next token
|
||||||
|
// as its value, so skip that value before matching subcommands. Otherwise a
|
||||||
|
// connection id like `query` would be resolved as the `sl query` subcommand
|
||||||
|
// instead of being treated as the `--connection-id` value. The `--flag=value`
|
||||||
|
// form carries its own value and consumes nothing extra.
|
||||||
|
if (!token.includes('=')) {
|
||||||
|
const option = findOption(command, token);
|
||||||
|
if (option && !option.isBoolean()) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sub = command.commands.find((candidate) => candidate.name() === token || candidate.aliases().includes(token));
|
||||||
|
if (sub) {
|
||||||
|
command = sub;
|
||||||
|
commandPath.push(sub.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { command, commandPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOptions(command: CommandUnknownOpts): Option[] {
|
||||||
|
const options: Option[] = [];
|
||||||
|
let current: CommandUnknownOpts | null = command;
|
||||||
|
while (current) {
|
||||||
|
options.push(...current.options);
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOption(command: CommandUnknownOpts, flag: string): Option | undefined {
|
||||||
|
return collectOptions(command).find((option) => option.long === flag || option.short === flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRepeatableOption(option: Option): boolean {
|
||||||
|
// Variadic options, and options backed by a collector with an array default
|
||||||
|
// (e.g. `--measure`/`--dimension`), may be supplied more than once.
|
||||||
|
return option.variadic || Array.isArray(option.defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagCandidates(command: CommandUnknownOpts, typedTokens: string[]): string[] {
|
||||||
|
const present = new Set(typedTokens.filter((token) => token.startsWith('-')));
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const option of collectOptions(command)) {
|
||||||
|
if (option.hidden || !option.long) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (present.has(option.long) && !isRepeatableOption(option)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(option.long);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionValueCandidates(
|
||||||
|
resolved: ResolvedCommand,
|
||||||
|
option: Option,
|
||||||
|
typedTokens: string[],
|
||||||
|
providers: CompletionProviders,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (option.argChoices && option.argChoices.length > 0) {
|
||||||
|
return option.argChoices;
|
||||||
|
}
|
||||||
|
return providers.optionValueCandidates(resolved.commandPath, option.long ?? option.name(), typedTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeSortFilter(candidates: string[], partial: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const matches: string[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate.startsWith(partial) || seen.has(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(candidate);
|
||||||
|
matches.push(candidate);
|
||||||
|
}
|
||||||
|
return matches.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute completion candidates for the partial last element of `words`
|
||||||
|
* (everything the shell has on the line after `ktx`). The active command and
|
||||||
|
* its flags are derived by walking the live Commander tree, so completion never
|
||||||
|
* drifts from the real command structure.
|
||||||
|
*/
|
||||||
|
export async function computeCompletions(
|
||||||
|
program: CommandUnknownOpts,
|
||||||
|
words: string[],
|
||||||
|
providers: CompletionProviders,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const partial = words.length > 0 ? (words[words.length - 1] ?? '') : '';
|
||||||
|
const typedTokens = words.slice(0, -1);
|
||||||
|
const resolved = resolveCommand(program, typedTokens);
|
||||||
|
|
||||||
|
// (a) Option value via the `--opt=value` form.
|
||||||
|
const equalsMatch = /^(--[^=]+)=(.*)$/.exec(partial);
|
||||||
|
if (equalsMatch) {
|
||||||
|
const [, flag, valuePartial] = equalsMatch;
|
||||||
|
const option = findOption(resolved.command, flag);
|
||||||
|
if (!option || option.isBoolean()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const values = await optionValueCandidates(resolved, option, typedTokens, providers);
|
||||||
|
return dedupeSortFilter(
|
||||||
|
values.map((value) => `${flag}=${value}`),
|
||||||
|
`${flag}=${valuePartial}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) Option value via the `--opt value` form (previous token is a value-taking option).
|
||||||
|
const previous = typedTokens[typedTokens.length - 1];
|
||||||
|
if (previous && previous.startsWith('-') && !partial.startsWith('-')) {
|
||||||
|
const option = findOption(resolved.command, previous);
|
||||||
|
if (option && !option.isBoolean()) {
|
||||||
|
return dedupeSortFilter(await optionValueCandidates(resolved, option, typedTokens, providers), partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) Flag completion.
|
||||||
|
if (partial.startsWith('-')) {
|
||||||
|
return dedupeSortFilter(flagCandidates(resolved.command, typedTokens), partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) Positional: subcommand names union static argument choices union dynamic operand candidates.
|
||||||
|
const candidates: string[] = resolved.command.commands
|
||||||
|
.filter((sub) => !isHiddenCommand(sub))
|
||||||
|
.map((sub) => sub.name());
|
||||||
|
for (const argument of resolved.command.registeredArguments) {
|
||||||
|
if (argument.argChoices) {
|
||||||
|
candidates.push(...argument.argChoices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.push(...(await providers.positionalCandidates(resolved.commandPath, typedTokens)));
|
||||||
|
return dedupeSortFilter(candidates, partial);
|
||||||
|
}
|
||||||
39
packages/cli/src/completion/completion-scripts.ts
Normal file
39
packages/cli/src/completion/completion-scripts.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Static shell completion scripts emitted by `ktx completion <shell>`.
|
||||||
|
//
|
||||||
|
// Both scripts gather the words on the current command line (excluding the
|
||||||
|
// leading `ktx`), append the partial word under the cursor, and delegate to the
|
||||||
|
// hidden `ktx __complete` command, which prints newline-separated candidates.
|
||||||
|
// All command/flag/entity knowledge lives in `ktx __complete` so these scripts
|
||||||
|
// never have to encode the command tree.
|
||||||
|
//
|
||||||
|
// Lines are single-quoted JS strings so the shell `${...}` expansions are
|
||||||
|
// emitted verbatim (a template literal would try to interpolate them).
|
||||||
|
|
||||||
|
const ZSH_SCRIPT = [
|
||||||
|
'#compdef ktx',
|
||||||
|
'_ktx() {',
|
||||||
|
' local -a candidates',
|
||||||
|
' local out',
|
||||||
|
' out="$(ktx __complete -- "${words[@]:1:$((CURRENT-1))}" 2>/dev/null)" || return 0',
|
||||||
|
' candidates=("${(@f)out}")',
|
||||||
|
' compadd -- $candidates',
|
||||||
|
'}',
|
||||||
|
'compdef _ktx ktx',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const BASH_SCRIPT = [
|
||||||
|
'_ktx() {',
|
||||||
|
' local cur out',
|
||||||
|
' cur="${COMP_WORDS[COMP_CWORD]}"',
|
||||||
|
' out="$(ktx __complete -- "${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null)" || { COMPREPLY=(); return 0; }',
|
||||||
|
" local IFS=$'\\n'",
|
||||||
|
' COMPREPLY=($(compgen -W "${out}" -- "$cur"))',
|
||||||
|
'}',
|
||||||
|
'complete -F _ktx ktx',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
export function completionScript(shell: 'zsh' | 'bash'): string {
|
||||||
|
return shell === 'zsh' ? ZSH_SCRIPT : BASH_SCRIPT;
|
||||||
|
}
|
||||||
103
packages/cli/src/completion/dynamic-candidates.ts
Normal file
103
packages/cli/src/completion/dynamic-candidates.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { KtxLocalProject } from '../context/project/project.js';
|
||||||
|
import { resolveKtxProjectDir } from '../project-resolver.js';
|
||||||
|
import type { CompletionProviders } from './complete-engine.js';
|
||||||
|
|
||||||
|
/** Extract an option value from already-typed tokens (`--flag value` or `--flag=value`). */
|
||||||
|
function extractOptionValue(tokens: string[], flag: string): string | undefined {
|
||||||
|
const prefix = `${flag}=`;
|
||||||
|
for (let index = 0; index < tokens.length; index += 1) {
|
||||||
|
const token = tokens[index];
|
||||||
|
if (token === flag) {
|
||||||
|
const next = tokens[index + 1];
|
||||||
|
if (next !== undefined && !next.startsWith('-')) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
} else if (token.startsWith(prefix)) {
|
||||||
|
return token.slice(prefix.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve and load the project the user is completing against. Honors a
|
||||||
|
* `--project-dir` typed on the line, then `KTX_PROJECT_DIR`, then the nearest
|
||||||
|
* `ktx.yaml`. Returns null (no completions) when there is no project, without
|
||||||
|
* creating any files.
|
||||||
|
*/
|
||||||
|
async function loadCompletionProject(typedTokens: string[]): Promise<KtxLocalProject | null> {
|
||||||
|
const explicitProjectDir = extractOptionValue(typedTokens, '--project-dir');
|
||||||
|
const projectDir = resolveKtxProjectDir(explicitProjectDir !== undefined ? { explicitProjectDir } : {});
|
||||||
|
if (!existsSync(join(projectDir, 'ktx.yaml'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { loadKtxProject } = await import('../context/project/project.js');
|
||||||
|
return loadKtxProject({ projectDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceNames(typedTokens: string[]): Promise<string[]> {
|
||||||
|
const project = await loadCompletionProject(typedTokens);
|
||||||
|
if (!project) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const connectionId = extractOptionValue(typedTokens, '--connection-id');
|
||||||
|
const { listLocalSlSources } = await import('../context/sl/local-sl.js');
|
||||||
|
const summaries = await listLocalSlSources(project, connectionId !== undefined ? { connectionId } : {});
|
||||||
|
return [...new Set(summaries.map((summary) => summary.name))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wikiPageKeys(typedTokens: string[]): Promise<string[]> {
|
||||||
|
const project = await loadCompletionProject(typedTokens);
|
||||||
|
if (!project) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const userId = extractOptionValue(typedTokens, '--user-id');
|
||||||
|
const { listLocalKnowledgePageKeys } = await import('../context/wiki/local-knowledge.js');
|
||||||
|
return listLocalKnowledgePageKeys(project, userId !== undefined ? { userId } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectionIds(typedTokens: string[]): Promise<string[]> {
|
||||||
|
const project = await loadCompletionProject(typedTokens);
|
||||||
|
if (!project) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.keys(project.config.connections).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-backed completion providers. Every entry swallows its own errors so a
|
||||||
|
* failed lookup never breaks the shell — completion degrades to commands/flags.
|
||||||
|
*/
|
||||||
|
export function createProjectCompletionProviders(): CompletionProviders {
|
||||||
|
return {
|
||||||
|
async positionalCandidates(commandPath, typedTokens) {
|
||||||
|
try {
|
||||||
|
const key = commandPath.join(' ');
|
||||||
|
if (key === 'sl read' || key === 'sl validate') {
|
||||||
|
return await sourceNames(typedTokens);
|
||||||
|
}
|
||||||
|
if (key === 'wiki read') {
|
||||||
|
return await wikiPageKeys(typedTokens);
|
||||||
|
}
|
||||||
|
if (key === 'connection test' || key === 'ingest') {
|
||||||
|
return await connectionIds(typedTokens);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async optionValueCandidates(_commandPath, optionFlag, typedTokens) {
|
||||||
|
try {
|
||||||
|
if (optionFlag === '--connection-id' || optionFlag === '--connection') {
|
||||||
|
return await connectionIds(typedTokens);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,7 @@ export interface LocalSlSearchInput {
|
||||||
pglite?: PgliteSlSearchPrototypeOwnerOptions;
|
pglite?: PgliteSlSearchPrototypeOwnerOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
export interface LocalSlSource extends LocalSlSourceSummary {
|
export interface LocalSlSource extends LocalSlSourceSummary {
|
||||||
yaml: string;
|
yaml: string;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +64,11 @@ export interface LocalSlValidationResult {
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResolvedSlSource =
|
||||||
|
| { kind: 'found'; source: LocalSlSource }
|
||||||
|
| { kind: 'not-found' }
|
||||||
|
| { kind: 'ambiguous'; connectionIds: string[] };
|
||||||
|
|
||||||
const LOCAL_AUTHOR = 'ktx';
|
const LOCAL_AUTHOR = 'ktx';
|
||||||
const LOCAL_AUTHOR_EMAIL = 'ktx@example.com';
|
const LOCAL_AUTHOR_EMAIL = 'ktx@example.com';
|
||||||
|
|
||||||
|
|
@ -311,6 +317,7 @@ export async function writeLocalSlSource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
export async function readLocalSlSource(
|
export async function readLocalSlSource(
|
||||||
project: KtxLocalProject,
|
project: KtxLocalProject,
|
||||||
input: { connectionId: string; sourceName: string },
|
input: { connectionId: string; sourceName: string },
|
||||||
|
|
@ -331,6 +338,41 @@ export async function readLocalSlSource(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveLocalSlSource(
|
||||||
|
project: KtxLocalProject,
|
||||||
|
input: { sourceName: string; connectionId?: string },
|
||||||
|
): Promise<ResolvedSlSource> {
|
||||||
|
if (input.connectionId !== undefined) {
|
||||||
|
const source = await readLocalSlSource(project, {
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
sourceName: input.sourceName,
|
||||||
|
});
|
||||||
|
return source ? { kind: 'found', source } : { kind: 'not-found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = await listLocalSlSources(project, {});
|
||||||
|
const matches = summaries.filter((summary) => summary.name === input.sourceName);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { kind: 'not-found' };
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return {
|
||||||
|
kind: 'ambiguous',
|
||||||
|
connectionIds: [...new Set(matches.map((match) => match.connectionId))].sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = matches[0];
|
||||||
|
if (match === undefined) {
|
||||||
|
return { kind: 'not-found' };
|
||||||
|
}
|
||||||
|
const source = await readLocalSlSource(project, {
|
||||||
|
connectionId: match.connectionId,
|
||||||
|
sourceName: input.sourceName,
|
||||||
|
});
|
||||||
|
return source ? { kind: 'found', source } : { kind: 'not-found' };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listLocalSlSources(
|
export async function listLocalSlSources(
|
||||||
project: KtxLocalProject,
|
project: KtxLocalProject,
|
||||||
input: { connectionId?: string } = {},
|
input: { connectionId?: string } = {},
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,32 @@ export async function listLocalKnowledgePages(
|
||||||
return pages.sort((left, right) => left.path.localeCompare(right.path));
|
return pages.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List wiki page keys without reading or parsing file contents.
|
||||||
|
*
|
||||||
|
* Keys are derived purely from file paths, so this stays cheap enough for
|
||||||
|
* shell tab-completion (unlike `listLocalKnowledgePages`, which reads every
|
||||||
|
* page to populate summaries).
|
||||||
|
*/
|
||||||
|
export async function listLocalKnowledgePageKeys(
|
||||||
|
project: KtxLocalProject,
|
||||||
|
input: { userId?: string } = {},
|
||||||
|
): Promise<string[]> {
|
||||||
|
const userId = input.userId ?? 'local';
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const scope of ['GLOBAL', 'USER'] as const) {
|
||||||
|
const root = scope === 'GLOBAL' ? 'wiki/global' : `wiki/user/${assertSafePathToken('user id', userId)}`;
|
||||||
|
const listed = await project.fileStore.listFiles(root);
|
||||||
|
for (const path of listed.files.filter((file) => file.endsWith('.md'))) {
|
||||||
|
const key = keyFromKnowledgePath(path, scope, userId);
|
||||||
|
if (key) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys].sort();
|
||||||
|
}
|
||||||
|
|
||||||
function scorePage(page: LocalKnowledgePage, terms: string[]): number {
|
function scorePage(page: LocalKnowledgePage, terms: string[]): number {
|
||||||
const haystack = buildKnowledgeSearchText(page.key, page.summary, page.content, page.tags).toLowerCase();
|
const haystack = buildKnowledgeSearchText(page.key, page.summary, page.content, page.tags).toLowerCase();
|
||||||
return terms.some((term) => haystack.includes(term)) ? 3 : 0;
|
return terms.some((term) => haystack.includes(term)) ? 3 : 0;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js';
|
import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js';
|
||||||
import type { KtxEmbeddingPort } from './context/core/embedding.js';
|
import type { KtxEmbeddingPort } from './context/core/embedding.js';
|
||||||
import { loadKtxProject } from './context/project/project.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 {
|
import {
|
||||||
resolveProjectEmbeddingProvider,
|
resolveProjectEmbeddingProvider,
|
||||||
type EmbeddingProviderResolution,
|
type EmbeddingProviderResolution,
|
||||||
|
|
@ -22,7 +28,8 @@ export type KtxKnowledgeArgs =
|
||||||
limit?: number;
|
limit?: number;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
cliVersion: string;
|
cliVersion: string;
|
||||||
};
|
}
|
||||||
|
| { command: 'read'; projectDir: string; key: string; userId: string };
|
||||||
|
|
||||||
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
|
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
|
||||||
|
|
||||||
|
|
@ -128,6 +135,15 @@ export async function runKtxKnowledge(
|
||||||
});
|
});
|
||||||
return 0;
|
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') {
|
if (args.command === 'search') {
|
||||||
const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io);
|
const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io);
|
||||||
const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages;
|
const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,14 @@ import type { KtxEmbeddingPort } from './context/core/embedding.js';
|
||||||
import type { KtxSemanticLayerComputePort } from './context/daemon/semantic-layer-compute.js';
|
import type { KtxSemanticLayerComputePort } from './context/daemon/semantic-layer-compute.js';
|
||||||
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
|
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
|
||||||
import { compileLocalSlQuery } from './context/sl/local-query.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 type { SemanticLayerQueryInput } from './context/sl/types.js';
|
||||||
import {
|
import {
|
||||||
resolveProjectEmbeddingProvider,
|
resolveProjectEmbeddingProvider,
|
||||||
|
|
@ -45,7 +52,8 @@ export type KtxSlArgs =
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
cliVersion: string;
|
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';
|
command: 'query';
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
|
|
@ -185,6 +193,12 @@ async function readSlQueryFile(path: string): Promise<SemanticLayerQueryInput> {
|
||||||
return parsed as SemanticLayerQueryInput;
|
return parsed as SemanticLayerQueryInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ambiguousSourceMessage(sourceName: string, connectionIds: readonly string[]): string {
|
||||||
|
return `Source '${sourceName}' exists in multiple connections: ${connectionIds.join(
|
||||||
|
', ',
|
||||||
|
)}. Re-run with --connection-id <id>.`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
|
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
let queryForTelemetry: SemanticLayerQueryInput | undefined;
|
let queryForTelemetry: SemanticLayerQueryInput | undefined;
|
||||||
|
|
@ -232,25 +246,50 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
||||||
});
|
});
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (args.command === 'validate') {
|
if (args.command === 'read') {
|
||||||
const source = await readLocalSlSource(project, {
|
const resolved = await resolveLocalSlSource(project, {
|
||||||
connectionId: args.connectionId,
|
connectionId: args.connectionId,
|
||||||
sourceName: args.sourceName,
|
sourceName: args.sourceName,
|
||||||
});
|
});
|
||||||
if (!source) {
|
if (resolved.kind === 'not-found') {
|
||||||
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was 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, {
|
if (resolved.kind === 'ambiguous') {
|
||||||
project,
|
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,
|
connectionId: args.connectionId,
|
||||||
sourceName: args.sourceName,
|
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({
|
await emitTelemetryEvent({
|
||||||
name: 'sl_validate_completed',
|
name: 'sl_validate_completed',
|
||||||
projectDir: args.projectDir,
|
projectDir: args.projectDir,
|
||||||
io,
|
io,
|
||||||
fields: {
|
fields: {
|
||||||
sourceCount: source ? 1 : 0,
|
sourceCount: 1,
|
||||||
modelCount: 0,
|
modelCount: 0,
|
||||||
validationErrorCount: result.valid ? 0 : result.errors.length,
|
validationErrorCount: result.valid ? 0 : result.errors.length,
|
||||||
outcome: result.valid ? 'ok' : 'error',
|
outcome: result.valid ? 'ok' : 'error',
|
||||||
|
|
@ -263,7 +302,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
||||||
}
|
}
|
||||||
return 1;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
if (args.command === 'query') {
|
if (args.command === 'query') {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { runCommanderKtxCli } from '../src/cli-program.js';
|
import { runCommanderKtxCli } from '../src/cli-program.js';
|
||||||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.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 } {
|
function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } {
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
|
@ -85,7 +86,7 @@ describe('runCommanderKtxCli telemetry', () => {
|
||||||
expect(statusIo.stderr()).toContain('"connectionCount"');
|
expect(statusIo.stderr()).toContain('"connectionCount"');
|
||||||
expect(statusIo.stderr()).not.toContain(tempDir);
|
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]');
|
const firstTelemetryIndex = statusIo.stderr().indexOf('[telemetry]');
|
||||||
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(firstTelemetryIndex).toBeGreaterThan(noticeIndex);
|
expect(firstTelemetryIndex).toBeGreaterThan(noticeIndex);
|
||||||
|
|
|
||||||
157
packages/cli/test/commands/wiki-sl-read-commands.test.ts
Normal file
157
packages/cli/test/commands/wiki-sl-read-commands.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { Command } from '@commander-js/extra-typings';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { KtxCliCommandContext } from '../../src/cli-program.js';
|
||||||
|
import { registerWikiCommands } from '../../src/commands/knowledge-commands.js';
|
||||||
|
import { registerSlCommands } from '../../src/commands/sl-commands.js';
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<KtxCliCommandContext> = {}): KtxCliCommandContext {
|
||||||
|
let exitCode = 0;
|
||||||
|
return {
|
||||||
|
io: {
|
||||||
|
stdout: { write: vi.fn() },
|
||||||
|
stderr: { write: vi.fn() },
|
||||||
|
},
|
||||||
|
deps: {},
|
||||||
|
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||||
|
setExitCode: (code) => {
|
||||||
|
exitCode = code;
|
||||||
|
},
|
||||||
|
runInit: vi.fn(),
|
||||||
|
writeDebug: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
get exitCode() {
|
||||||
|
return exitCode;
|
||||||
|
},
|
||||||
|
} as KtxCliCommandContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wiki and sl read command routing', () => {
|
||||||
|
it('routes wiki read through the knowledge runner', async () => {
|
||||||
|
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||||
|
const knowledge = vi.fn(async () => 0);
|
||||||
|
const context = makeContext({ deps: { knowledge } });
|
||||||
|
registerWikiCommands(program, context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(['--project-dir', '/tmp/ktx-project', 'wiki', 'read', 'metrics-revenue'], {
|
||||||
|
from: 'user',
|
||||||
|
}),
|
||||||
|
).resolves.toBe(program);
|
||||||
|
|
||||||
|
expect(knowledge).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir: '/tmp/ktx-project',
|
||||||
|
key: 'metrics-revenue',
|
||||||
|
userId: 'local',
|
||||||
|
},
|
||||||
|
context.io,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes wiki read with the parent --user-id option', async () => {
|
||||||
|
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||||
|
const knowledge = vi.fn(async () => 0);
|
||||||
|
const context = makeContext({ deps: { knowledge } });
|
||||||
|
registerWikiCommands(program, context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(
|
||||||
|
['--project-dir', '/tmp/ktx-project', 'wiki', '--user-id', 'alex', 'read', 'handoff'],
|
||||||
|
{ from: 'user' },
|
||||||
|
),
|
||||||
|
).resolves.toBe(program);
|
||||||
|
|
||||||
|
expect(knowledge).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir: '/tmp/ktx-project',
|
||||||
|
key: 'handoff',
|
||||||
|
userId: 'alex',
|
||||||
|
},
|
||||||
|
context.io,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes sl read through the semantic-layer runner', async () => {
|
||||||
|
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||||
|
const sl = vi.fn(async () => 0);
|
||||||
|
const context = makeContext({ deps: { sl } });
|
||||||
|
registerSlCommands(program, context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(
|
||||||
|
['--project-dir', '/tmp/ktx-project', 'sl', '--connection-id', 'warehouse', 'read', 'orders'],
|
||||||
|
{ from: 'user' },
|
||||||
|
),
|
||||||
|
).resolves.toBe(program);
|
||||||
|
|
||||||
|
expect(sl).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir: '/tmp/ktx-project',
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
sourceName: 'orders',
|
||||||
|
},
|
||||||
|
context.io,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes sl read without --connection-id through the semantic-layer runner', async () => {
|
||||||
|
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||||
|
const sl = vi.fn(async () => 0);
|
||||||
|
const context = makeContext({ deps: { sl } });
|
||||||
|
registerSlCommands(program, context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(['--project-dir', '/tmp/ktx-project', 'sl', 'read', 'orders'], { from: 'user' }),
|
||||||
|
).resolves.toBe(program);
|
||||||
|
|
||||||
|
expect(sl).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir: '/tmp/ktx-project',
|
||||||
|
connectionId: undefined,
|
||||||
|
sourceName: 'orders',
|
||||||
|
},
|
||||||
|
context.io,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes sl validate without --connection-id through the semantic-layer runner', async () => {
|
||||||
|
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||||
|
const sl = vi.fn(async () => 0);
|
||||||
|
const context = makeContext({ deps: { sl } });
|
||||||
|
registerSlCommands(program, context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(['--project-dir', '/tmp/ktx-project', 'sl', 'validate', 'orders'], { from: 'user' }),
|
||||||
|
).resolves.toBe(program);
|
||||||
|
|
||||||
|
expect(sl).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
command: 'validate',
|
||||||
|
projectDir: '/tmp/ktx-project',
|
||||||
|
connectionId: undefined,
|
||||||
|
sourceName: 'orders',
|
||||||
|
},
|
||||||
|
context.io,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sl query requiring --connection-id before invoking the runner', async () => {
|
||||||
|
const program = new Command().exitOverride().option('--project-dir <path>');
|
||||||
|
const sl = vi.fn(async () => 0);
|
||||||
|
const context = makeContext({ deps: { sl } });
|
||||||
|
registerSlCommands(program, context);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(
|
||||||
|
['--project-dir', '/tmp/ktx-project', 'sl', 'query', '--measure', 'orders.count'],
|
||||||
|
{ from: 'user' },
|
||||||
|
),
|
||||||
|
).rejects.toThrow("error: required option '--connection-id <id>' not specified");
|
||||||
|
|
||||||
|
expect(sl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
137
packages/cli/test/completion/complete-engine.test.ts
Normal file
137
packages/cli/test/completion/complete-engine.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import type { Command } from '@commander-js/extra-typings';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildKtxProgram } from '../../src/cli-program.js';
|
||||||
|
import type { KtxCliIo, KtxCliPackageInfo } from '../../src/cli-runtime.js';
|
||||||
|
import { type CompletionProviders, computeCompletions } from '../../src/completion/complete-engine.js';
|
||||||
|
|
||||||
|
function stubIo(): KtxCliIo {
|
||||||
|
return { stdout: { isTTY: false, columns: 80, write: () => {} }, stderr: { write: () => {} } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubPackageInfo(): KtxCliPackageInfo {
|
||||||
|
return { name: '@kaelio/ktx', version: '0.0.0-test' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProgram(): Command {
|
||||||
|
return buildKtxProgram({ io: stubIo(), deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCES = ['orders', 'customers'];
|
||||||
|
const WIKI_KEYS = ['revenue', 'churn'];
|
||||||
|
const CONNECTIONS = ['warehouse'];
|
||||||
|
|
||||||
|
function fakeProviders(overrides: Partial<CompletionProviders> = {}): CompletionProviders {
|
||||||
|
return {
|
||||||
|
async positionalCandidates(commandPath) {
|
||||||
|
const key = commandPath.join(' ');
|
||||||
|
if (key === 'sl read' || key === 'sl validate') {
|
||||||
|
return SOURCES;
|
||||||
|
}
|
||||||
|
if (key === 'wiki read') {
|
||||||
|
return WIKI_KEYS;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async optionValueCandidates(_commandPath, optionFlag) {
|
||||||
|
return optionFlag === '--connection-id' ? CONNECTIONS : [];
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete(words: string[], providers: CompletionProviders = fakeProviders()): Promise<string[]> {
|
||||||
|
return computeCompletions(buildProgram(), words, providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeCompletions', () => {
|
||||||
|
it('lists top-level commands and hides internal ones', async () => {
|
||||||
|
const result = await complete(['']);
|
||||||
|
expect(result).toContain('sl');
|
||||||
|
expect(result).toContain('wiki');
|
||||||
|
expect(result).toContain('completion');
|
||||||
|
expect(result).not.toContain('__complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters top-level commands by prefix', async () => {
|
||||||
|
expect(await complete(['co'])).toEqual(['completion', 'connection']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Commander-hidden subcommands such as `mcp serve-internal`', async () => {
|
||||||
|
const result = await complete(['mcp', '']);
|
||||||
|
expect(result).not.toContain('serve-internal');
|
||||||
|
expect(result).toEqual(['logs', 'start', 'status', 'stdio', 'stop']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only sl subcommands at the bare sl positional', async () => {
|
||||||
|
expect(await complete(['sl', ''])).toEqual(['query', 'read', 'validate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers source names for sl read and sl validate', async () => {
|
||||||
|
expect(await complete(['sl', 'read', ''])).toEqual(['customers', 'orders']);
|
||||||
|
expect(await complete(['sl', 'validate', ''])).toEqual(['customers', 'orders']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only the wiki read subcommand at the bare wiki positional', async () => {
|
||||||
|
expect(await complete(['wiki', ''])).toEqual(['read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers wiki page keys for wiki read', async () => {
|
||||||
|
expect(await complete(['wiki', 'read', ''])).toEqual(['churn', 'revenue']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not complete entity names for bare search positionals', async () => {
|
||||||
|
expect(await complete(['sl', 'o'])).toEqual([]);
|
||||||
|
expect(await complete(['wiki', 'r'])).toEqual(['read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes flags (own + inherited globals) when the partial starts with a dash', async () => {
|
||||||
|
const result = await complete(['sl', '-']);
|
||||||
|
expect(result).toContain('--connection-id');
|
||||||
|
expect(result).toContain('--output');
|
||||||
|
expect(result).toContain('--json');
|
||||||
|
expect(result).toContain('--debug');
|
||||||
|
expect(result).toContain('--project-dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes option choices for the `--opt value` form', async () => {
|
||||||
|
expect(await complete(['sl', '--output', ''])).toEqual(['json', 'plain', 'pretty']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes option choices for the `--opt=value` form', async () => {
|
||||||
|
expect(await complete(['sl', '--output=pr'])).toEqual(['--output=pretty']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes option values from a provider for options without static choices', async () => {
|
||||||
|
expect(await complete(['sl', '--connection-id', ''])).toEqual(['warehouse']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to positional completion after a boolean flag', async () => {
|
||||||
|
const result = await complete(['sl', '--json', '']);
|
||||||
|
expect(result).toEqual(['query', 'read', 'validate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a value-taking option value as a subcommand', async () => {
|
||||||
|
// A connection id that happens to match a subcommand name (`query`, `read`)
|
||||||
|
// is the `--connection-id` value, not a subcommand: the next positional must
|
||||||
|
// still offer the `sl` subcommands rather than resolving into `sl query`/`sl read`.
|
||||||
|
expect(await complete(['sl', '--connection-id', 'query', ''])).toEqual(['query', 'read', 'validate']);
|
||||||
|
expect(await complete(['sl', '--connection-id', 'read', ''])).toEqual(['query', 'read', 'validate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns subcommands/flags when dynamic providers yield nothing (no project)', async () => {
|
||||||
|
const empty = fakeProviders({
|
||||||
|
positionalCandidates: async () => [],
|
||||||
|
optionValueCandidates: async () => [],
|
||||||
|
});
|
||||||
|
expect(await complete(['sl', ''], empty)).toEqual(['query', 'read', 'validate']);
|
||||||
|
expect(await complete(['-'], empty)).toContain('--debug');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes the completion command shell positional from its static choices', async () => {
|
||||||
|
expect(await complete(['completion', ''])).toEqual(['bash', 'zsh']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters positional argument choices by prefix', async () => {
|
||||||
|
expect(await complete(['completion', 'z'])).toEqual(['zsh']);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
packages/cli/test/completion/completion-scripts.test.ts
Normal file
20
packages/cli/test/completion/completion-scripts.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { completionScript } from '../../src/completion/completion-scripts.js';
|
||||||
|
|
||||||
|
describe('completionScript', () => {
|
||||||
|
it('emits a zsh script that registers _ktx and delegates to ktx __complete', () => {
|
||||||
|
const script = completionScript('zsh');
|
||||||
|
expect(script).toContain('#compdef ktx');
|
||||||
|
expect(script).toContain('compdef _ktx ktx');
|
||||||
|
expect(script).toContain('ktx __complete --');
|
||||||
|
expect(script).toContain('compadd -- $candidates');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a bash script that registers _ktx and preserves newline-split candidates', () => {
|
||||||
|
const script = completionScript('bash');
|
||||||
|
expect(script).toContain('complete -F _ktx ktx');
|
||||||
|
expect(script).toContain('ktx __complete --');
|
||||||
|
expect(script).toContain("local IFS=$'\\n'");
|
||||||
|
expect(script).toContain('COMPREPLY=($(compgen -W "${out}" -- "$cur"))');
|
||||||
|
});
|
||||||
|
});
|
||||||
103
packages/cli/test/completion/dynamic-candidates.test.ts
Normal file
103
packages/cli/test/completion/dynamic-candidates.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { createProjectCompletionProviders } from '../../src/completion/dynamic-candidates.js';
|
||||||
|
|
||||||
|
const KTX_YAML = ['connections:', ' warehouse:', ' driver: sqlite', ' analytics:', ' driver: sqlite', ''].join(
|
||||||
|
'\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('createProjectCompletionProviders', () => {
|
||||||
|
let projectDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
projectDir = await mkdtemp(join(tmpdir(), 'ktx-completion-'));
|
||||||
|
await writeFile(join(projectDir, 'ktx.yaml'), KTX_YAML, 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(projectDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedProjectEntities(): Promise<void> {
|
||||||
|
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'),
|
||||||
|
['name: orders', 'table: public.orders', 'grain: [order_id]', 'columns: []', ''].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
await mkdir(join(projectDir, 'semantic-layer', 'analytics'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(projectDir, 'semantic-layer', 'analytics', 'orders.yaml'),
|
||||||
|
['name: orders', 'table: public.analytics_orders', 'grain: [order_id]', 'columns: []', ''].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(projectDir, 'semantic-layer', 'analytics', 'tickets.yaml'),
|
||||||
|
['name: tickets', 'table: public.tickets', 'grain: [ticket_id]', 'columns: []', ''].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
await mkdir(join(projectDir, 'wiki', 'global'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(projectDir, 'wiki', 'global', 'revenue.md'),
|
||||||
|
['---', 'summary: Revenue', 'tags: []', 'refs: []', 'sl_refs: []', '---', '', 'Revenue rules.', ''].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('completes connection ids for the `connection test` positional', async () => {
|
||||||
|
const providers = createProjectCompletionProviders();
|
||||||
|
const result = await providers.positionalCandidates(['connection', 'test'], ['--project-dir', projectDir]);
|
||||||
|
expect(result).toEqual(['analytics', 'warehouse']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes connection ids for the `ingest` positional', async () => {
|
||||||
|
const providers = createProjectCompletionProviders();
|
||||||
|
const result = await providers.positionalCandidates(['ingest'], ['--project-dir', projectDir]);
|
||||||
|
expect(result).toEqual(['analytics', 'warehouse']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes entity names only for read and validate subcommands', async () => {
|
||||||
|
await seedProjectEntities();
|
||||||
|
const providers = createProjectCompletionProviders();
|
||||||
|
|
||||||
|
await expect(providers.positionalCandidates(['sl'], ['--project-dir', projectDir])).resolves.toEqual([]);
|
||||||
|
await expect(providers.positionalCandidates(['sl', 'read'], ['--project-dir', projectDir])).resolves.toEqual([
|
||||||
|
'orders',
|
||||||
|
'tickets',
|
||||||
|
]);
|
||||||
|
await expect(providers.positionalCandidates(['sl', 'validate'], ['--project-dir', projectDir])).resolves.toEqual([
|
||||||
|
'orders',
|
||||||
|
'tickets',
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
providers.positionalCandidates(['sl', 'read'], ['--project-dir', projectDir, '--connection-id', 'warehouse']),
|
||||||
|
).resolves.toEqual(['orders']);
|
||||||
|
await expect(
|
||||||
|
providers.positionalCandidates(['sl', 'validate'], ['--project-dir', projectDir, '--connection-id', 'analytics']),
|
||||||
|
).resolves.toEqual(['orders', 'tickets']);
|
||||||
|
await expect(providers.positionalCandidates(['wiki'], ['--project-dir', projectDir])).resolves.toEqual([]);
|
||||||
|
await expect(providers.positionalCandidates(['wiki', 'read'], ['--project-dir', projectDir])).resolves.toEqual([
|
||||||
|
'revenue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no positional candidates outside a project', async () => {
|
||||||
|
const providers = createProjectCompletionProviders();
|
||||||
|
const result = await providers.positionalCandidates(['connection', 'test'], ['--project-dir', join(projectDir, 'nope')]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes connection ids for the sql --connection option', async () => {
|
||||||
|
const providers = createProjectCompletionProviders();
|
||||||
|
const result = await providers.optionValueCandidates(['sql'], '--connection', ['--project-dir', projectDir]);
|
||||||
|
expect(result).toEqual(['analytics', 'warehouse']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still completes connection ids for the --connection-id option', async () => {
|
||||||
|
const providers = createProjectCompletionProviders();
|
||||||
|
const result = await providers.optionValueCandidates(['ingest'], '--connection-id', ['--project-dir', projectDir]);
|
||||||
|
expect(result).toEqual(['analytics', 'warehouse']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import { initKtxProject, type KtxLocalProject } from '../../../src/context/proje
|
||||||
import {
|
import {
|
||||||
listLocalSlSources,
|
listLocalSlSources,
|
||||||
readLocalSlSource,
|
readLocalSlSource,
|
||||||
|
resolveLocalSlSource,
|
||||||
searchLocalSlSources,
|
searchLocalSlSources,
|
||||||
validateLocalSlSource,
|
validateLocalSlSource,
|
||||||
writeLocalSlSource,
|
writeLocalSlSource,
|
||||||
|
|
@ -90,6 +91,101 @@ describe('local semantic-layer helpers', () => {
|
||||||
await expect(validateLocalSlSource(ORDERS_YAML)).resolves.toEqual({ valid: true, errors: [] });
|
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 () => {
|
it('validates table-backed sources against matching physical manifests when project context is provided', async () => {
|
||||||
await project.fileStore.writeFile(
|
await project.fileStore.writeFile(
|
||||||
'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml',
|
'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js';
|
import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js';
|
||||||
import {
|
import {
|
||||||
|
listLocalKnowledgePageKeys,
|
||||||
listLocalKnowledgePages,
|
listLocalKnowledgePages,
|
||||||
readLocalKnowledgePage,
|
readLocalKnowledgePage,
|
||||||
searchLocalKnowledgePages,
|
searchLocalKnowledgePages,
|
||||||
|
|
@ -102,6 +103,35 @@ describe('local knowledge helpers', () => {
|
||||||
await expect(access(join(project.projectDir, '.ktx', 'db.sqlite'))).resolves.toBeUndefined();
|
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 () => {
|
it('adds the token lane alongside lexical wiki matches', async () => {
|
||||||
await writeLocalKnowledgePage(project, {
|
await writeLocalKnowledgePage(project, {
|
||||||
key: 'metrics-revenue',
|
key: 'metrics-revenue',
|
||||||
|
|
|
||||||
|
|
@ -132,9 +132,12 @@ describe('runKtxCli', () => {
|
||||||
}
|
}
|
||||||
expect(testIo.stdout()).not.toMatch(/^ dev\s/m);
|
expect(testIo.stdout()).not.toMatch(/^ dev\s/m);
|
||||||
expect(testIo.stdout()).not.toMatch(/^ scan\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'));
|
expect(testIo.stdout()).not.toMatch(new RegExp(`^\\s+${removed}(?:\\s|\\[|$)`, 'm'));
|
||||||
}
|
}
|
||||||
|
// `completion` is a public command; the internal `__complete` helper is hidden.
|
||||||
|
expect(testIo.stdout()).toMatch(/^\s+completion <shell>/m);
|
||||||
|
expect(testIo.stdout()).not.toContain('__complete');
|
||||||
expect(testIo.stdout()).toContain('--project-dir <path>');
|
expect(testIo.stdout()).toContain('--project-dir <path>');
|
||||||
expect(testIo.stdout()).toContain('KTX_PROJECT_DIR');
|
expect(testIo.stdout()).toContain('KTX_PROJECT_DIR');
|
||||||
expect(testIo.stdout()).toContain('--debug');
|
expect(testIo.stdout()).toContain('--debug');
|
||||||
|
|
@ -414,12 +417,17 @@ describe('runKtxCli', () => {
|
||||||
|
|
||||||
const promptIo = makeIo();
|
const promptIo = makeIo();
|
||||||
await expect(
|
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);
|
).resolves.toBe(0);
|
||||||
expect(sl).toHaveBeenLastCalledWith(
|
expect(sl).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
command: 'query',
|
command: 'query',
|
||||||
projectDir: tempDir,
|
projectDir: tempDir,
|
||||||
|
connectionId: 'warehouse',
|
||||||
cliVersion,
|
cliVersion,
|
||||||
runtimeInstallPolicy: 'prompt',
|
runtimeInstallPolicy: 'prompt',
|
||||||
query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }),
|
query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }),
|
||||||
|
|
@ -429,9 +437,21 @@ describe('runKtxCli', () => {
|
||||||
|
|
||||||
const autoIo = makeIo();
|
const autoIo = makeIo();
|
||||||
await expect(
|
await expect(
|
||||||
runKtxCli(['--project-dir', tempDir, 'sl', 'query', '--measure', 'orders.order_count', '--yes'], autoIo.io, {
|
runKtxCli(
|
||||||
sl,
|
[
|
||||||
}),
|
'--project-dir',
|
||||||
|
tempDir,
|
||||||
|
'sl',
|
||||||
|
'query',
|
||||||
|
'--connection-id',
|
||||||
|
'warehouse',
|
||||||
|
'--measure',
|
||||||
|
'orders.order_count',
|
||||||
|
'--yes',
|
||||||
|
],
|
||||||
|
autoIo.io,
|
||||||
|
{ sl },
|
||||||
|
),
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
expect(sl).toHaveBeenLastCalledWith(
|
expect(sl).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -444,7 +464,17 @@ describe('runKtxCli', () => {
|
||||||
const noInputIo = makeIo();
|
const noInputIo = makeIo();
|
||||||
await expect(
|
await expect(
|
||||||
runKtxCli(
|
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,
|
noInputIo.io,
|
||||||
{ sl },
|
{ sl },
|
||||||
),
|
),
|
||||||
|
|
@ -464,7 +494,18 @@ describe('runKtxCli', () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runKtxCli(
|
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,
|
io.io,
|
||||||
{ sl },
|
{ sl },
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,46 @@ describe('runKtxKnowledge', () => {
|
||||||
expect(searchIo.stdout()).toContain('metrics-revenue');
|
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 () => {
|
it('emits debug telemetry for wiki search without query text', async () => {
|
||||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||||
vi.stubEnv('CI', '');
|
vi.stubEnv('CI', '');
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,14 @@ describe('renderKtxCommandTree', () => {
|
||||||
.filter((line) => /^ {2}[├└]── \S/.test(line))
|
.filter((line) => /^ {2}[├└]── \S/.test(line))
|
||||||
.map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]);
|
.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);
|
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('│ └── test [connectionId]');
|
||||||
expect(output).toContain('│ ├── status Show KTX MCP daemon status');
|
expect(output).toContain('│ ├── status Show KTX MCP daemon status');
|
||||||
expect(output).not.toContain('│ ├── add');
|
expect(output).not.toContain('│ ├── add');
|
||||||
|
|
@ -27,10 +31,14 @@ describe('renderKtxCommandTree', () => {
|
||||||
expect(output).not.toContain('scan <connectionId>');
|
expect(output).not.toContain('scan <connectionId>');
|
||||||
expect(output).not.toContain('│ ├── replay');
|
expect(output).not.toContain('│ ├── replay');
|
||||||
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('│ └── watch');
|
expect(output).not.toContain('│ └── watch');
|
||||||
expect(output).not.toContain('│ ├── read');
|
expect(output).toContain('│ └── read <key> Read a wiki page file by key');
|
||||||
|
expect(output).toContain(
|
||||||
|
'│ ├── read <sourceName> Read a semantic-layer source YAML file',
|
||||||
|
);
|
||||||
expect(output).not.toContain('│ ├── write');
|
expect(output).not.toContain('│ ├── write');
|
||||||
expect(output).not.toContain('│ └── write');
|
expect(output).not.toContain('│ └── write');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,267 @@ describe('runKtxSl', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads a semantic-layer source as raw YAML', async () => {
|
||||||
|
const projectDir = join(tempDir, 'read-project');
|
||||||
|
await seedSlSource({ projectDir });
|
||||||
|
|
||||||
|
const readIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir,
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
sourceName: 'orders',
|
||||||
|
},
|
||||||
|
readIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(0);
|
||||||
|
|
||||||
|
expect(readIo.stdout()).toBe(ORDERS_YAML);
|
||||||
|
expect(readIo.stderr()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a unique semantic-layer source without a connection id', async () => {
|
||||||
|
const projectDir = join(tempDir, 'read-unique-project');
|
||||||
|
const project = await initKtxProject({ projectDir });
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/warehouse/orders.yaml',
|
||||||
|
ORDERS_YAML,
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/analytics/tickets.yaml',
|
||||||
|
[
|
||||||
|
'name: tickets',
|
||||||
|
'table: public.tickets',
|
||||||
|
'grain:',
|
||||||
|
' - ticket_id',
|
||||||
|
'columns:',
|
||||||
|
' - name: ticket_id',
|
||||||
|
' type: string',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
|
||||||
|
const readIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir,
|
||||||
|
sourceName: 'tickets',
|
||||||
|
},
|
||||||
|
readIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(0);
|
||||||
|
|
||||||
|
expect(readIo.stdout()).toContain('name: tickets');
|
||||||
|
expect(readIo.stderr()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports ambiguous unscoped reads with sorted connection ids', async () => {
|
||||||
|
const projectDir = join(tempDir, 'read-ambiguous-project');
|
||||||
|
const project = await initKtxProject({ projectDir });
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/warehouse/orders.yaml',
|
||||||
|
ORDERS_YAML,
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/analytics/orders.yaml',
|
||||||
|
ORDERS_YAML,
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
|
||||||
|
const readIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir,
|
||||||
|
sourceName: 'orders',
|
||||||
|
},
|
||||||
|
readIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(readIo.stdout()).toBe('');
|
||||||
|
expect(readIo.stderr()).toBe(
|
||||||
|
"Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id <id>.\n",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a clear error when an unscoped semantic-layer source is missing', async () => {
|
||||||
|
const projectDir = join(tempDir, 'missing-unscoped-read-project');
|
||||||
|
await seedSlSource({ projectDir });
|
||||||
|
|
||||||
|
const readIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir,
|
||||||
|
sourceName: 'missing_orders',
|
||||||
|
},
|
||||||
|
readIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(readIo.stdout()).toBe('');
|
||||||
|
expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders'\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a clear error when a semantic-layer source is missing', async () => {
|
||||||
|
const projectDir = join(tempDir, 'missing-read-project');
|
||||||
|
await seedSlSource({ projectDir });
|
||||||
|
|
||||||
|
const readIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'read',
|
||||||
|
projectDir,
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
sourceName: 'missing_orders',
|
||||||
|
},
|
||||||
|
readIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(readIo.stdout()).toBe('');
|
||||||
|
expect(readIo.stderr()).toBe("No semantic-layer source 'missing_orders' for connection 'warehouse'\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates a unique semantic-layer source without a connection id', async () => {
|
||||||
|
const projectDir = join(tempDir, 'validate-unique-project');
|
||||||
|
const project = await initKtxProject({ projectDir });
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/warehouse/orders.yaml',
|
||||||
|
ORDERS_YAML,
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/analytics/tickets.yaml',
|
||||||
|
[
|
||||||
|
'name: tickets',
|
||||||
|
'table: public.tickets',
|
||||||
|
'grain:',
|
||||||
|
' - ticket_id',
|
||||||
|
'columns:',
|
||||||
|
' - name: ticket_id',
|
||||||
|
' type: string',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'validate',
|
||||||
|
projectDir,
|
||||||
|
sourceName: 'tickets',
|
||||||
|
},
|
||||||
|
validateIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(0);
|
||||||
|
|
||||||
|
expect(validateIo.stdout()).toBe('Valid semantic-layer source: analytics/tickets\n');
|
||||||
|
expect(validateIo.stderr()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports ambiguous unscoped validation with sorted connection ids', async () => {
|
||||||
|
const projectDir = join(tempDir, 'validate-ambiguous-project');
|
||||||
|
const project = await initKtxProject({ projectDir });
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/warehouse/orders.yaml',
|
||||||
|
ORDERS_YAML,
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
await project.fileStore.writeFile(
|
||||||
|
'semantic-layer/analytics/orders.yaml',
|
||||||
|
ORDERS_YAML,
|
||||||
|
'ktx',
|
||||||
|
'ktx@example.com',
|
||||||
|
'Add semantic-layer source',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'validate',
|
||||||
|
projectDir,
|
||||||
|
sourceName: 'orders',
|
||||||
|
},
|
||||||
|
validateIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(validateIo.stdout()).toBe('');
|
||||||
|
expect(validateIo.stderr()).toBe(
|
||||||
|
"Source 'orders' exists in multiple connections: analytics, warehouse. Re-run with --connection-id <id>.\n",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a clear error when an unscoped semantic-layer source validation target is missing', async () => {
|
||||||
|
const projectDir = join(tempDir, 'missing-unscoped-validate-project');
|
||||||
|
await seedSlSource({ projectDir });
|
||||||
|
|
||||||
|
const validateIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'validate',
|
||||||
|
projectDir,
|
||||||
|
sourceName: 'missing_orders',
|
||||||
|
},
|
||||||
|
validateIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(validateIo.stdout()).toBe('');
|
||||||
|
expect(validateIo.stderr()).toBe('Semantic-layer source "missing_orders" was not found\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps scoped validation not-found wording', async () => {
|
||||||
|
const projectDir = join(tempDir, 'missing-scoped-validate-project');
|
||||||
|
await seedSlSource({ projectDir });
|
||||||
|
|
||||||
|
const validateIo = makeIo();
|
||||||
|
await expect(
|
||||||
|
runKtxSl(
|
||||||
|
{
|
||||||
|
command: 'validate',
|
||||||
|
projectDir,
|
||||||
|
connectionId: 'warehouse',
|
||||||
|
sourceName: 'missing_orders',
|
||||||
|
},
|
||||||
|
validateIo.io,
|
||||||
|
),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(validateIo.stdout()).toBe('');
|
||||||
|
expect(validateIo.stderr()).toBe('Semantic-layer source "warehouse/missing_orders" was not found\n');
|
||||||
|
});
|
||||||
|
|
||||||
it('prints semantic-layer search rank badges in pretty output', async () => {
|
it('prints semantic-layer search rank badges in pretty output', async () => {
|
||||||
const projectDir = join(tempDir, 'rank-project');
|
const projectDir = join(tempDir, 'rank-project');
|
||||||
await seedSlSource({ projectDir });
|
await seedSlSource({ projectDir });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue