mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +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.
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
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']);
|
|
});
|
|
});
|