mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +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
|
|
@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|||
import { join } from 'node:path';
|
||||
import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
||||
import { registerCompletionCommands } from './commands/completion-commands.js';
|
||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
|
|
@ -431,6 +432,11 @@ export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record<
|
|||
export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
||||
const program = createBaseProgram(options.packageInfo, options.io);
|
||||
program.hook('preAction', async (_thisCommand, actionCommand) => {
|
||||
// The hidden completion command must stay silent and side-effect free: skip
|
||||
// the telemetry notice, command span, and project checks entirely.
|
||||
if (commandPath(actionCommand as CommandPathNode).includes('__complete')) {
|
||||
return;
|
||||
}
|
||||
const telemetry = await import('./telemetry/index.js');
|
||||
options.setTelemetryModule?.(telemetry);
|
||||
await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo);
|
||||
|
|
@ -476,6 +482,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
|||
registerStatusCommands(program, context);
|
||||
registerMcpCommands(program, context);
|
||||
registerAdminCommands(program, context);
|
||||
registerCompletionCommands(program, context);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ export function walkCommandTree(command: CommandUnknownOpts): CommandTreeNode {
|
|||
description: command.description(),
|
||||
aliases: command.aliases(),
|
||||
arguments: command.registeredArguments.map(formatArgumentDeclaration),
|
||||
children: command.commands.map((child) => walkCommandTree(child)),
|
||||
// Internal commands (e.g. the shell-completion helper `__complete`) use a
|
||||
// `__` prefix and are omitted from the human-facing command tree.
|
||||
children: command.commands
|
||||
.filter((child) => !child.name().startsWith('__'))
|
||||
.map((child) => walkCommandTree(child)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
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 {
|
||||
program
|
||||
const wiki = program
|
||||
.command('wiki')
|
||||
.description('List or search local wiki pages')
|
||||
.description('List, search, or read local wiki pages')
|
||||
.usage('[options] [query...]')
|
||||
.argument('[query...]', 'Search query; omit to list all pages')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
|
|
@ -76,4 +76,18 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
wiki
|
||||
.command('read')
|
||||
.description('Read a wiki page file by key')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.action(async (key: string, _options, command) => {
|
||||
const parentOpts = command.parent?.opts() as { userId?: string } | undefined;
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
userId: parentOpts?.userId ?? 'local',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,19 +94,28 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
},
|
||||
);
|
||||
|
||||
sl.command('validate')
|
||||
.description('Validate a semantic-layer source (set --connection-id on `ktx sl`)')
|
||||
sl.command('read')
|
||||
.description('Read a semantic-layer source YAML file')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.action(async (sourceName: string, _options, command) => {
|
||||
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||
await runSlArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: parentOpts?.connectionId,
|
||||
sourceName,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('validate')
|
||||
.description('Validate a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.action(async (sourceName: string, _options, command) => {
|
||||
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||
const connectionId = parentOpts?.connectionId;
|
||||
if (connectionId === undefined) {
|
||||
command.error("error: required option '--connection-id <id>' not specified");
|
||||
}
|
||||
await runSlArgs(context, {
|
||||
command: 'validate',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: connectionId as string,
|
||||
connectionId: parentOpts?.connectionId,
|
||||
sourceName,
|
||||
});
|
||||
});
|
||||
|
|
@ -131,10 +140,14 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
throw new Error('sl query requires at least one --measure');
|
||||
}
|
||||
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||
const connectionId = parentOpts?.connectionId;
|
||||
if (connectionId === undefined) {
|
||||
command.error("error: required option '--connection-id <id>' not specified");
|
||||
}
|
||||
const args = slQueryCommandSchema.parse({
|
||||
command: 'query',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: parentOpts?.connectionId,
|
||||
connectionId,
|
||||
...(options.queryFile
|
||||
? { queryFile: options.queryFile }
|
||||
: {
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface LocalSlSource extends LocalSlSourceSummary {
|
||||
yaml: string;
|
||||
}
|
||||
|
|
@ -63,6 +64,11 @@ export interface LocalSlValidationResult {
|
|||
errors: string[];
|
||||
}
|
||||
|
||||
export type ResolvedSlSource =
|
||||
| { kind: 'found'; source: LocalSlSource }
|
||||
| { kind: 'not-found' }
|
||||
| { kind: 'ambiguous'; connectionIds: string[] };
|
||||
|
||||
const LOCAL_AUTHOR = 'ktx';
|
||||
const LOCAL_AUTHOR_EMAIL = 'ktx@example.com';
|
||||
|
||||
|
|
@ -311,6 +317,7 @@ export async function writeLocalSlSource(
|
|||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function readLocalSlSource(
|
||||
project: KtxLocalProject,
|
||||
input: { connectionId: string; sourceName: string },
|
||||
|
|
@ -331,6 +338,41 @@ export async function readLocalSlSource(
|
|||
}
|
||||
}
|
||||
|
||||
export async function resolveLocalSlSource(
|
||||
project: KtxLocalProject,
|
||||
input: { sourceName: string; connectionId?: string },
|
||||
): Promise<ResolvedSlSource> {
|
||||
if (input.connectionId !== undefined) {
|
||||
const source = await readLocalSlSource(project, {
|
||||
connectionId: input.connectionId,
|
||||
sourceName: input.sourceName,
|
||||
});
|
||||
return source ? { kind: 'found', source } : { kind: 'not-found' };
|
||||
}
|
||||
|
||||
const summaries = await listLocalSlSources(project, {});
|
||||
const matches = summaries.filter((summary) => summary.name === input.sourceName);
|
||||
if (matches.length === 0) {
|
||||
return { kind: 'not-found' };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return {
|
||||
kind: 'ambiguous',
|
||||
connectionIds: [...new Set(matches.map((match) => match.connectionId))].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
const match = matches[0];
|
||||
if (match === undefined) {
|
||||
return { kind: 'not-found' };
|
||||
}
|
||||
const source = await readLocalSlSource(project, {
|
||||
connectionId: match.connectionId,
|
||||
sourceName: input.sourceName,
|
||||
});
|
||||
return source ? { kind: 'found', source } : { kind: 'not-found' };
|
||||
}
|
||||
|
||||
export async function listLocalSlSources(
|
||||
project: KtxLocalProject,
|
||||
input: { connectionId?: string } = {},
|
||||
|
|
|
|||
|
|
@ -201,6 +201,32 @@ export async function listLocalKnowledgePages(
|
|||
return pages.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
/**
|
||||
* List wiki page keys without reading or parsing file contents.
|
||||
*
|
||||
* Keys are derived purely from file paths, so this stays cheap enough for
|
||||
* shell tab-completion (unlike `listLocalKnowledgePages`, which reads every
|
||||
* page to populate summaries).
|
||||
*/
|
||||
export async function listLocalKnowledgePageKeys(
|
||||
project: KtxLocalProject,
|
||||
input: { userId?: string } = {},
|
||||
): Promise<string[]> {
|
||||
const userId = input.userId ?? 'local';
|
||||
const keys = new Set<string>();
|
||||
for (const scope of ['GLOBAL', 'USER'] as const) {
|
||||
const root = scope === 'GLOBAL' ? 'wiki/global' : `wiki/user/${assertSafePathToken('user id', userId)}`;
|
||||
const listed = await project.fileStore.listFiles(root);
|
||||
for (const path of listed.files.filter((file) => file.endsWith('.md'))) {
|
||||
const key = keyFromKnowledgePath(path, scope, userId);
|
||||
if (key) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
function scorePage(page: LocalKnowledgePage, terms: string[]): number {
|
||||
const haystack = buildKnowledgeSearchText(page.key, page.summary, page.content, page.tags).toLowerCase();
|
||||
return terms.some((term) => haystack.includes(term)) ? 3 : 0;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { KtxIngestEmbeddingPortAdapter } from './context/llm/embedding-port.js';
|
||||
import type { KtxEmbeddingPort } from './context/core/embedding.js';
|
||||
import { loadKtxProject } from './context/project/project.js';
|
||||
import { type LocalKnowledgeSearchResult, type LocalKnowledgeSummary, listLocalKnowledgePages, searchLocalKnowledgePages as defaultSearchLocalKnowledgePages } from './context/wiki/local-knowledge.js';
|
||||
import {
|
||||
type LocalKnowledgeSearchResult,
|
||||
type LocalKnowledgeSummary,
|
||||
listLocalKnowledgePages,
|
||||
readLocalKnowledgePage,
|
||||
searchLocalKnowledgePages as defaultSearchLocalKnowledgePages,
|
||||
} from './context/wiki/local-knowledge.js';
|
||||
import {
|
||||
resolveProjectEmbeddingProvider,
|
||||
type EmbeddingProviderResolution,
|
||||
|
|
@ -22,7 +28,8 @@ export type KtxKnowledgeArgs =
|
|||
limit?: number;
|
||||
debug?: boolean;
|
||||
cliVersion: string;
|
||||
};
|
||||
}
|
||||
| { command: 'read'; projectDir: string; key: string; userId: string };
|
||||
|
||||
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
|
||||
|
||||
|
|
@ -128,6 +135,15 @@ export async function runKtxKnowledge(
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'read') {
|
||||
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
|
||||
if (!page) {
|
||||
throw new Error(`No wiki page found for key '${args.key}'`);
|
||||
}
|
||||
const raw = await project.fileStore.readFile(page.path);
|
||||
io.stdout.write(raw.content);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'search') {
|
||||
const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io);
|
||||
const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ import type { KtxEmbeddingPort } from './context/core/embedding.js';
|
|||
import type { KtxSemanticLayerComputePort } from './context/daemon/semantic-layer-compute.js';
|
||||
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
|
||||
import { compileLocalSlQuery } from './context/sl/local-query.js';
|
||||
import { listLocalSlSources, readLocalSlSource, searchLocalSlSources as defaultSearchLocalSlSources, validateLocalSlSource, type LocalSlSourceSearchResult, type LocalSlSourceSummary } from './context/sl/local-sl.js';
|
||||
import {
|
||||
listLocalSlSources,
|
||||
resolveLocalSlSource,
|
||||
searchLocalSlSources as defaultSearchLocalSlSources,
|
||||
validateLocalSlSource,
|
||||
type LocalSlSourceSearchResult,
|
||||
type LocalSlSourceSummary,
|
||||
} from './context/sl/local-sl.js';
|
||||
import type { SemanticLayerQueryInput } from './context/sl/types.js';
|
||||
import {
|
||||
resolveProjectEmbeddingProvider,
|
||||
|
|
@ -45,7 +52,8 @@ export type KtxSlArgs =
|
|||
json?: boolean;
|
||||
cliVersion: string;
|
||||
}
|
||||
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
|
||||
| { command: 'read'; projectDir: string; connectionId?: string; sourceName: string }
|
||||
| { command: 'validate'; projectDir: string; connectionId?: string; sourceName: string }
|
||||
| {
|
||||
command: 'query';
|
||||
projectDir: string;
|
||||
|
|
@ -185,6 +193,12 @@ async function readSlQueryFile(path: string): Promise<SemanticLayerQueryInput> {
|
|||
return parsed as SemanticLayerQueryInput;
|
||||
}
|
||||
|
||||
function ambiguousSourceMessage(sourceName: string, connectionIds: readonly string[]): string {
|
||||
return `Source '${sourceName}' exists in multiple connections: ${connectionIds.join(
|
||||
', ',
|
||||
)}. Re-run with --connection-id <id>.`;
|
||||
}
|
||||
|
||||
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
|
||||
const startedAt = performance.now();
|
||||
let queryForTelemetry: SemanticLayerQueryInput | undefined;
|
||||
|
|
@ -232,25 +246,50 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'validate') {
|
||||
const source = await readLocalSlSource(project, {
|
||||
if (args.command === 'read') {
|
||||
const resolved = await resolveLocalSlSource(project, {
|
||||
connectionId: args.connectionId,
|
||||
sourceName: args.sourceName,
|
||||
});
|
||||
if (!source) {
|
||||
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
|
||||
if (resolved.kind === 'not-found') {
|
||||
throw new Error(
|
||||
args.connectionId !== undefined
|
||||
? `No semantic-layer source '${args.sourceName}' for connection '${args.connectionId}'`
|
||||
: `No semantic-layer source '${args.sourceName}'`,
|
||||
);
|
||||
}
|
||||
const result = await validateLocalSlSource(source.yaml, {
|
||||
project,
|
||||
if (resolved.kind === 'ambiguous') {
|
||||
throw new Error(ambiguousSourceMessage(args.sourceName, resolved.connectionIds));
|
||||
}
|
||||
io.stdout.write(resolved.source.yaml);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'validate') {
|
||||
const resolved = await resolveLocalSlSource(project, {
|
||||
connectionId: args.connectionId,
|
||||
sourceName: args.sourceName,
|
||||
});
|
||||
if (resolved.kind === 'not-found') {
|
||||
throw new Error(
|
||||
args.connectionId !== undefined
|
||||
? `Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`
|
||||
: `Semantic-layer source "${args.sourceName}" was not found`,
|
||||
);
|
||||
}
|
||||
if (resolved.kind === 'ambiguous') {
|
||||
throw new Error(ambiguousSourceMessage(args.sourceName, resolved.connectionIds));
|
||||
}
|
||||
const result = await validateLocalSlSource(resolved.source.yaml, {
|
||||
project,
|
||||
connectionId: resolved.source.connectionId,
|
||||
sourceName: args.sourceName,
|
||||
});
|
||||
await emitTelemetryEvent({
|
||||
name: 'sl_validate_completed',
|
||||
projectDir: args.projectDir,
|
||||
io,
|
||||
fields: {
|
||||
sourceCount: source ? 1 : 0,
|
||||
sourceCount: 1,
|
||||
modelCount: 0,
|
||||
validationErrorCount: result.valid ? 0 : result.errors.length,
|
||||
outcome: result.valid ? 'ok' : 'error',
|
||||
|
|
@ -263,7 +302,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
}
|
||||
return 1;
|
||||
}
|
||||
io.stdout.write(`Valid semantic-layer source: ${args.connectionId}/${args.sourceName}\n`);
|
||||
io.stdout.write(`Valid semantic-layer source: ${resolved.source.connectionId}/${args.sourceName}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'query') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue