feat: Phase 2 caller migration — gsd-sdk query in workflows, agents, commands (#2179)
* feat: Phase 2 caller migration — gsd-sdk query in workflows (#2122) Cherry-picked orchestration rewrites from feat/sdk-foundation (#2008, 4018fee) onto current main, resolving conflicts to keep upstream worktree guards and post-merge test gate. SDK stub registry omitted (out of Phase 2 scope per #2122). Refs: #2122 #2008 Made-with: Cursor * docs: add gsd-sdk query migration blurb Made-with: Cursor * docs(workflows): extend Phase 2 gsd-sdk query caller migration - Swap node gsd-tools.cjs for gsd-sdk query in review, plan-phase, execute-plan, ship, extract_learnings, ai-integration-phase, eval-review, next, thread - Document graphify CJS-only in gsd-planner; dual-path in CLI-TOOLS and ARCHITECTURE - Update tests: workstreams gsd-sdk path, thread frontmatter.get, workspace init.*, CRLF-safe autonomous frontmatter parse - CHANGELOG: Phase 2 caller migration scope Made-with: Cursor * docs(phase2): USER-GUIDE + remaining gsd-sdk query call sites - USER-GUIDE: dual-path CLI section; state validate/sync use full CJS path - Commands: debug (config-get+tdd), quick (security note), intel Task prompt - Agent: gsd-debug-session-manager resolve-model via jq - Workflows: milestone-summary, forensics, next, complete-milestone/verify-work (audit-open CJS notes), discuss-phase, progress, verify-phase, add/insert/remove phase, transition, manager, quick workflow; remove-phase commit without --files - Test: quick-session-management accepts frontmatter.get - CHANGELOG: Phase 2 follow-up bullet Made-with: Cursor * docs(phase2): align gsd-sdk query examples in commands and agents - init.* query names; frontmatter.get uses positional field name - state.* handlers use positional args; commit uses positional paths - CJS-only notes for from-gsd2 and graphify; learnings.query wording - CHANGELOG: Phase 2 orchestration doc pass Made-with: Cursor * docs(phase2): normalize gsd-sdk query commit to positional file paths - Strip --files from commit examples in workflows, references, commands - Keep commit-to-subrepo ... --files (separate handler) - git-planning-commit.md: document positional args - Tests: new-project commit line, state.record-session, gates CRLF, roadmap.analyze - CHANGELOG [Unreleased] Made-with: Cursor * feat(sdk): gsd-sdk query parity with gsd-tools and PR 2179 registry fixes - Route query via longest-prefix match and dotted single-token expansion; fall back to runGsdToolsQuery (same argv as node gsd-tools.cjs) for full CLI coverage. - Parse gsd-sdk query permissively so gsd-tools flags (--json, --verify, etc.) are not rejected by strict parseArgs. - resolveGsdToolsPath: honor GSD_TOOLS_PATH; prefer bundled get-shit-done copy over project .claude installs; export runGsdToolsQuery from the SDK. - Fix gsd-tools audit-open (core.output; pass object for --json JSON). - Register summary-extract as alias of summary.extract; fix audit-fix workflow to call audit-uat instead of invalid init.audit-uat (PR review). Updates QUERY-HANDLERS.md and CHANGELOG [Unreleased]. Made-with: Cursor * fix(sdk): Phase 2 scope — Trek-e review (#2179, #2122) - Remove gsd-sdk query passthrough to gsd-tools.cjs; drop GSD_TOOLS_PATH - Consolidate argv routing in resolveQueryArgv(); update USAGE and QUERY-HANDLERS - Surface @file: read failures in GSDTools.parseOutput - execute-plan: defer Task Commit Protocol to gsd-executor - stale-colon-refs: skip .planning/ and root CLAUDE.md (gitignored overlays) - CHANGELOG [Unreleased]: maintainer review and routing notes Made-with: Cursor
This commit is contained in:
@@ -108,12 +108,22 @@ describe('parseCliArgs', () => {
|
||||
expect(() => parseCliArgs(['run', 'hello', '--not-a-real-option'])).toThrow();
|
||||
});
|
||||
|
||||
it('parses query with --pick stripped before strict parse', () => {
|
||||
it('parses query permissively (keeps gsd-tools flags like --pick, --json)', () => {
|
||||
const result = parseCliArgs([
|
||||
'query', 'state.load', '--pick', 'data', '--project-dir', 'C:\\tmp\\proj',
|
||||
]);
|
||||
expect(result.command).toBe('query');
|
||||
expect(result.projectDir).toBe('C:\\tmp\\proj');
|
||||
expect(result.queryArgv).toEqual(['state.load', '--pick', 'data']);
|
||||
});
|
||||
|
||||
it('parses query with extra flags forwarded in queryArgv', () => {
|
||||
const result = parseCliArgs([
|
||||
'query', 'audit-open', '--json', '--project-dir', 'D:\\proj',
|
||||
]);
|
||||
expect(result.command).toBe('query');
|
||||
expect(result.projectDir).toBe('D:\\proj');
|
||||
expect(result.queryArgv).toEqual(['audit-open', '--json']);
|
||||
});
|
||||
|
||||
// ─── Init command parsing ──────────────────────────────────────────────
|
||||
|
||||
115
sdk/src/cli.ts
115
sdk/src/cli.ts
@@ -34,20 +34,82 @@ export interface ParsedCliArgs {
|
||||
ws: string | undefined;
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
/**
|
||||
* When `command === 'query'`, tokens after `query` with only known SDK flags removed.
|
||||
* Extra flags are kept so handlers that share gsd-tools-style argv (e.g. `--pick`) still receive them.
|
||||
*/
|
||||
queryArgv?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `--pick <field>` from argv before parseArgs so the global parser stays strict.
|
||||
* Query dispatch removes --pick separately in main(); this only affects CLI parsing.
|
||||
* Parse `gsd-sdk query …` without rejecting unknown flags (query argv is forwarded to the registry).
|
||||
*/
|
||||
function argvForCliParse(argv: string[]): string[] {
|
||||
if (argv[0] !== 'query') return argv;
|
||||
const copy = [...argv];
|
||||
const pickIdx = copy.indexOf('--pick');
|
||||
if (pickIdx !== -1 && pickIdx + 1 < copy.length) {
|
||||
copy.splice(pickIdx, 2);
|
||||
function parseCliArgsQueryPermissive(argv: string[]): ParsedCliArgs {
|
||||
let projectDir = process.cwd();
|
||||
let ws: string | undefined;
|
||||
let wsPort: number | undefined;
|
||||
let model: string | undefined;
|
||||
let maxBudget: number | undefined;
|
||||
let help = false;
|
||||
let version = false;
|
||||
const queryArgv: string[] = [];
|
||||
|
||||
let i = 1;
|
||||
while (i < argv.length) {
|
||||
const a = argv[i];
|
||||
if (a === '--project-dir' && argv[i + 1]) {
|
||||
projectDir = argv[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (a === '--ws' && argv[i + 1]) {
|
||||
ws = argv[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (a === '--ws-port' && argv[i + 1]) {
|
||||
wsPort = Number(argv[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (a === '--model' && argv[i + 1]) {
|
||||
model = argv[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (a === '--max-budget' && argv[i + 1]) {
|
||||
maxBudget = Number(argv[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (a === '-h' || a === '--help') {
|
||||
help = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a === '-v' || a === '--version') {
|
||||
version = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
queryArgv.push(a);
|
||||
i += 1;
|
||||
}
|
||||
return copy;
|
||||
|
||||
return {
|
||||
command: 'query',
|
||||
prompt: undefined,
|
||||
initInput: undefined,
|
||||
init: undefined,
|
||||
projectDir,
|
||||
wsPort,
|
||||
model,
|
||||
maxBudget,
|
||||
ws,
|
||||
help,
|
||||
version,
|
||||
queryArgv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +117,12 @@ function argvForCliParse(argv: string[]): string[] {
|
||||
* Exported for testing — the main() function uses this internally.
|
||||
*/
|
||||
export function parseCliArgs(argv: string[]): ParsedCliArgs {
|
||||
if (argv[0] === 'query') {
|
||||
return parseCliArgsQueryPermissive(argv);
|
||||
}
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
args: argvForCliParse(argv),
|
||||
args: argv,
|
||||
options: {
|
||||
'project-dir': { type: 'string', default: process.cwd() },
|
||||
'ws-port': { type: 'string' },
|
||||
@@ -106,8 +172,8 @@ Commands:
|
||||
@path/to/prd.md Read input from a file
|
||||
"description" Use text directly
|
||||
(empty) Read from stdin
|
||||
query <command> Execute a registered native query command (registry: sdk/src/query/index.ts)
|
||||
Use --pick <field> to extract a specific field
|
||||
query <argv...> Registered query handlers only (longest-prefix argv match; see QUERY-HANDLERS.md)
|
||||
Use --pick <field> to extract a specific field from JSON output
|
||||
|
||||
Options:
|
||||
--init <input> Bootstrap from a PRD before running (auto only)
|
||||
@@ -226,13 +292,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
// ─── Query command ──────────────────────────────────────────────────────
|
||||
if (args.command === 'query') {
|
||||
const { createRegistry } = await import('./query/index.js');
|
||||
const { extractField } = await import('./query/registry.js');
|
||||
const { GSDError, exitCodeFor } = await import('./errors.js');
|
||||
const { extractField, resolveQueryArgv } = await import('./query/registry.js');
|
||||
const { GSDToolsError } = await import('./gsd-tools.js');
|
||||
const { GSDError, exitCodeFor, ErrorClassification } = await import('./errors.js');
|
||||
|
||||
const queryArgs = argv.slice(1); // everything after 'query'
|
||||
const queryCommand = queryArgs[0];
|
||||
const queryArgs = args.queryArgv ?? [];
|
||||
|
||||
if (!queryCommand) {
|
||||
if (queryArgs.length === 0 || !queryArgs[0]) {
|
||||
console.error('Error: "gsd-sdk query" requires a command');
|
||||
process.exitCode = 10;
|
||||
return;
|
||||
@@ -253,7 +319,17 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
|
||||
try {
|
||||
const registry = createRegistry();
|
||||
const result = await registry.dispatch(queryCommand, queryArgs.slice(1), args.projectDir);
|
||||
const tokens = [...queryArgs];
|
||||
const matched = resolveQueryArgv(tokens, registry);
|
||||
if (!matched) {
|
||||
throw new GSDError(
|
||||
`Unknown command: "${tokens.join(' ')}". Use a registered \`gsd-sdk query\` subcommand (see sdk/src/query/QUERY-HANDLERS.md) or invoke \`node …/gsd-tools.cjs\` for CJS-only operations.`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await registry.dispatch(matched.cmd, matched.args, args.projectDir);
|
||||
|
||||
let output: unknown = result.data;
|
||||
|
||||
if (pickField) {
|
||||
@@ -265,6 +341,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
if (err instanceof GSDError) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exitCode = exitCodeFor(err.classification);
|
||||
} else if (err instanceof GSDToolsError) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exitCode = err.exitCode ?? 1;
|
||||
} else {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -327,13 +327,17 @@ describe('GSDTools', () => {
|
||||
// ─── resolveGsdToolsPath() tests ────────────────────────────────────────
|
||||
|
||||
describe('resolveGsdToolsPath()', () => {
|
||||
it('returns repo-local path when it exists', async () => {
|
||||
it('prefers bundled gsd-tools over project .claude when the bundled file exists', async () => {
|
||||
const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin');
|
||||
await mkdir(localBinDir, { recursive: true });
|
||||
await writeFile(join(localBinDir, 'gsd-tools.cjs'), '// stub');
|
||||
|
||||
const result = resolveGsdToolsPath(tmpDir);
|
||||
expect(result).toBe(join(localBinDir, 'gsd-tools.cjs'));
|
||||
if (existsSync(BUNDLED_GSD_TOOLS_PATH)) {
|
||||
expect(result).toBe(BUNDLED_GSD_TOOLS_PATH);
|
||||
} else {
|
||||
expect(result).toBe(join(localBinDir, 'gsd-tools.cjs'));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to bundled repo path when repo-local does not exist', () => {
|
||||
@@ -345,7 +349,7 @@ describe('GSDTools', () => {
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('constructor uses repo-local path when available', async () => {
|
||||
it('uses explicit gsdToolsPath when provided (overrides bundled / .claude resolution)', async () => {
|
||||
const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin');
|
||||
await mkdir(localBinDir, { recursive: true });
|
||||
const scriptPath = join(localBinDir, 'gsd-tools.cjs');
|
||||
@@ -355,8 +359,7 @@ describe('GSDTools', () => {
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
// No explicit gsdToolsPath — should auto-resolve to local
|
||||
const tools = new GSDTools({ projectDir: tmpDir });
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.exec('test', []);
|
||||
expect(result).toEqual({ source: 'local' });
|
||||
});
|
||||
|
||||
@@ -151,7 +151,12 @@ export class GSDTools {
|
||||
let jsonStr = trimmed;
|
||||
if (jsonStr.startsWith('@file:')) {
|
||||
const filePath = jsonStr.slice(6).trim();
|
||||
jsonStr = await readFile(filePath, 'utf-8');
|
||||
try {
|
||||
jsonStr = await readFile(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read gsd-tools @file: indirection at "${filePath}": ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.parse(jsonStr);
|
||||
@@ -294,13 +299,14 @@ export class GSDTools {
|
||||
// ─── Path resolution ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve gsd-tools.cjs path with bundled-repo fallback.
|
||||
* Probe order: project-local → repo-bundled → global home directory.
|
||||
* Resolve gsd-tools.cjs path.
|
||||
* Probe order: SDK-bundled repo copy → `project/.claude/get-shit-done/` →
|
||||
* `~/.claude/get-shit-done/`.
|
||||
*/
|
||||
export function resolveGsdToolsPath(projectDir: string): string {
|
||||
const candidates = [
|
||||
join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
|
||||
BUNDLED_GSD_TOOLS_PATH,
|
||||
join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
|
||||
join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'),
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
This document records contracts for the typed query layer consumed by `gsd-sdk query` and programmatic `createRegistry()` callers.
|
||||
|
||||
## `gsd-sdk query` routing
|
||||
|
||||
- **Longest-prefix match** on argv (`resolveQueryArgv` in `registry.ts`): tries joined keys `a.b.c` then `a b c` for each prefix length, longest first. Example: `state update status X` → handler `state.update` with args `[status, X]`.
|
||||
- **Dotted single token**: one token like `init.new-project` matches the registry; if the first pass finds no handler, a single dotted token is split and matching runs again (same helper as above).
|
||||
- **No CJS passthrough**: if nothing matches a registered handler, the CLI exits with an error. Operations not ported to the query registry (e.g. `audit-open`, `graphify`, `from-gsd2`, `state validate`) must use `node …/gsd-tools.cjs` directly — see `docs/CLI-TOOLS.md`.
|
||||
- **Output**: JSON written to stdout for successful handler results.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Validation and programmer errors**: Handlers throw `GSDError` with an `ErrorClassification` (e.g. missing required args, invalid phase). The CLI maps these to exit codes via `exitCodeFor()`.
|
||||
|
||||
@@ -381,6 +381,7 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
|
||||
registry.register('milestone complete', milestoneComplete);
|
||||
registry.register('summary.extract', summaryExtract);
|
||||
registry.register('summary extract', summaryExtract);
|
||||
registry.register('summary-extract', summaryExtract);
|
||||
registry.register('history.digest', historyDigest);
|
||||
registry.register('history digest', historyDigest);
|
||||
registry.register('history-digest', historyDigest);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { QueryRegistry, extractField } from './registry.js';
|
||||
import { QueryRegistry, extractField, resolveQueryArgv } from './registry.js';
|
||||
import { createRegistry, QUERY_MUTATION_COMMANDS } from './index.js';
|
||||
import type { QueryResult } from './utils.js';
|
||||
|
||||
@@ -130,6 +130,11 @@ describe('createRegistry', () => {
|
||||
expect(registry.has('current-timestamp')).toBe(true);
|
||||
});
|
||||
|
||||
it('has summary-extract dash alias (PR #2179 / workflows)', () => {
|
||||
const registry = createRegistry();
|
||||
expect(registry.has('summary-extract')).toBe(true);
|
||||
});
|
||||
|
||||
it('can dispatch generate-slug', async () => {
|
||||
const registry = createRegistry();
|
||||
const result = await registry.dispatch('generate-slug', ['My Phase'], '/tmp');
|
||||
@@ -137,3 +142,40 @@ describe('createRegistry', () => {
|
||||
expect(result).toEqual({ data: { slug: 'my-phase' } });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveQueryArgv ───────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveQueryArgv', () => {
|
||||
it('matches longest dotted prefix (state.update + args)', () => {
|
||||
const registry = createRegistry();
|
||||
const m = resolveQueryArgv(['state', 'update', 'status', 'X'], registry);
|
||||
expect(m).toEqual({ cmd: 'state.update', args: ['status', 'X'] });
|
||||
});
|
||||
|
||||
it('matches longest prefix (phase.add wins over phase when both registered)', () => {
|
||||
const registry = createRegistry();
|
||||
const m = resolveQueryArgv(['phase', 'add', 'desc'], registry);
|
||||
expect(m).toEqual({ cmd: 'phase.add', args: ['desc'] });
|
||||
});
|
||||
|
||||
it('prefers longer match over shorter', () => {
|
||||
const registry = createRegistry();
|
||||
const m = resolveQueryArgv(['state', 'load'], registry);
|
||||
expect(m?.cmd).toBe('state.load');
|
||||
expect(m?.args).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null when no prefix matches', () => {
|
||||
const registry = createRegistry();
|
||||
const m = resolveQueryArgv(['totally-unknown', 'x'], registry);
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it('matches a single dotted command token', () => {
|
||||
const registry = createRegistry();
|
||||
expect(resolveQueryArgv(['init.new-project'], registry)).toEqual({
|
||||
cmd: 'init.new-project',
|
||||
args: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* Query command registry — routes commands to native SDK handlers.
|
||||
*
|
||||
* The registry is a flat `Map<string, QueryHandler>` that maps command names
|
||||
* to handler functions. Unknown commands throw GSDError — the gsd-tools.cjs
|
||||
* fallback was removed in v3.0 when all commands were migrated to native handlers.
|
||||
* to handler functions. Unknown keys passed to `dispatch()` throw `GSDError`.
|
||||
* The `gsd-sdk query` CLI resolves argv with `resolveQueryArgv()` before dispatch;
|
||||
* there is no automatic delegation to `gsd-tools.cjs`.
|
||||
*
|
||||
* Also exports `extractField` — a TypeScript port of the `--pick` field
|
||||
* extraction logic from gsd-tools.cjs (lines 365-382).
|
||||
@@ -59,9 +60,9 @@ export function extractField(obj: unknown, fieldPath: string): unknown {
|
||||
/**
|
||||
* Flat command registry that routes query commands to native handlers.
|
||||
*
|
||||
* Unknown commands throw `GSDError` from `dispatch()` — there is no fallback
|
||||
* to gsd-tools.cjs (bridge removed in v3.0). All supported commands must be
|
||||
* registered via `register()`.
|
||||
* `dispatch()` throws `GSDError` for unknown command keys. The `gsd-sdk query`
|
||||
* CLI uses `resolveQueryArgv()` to map argv to a registered handler; there is
|
||||
* no passthrough to `gsd-tools.cjs` — CJS-only commands stay on the legacy CLI.
|
||||
*/
|
||||
export class QueryRegistry {
|
||||
private handlers = new Map<string, QueryHandler>();
|
||||
@@ -106,9 +107,6 @@ export class QueryRegistry {
|
||||
/**
|
||||
* Dispatch a command to its registered native handler.
|
||||
*
|
||||
* Throws GSDError for unknown commands — the gsd-tools.cjs fallback was
|
||||
* removed in v3.0. All commands must be registered as native handlers (T-14-13).
|
||||
*
|
||||
* @param command - The command name to dispatch
|
||||
* @param args - Arguments to pass to the handler
|
||||
* @param projectDir - The project directory for context
|
||||
@@ -126,3 +124,51 @@ export class QueryRegistry {
|
||||
return handler(args, projectDir);
|
||||
}
|
||||
}
|
||||
|
||||
function expandSingleDottedToken(tokens: string[]): string[] {
|
||||
if (tokens.length !== 1 || tokens[0].startsWith('--')) {
|
||||
return tokens;
|
||||
}
|
||||
const t = tokens[0];
|
||||
if (!t.includes('.')) {
|
||||
return tokens;
|
||||
}
|
||||
return t.split('.');
|
||||
}
|
||||
|
||||
function matchRegisteredPrefix(
|
||||
tokens: string[],
|
||||
registry: QueryRegistry,
|
||||
): { cmd: string; args: string[] } | null {
|
||||
for (let i = tokens.length; i >= 1; i--) {
|
||||
const head = tokens.slice(0, i);
|
||||
const dotted = head.join('.');
|
||||
const spaced = head.join(' ');
|
||||
if (registry.has(dotted)) {
|
||||
return { cmd: dotted, args: tokens.slice(i) };
|
||||
}
|
||||
if (registry.has(spaced)) {
|
||||
return { cmd: spaced, args: tokens.slice(i) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map argv after `gsd-sdk query` to a registered handler key and remaining args.
|
||||
* Longest-prefix match on dotted (`a.b.c`) and spaced (`a b c`) keys; if no match,
|
||||
* expands a single dotted token (`state.validate` → `state`, `validate`) and retries.
|
||||
*/
|
||||
export function resolveQueryArgv(
|
||||
tokens: string[],
|
||||
registry: QueryRegistry,
|
||||
): { cmd: string; args: string[] } | null {
|
||||
let matched = matchRegisteredPrefix(tokens, registry);
|
||||
if (!matched) {
|
||||
const expanded = expandSingleDottedToken(tokens);
|
||||
if (expanded !== tokens) {
|
||||
matched = matchRegisteredPrefix(expanded, registry);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user