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

* feat(completion): complete known argument values

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

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

* test: cover wiki and sl read command routing

* test: cover raw wiki and sl reads

* feat: add wiki read command

* feat: add sl read command

* feat: complete read command entity names

* docs: document wiki and sl read commands

* test: include read commands in command tree

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

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

* fix(completion): dedupe semantic source names

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

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

* docs(sl): clarify query connection requirement

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

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

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

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

View file

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

View file

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

View file

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