mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
* 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.
172 lines
7 KiB
TypeScript
172 lines
7 KiB
TypeScript
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
|
import {
|
|
collectOption,
|
|
type KtxCliCommandContext,
|
|
parsePositiveIntegerOption,
|
|
resolveCommandProjectDir,
|
|
} from '../cli-program.js';
|
|
import { slQueryCommandSchema } from '../command-schemas.js';
|
|
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
|
import type { KtxSlArgs } from '../sl.js';
|
|
import { profileMark } from '../startup-profile.js';
|
|
|
|
profileMark('module:commands/sl-commands');
|
|
|
|
function parseOrderBy(value: string): string | { field: string; direction?: string } {
|
|
const [field, direction] = value.split(':');
|
|
if (!field) {
|
|
throw new InvalidArgumentError('requires a field');
|
|
}
|
|
if (!direction) {
|
|
return field;
|
|
}
|
|
if (direction !== 'asc' && direction !== 'desc') {
|
|
throw new InvalidArgumentError('direction must be asc or desc');
|
|
}
|
|
return { field, direction };
|
|
}
|
|
|
|
function collectOrderBy(
|
|
value: string,
|
|
previous: Array<string | { field: string; direction?: string }> = [],
|
|
): Array<string | { field: string; direction?: string }> {
|
|
return [...previous, parseOrderBy(value)];
|
|
}
|
|
|
|
async function runSlArgs(context: KtxCliCommandContext, args: KtxSlArgs): Promise<void> {
|
|
const runner = context.deps.sl ?? (await import('../sl.js')).runKtxSl;
|
|
context.setExitCode(await runner(args, context.io));
|
|
}
|
|
|
|
export function registerSlCommands(program: Command, context: KtxCliCommandContext, commandName = 'sl'): void {
|
|
const sl = program
|
|
.command(commandName)
|
|
.description('List, search, validate, or query local semantic-layer sources')
|
|
.usage('[options] [query...]')
|
|
.argument('[query...]', 'Search query; omit to list all sources')
|
|
.option('--connection-id <id>', 'KTX connection id')
|
|
.option('--limit <number>', 'Maximum search results (search mode only)', parsePositiveIntegerOption)
|
|
.addOption(
|
|
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
|
'pretty',
|
|
'plain',
|
|
'json',
|
|
]),
|
|
)
|
|
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
|
.showHelpAfterError()
|
|
.addHelpText(
|
|
'after',
|
|
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.\n',
|
|
)
|
|
.action(
|
|
async (
|
|
query: string[],
|
|
options: {
|
|
connectionId?: string;
|
|
limit?: number;
|
|
output?: 'pretty' | 'plain' | 'json';
|
|
json?: boolean;
|
|
},
|
|
command,
|
|
) => {
|
|
if (query.length === 0) {
|
|
await runSlArgs(context, {
|
|
command: 'list',
|
|
projectDir: resolveCommandProjectDir(command),
|
|
connectionId: options.connectionId,
|
|
output: options.output,
|
|
json: options.json,
|
|
cliVersion: context.packageInfo.version,
|
|
});
|
|
return;
|
|
}
|
|
await runSlArgs(context, {
|
|
command: 'search',
|
|
projectDir: resolveCommandProjectDir(command),
|
|
connectionId: options.connectionId,
|
|
query: query.join(' '),
|
|
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
output: options.output,
|
|
json: options.json,
|
|
cliVersion: context.packageInfo.version,
|
|
});
|
|
},
|
|
);
|
|
|
|
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;
|
|
await runSlArgs(context, {
|
|
command: 'validate',
|
|
projectDir: resolveCommandProjectDir(command),
|
|
connectionId: parentOpts?.connectionId,
|
|
sourceName,
|
|
});
|
|
});
|
|
|
|
sl.command('query')
|
|
.description('Compile or execute a semantic-layer query (set --connection-id on `ktx sl`)')
|
|
.option('--query-file <path>', 'JSON semantic-layer query file')
|
|
.option('--measure <measure>', 'Measure to query; repeatable', collectOption, [])
|
|
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
|
|
.option('--filter <filter>', 'Filter expression; repeatable', collectOption, [])
|
|
.option('--segment <segment>', 'Segment to include; repeatable', collectOption, [])
|
|
.option('--order-by <field[:direction]>', 'Order field, optionally suffixed with :asc or :desc', collectOrderBy, [])
|
|
.option('--limit <n>', 'Query limit', parsePositiveIntegerOption)
|
|
.option('--include-empty', 'Include empty rows', false)
|
|
.addOption(new Option('--format <format>', 'json or sql').choices(['json', 'sql']).default('json'))
|
|
.option('--execute', 'Execute the compiled query', false)
|
|
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
|
|
.option('--no-input', 'Disable interactive managed runtime installation')
|
|
.option('--max-rows <n>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
|
.action(async (options, command) => {
|
|
if (options.measure.length === 0 && !options.queryFile) {
|
|
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,
|
|
...(options.queryFile
|
|
? { queryFile: options.queryFile }
|
|
: {
|
|
query: {
|
|
measures: options.measure,
|
|
dimensions: options.dimension,
|
|
...(options.filter.length > 0 ? { filters: options.filter } : {}),
|
|
...(options.segment.length > 0 ? { segments: options.segment } : {}),
|
|
...(options.orderBy.length > 0 ? { order_by: options.orderBy } : {}),
|
|
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
...(options.includeEmpty === true ? { include_empty: true } : {}),
|
|
},
|
|
}),
|
|
format: options.format,
|
|
execute: options.execute === true,
|
|
cliVersion: context.packageInfo.version,
|
|
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
|
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
|
|
});
|
|
await runSlArgs(context, args);
|
|
});
|
|
}
|