refactor(cli): extract Query CLI Adapter Module seam (#3074)

* refactor(cli): extract query adapter seam from cli entrypoint

* test: update ws forwarding guard for query-cli-adapter seam

* fix(query): close remaining CodeRabbit findings on cli adapter

* test: address remaining CodeRabbit nitpicks on ws forwarding coverage
This commit is contained in:
Tom Boucher
2026-05-03 15:57:01 -04:00
committed by GitHub
parent b6c401dc90
commit 5c9f34bd31
5 changed files with 152 additions and 66 deletions

View File

@@ -0,0 +1,6 @@
---
type: Changed
pr: 3074
---
**query CLI path extracted into a dedicated Query CLI Adapter Module** — `sdk/src/cli.ts` now delegates query-specific dispatch, error mapping, and output/exit handling to `sdk/src/query/query-cli-adapter.ts` for better locality and testability.

View File

@@ -18,6 +18,7 @@ import { InitRunner } from './init-runner.js';
import { validateWorkstreamName } from './workstream-utils.js';
import { loadConfig } from './config.js';
import { assertRuntimeSupportsAutoMode } from './runtime-gate.js';
import { runQueryCliCommand } from './query/query-cli-adapter.js';
// ─── Parsed CLI args ─────────────────────────────────────────────────────────
@@ -276,13 +277,6 @@ async function readStdin(): Promise<string> {
});
}
/** When false, unknown `gsd-sdk query` commands error instead of shelling out to gsd-tools.cjs. */
function queryFallbackToCjsEnabled(): boolean {
const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase();
if (v === 'off' || v === 'never' || v === 'false' || v === '0') return false;
return true;
}
// ─── Main ────────────────────────────────────────────────────────────────────
@@ -316,72 +310,35 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
return;
}
// ─── Query command ──────────────────────────────────────────────────────
if (args.command === 'query') {
const result = await runQueryCliCommand({
projectDir: args.projectDir,
ws: args.ws,
queryArgv: args.queryArgv,
});
for (const line of result.stderrLines) console.error(line);
for (const chunk of result.stdoutChunks) process.stdout.write(chunk);
process.exitCode = result.exitCode;
return;
}
// Fall back to GSD_WORKSTREAM env var when --ws is not supplied (#2791).
// gsd-tools.cjs resolves the active workstream via this env var; parity
// means gsd-sdk query commands see the same .planning/ path as gsd-tools.
// means gsd-sdk command paths see the same .planning/ path as gsd-tools.
if (args.ws === undefined && process.env.GSD_WORKSTREAM) {
const envWs = process.env.GSD_WORKSTREAM;
if (validateWorkstreamName(envWs)) {
args = { ...args, ws: envWs };
}
// If the env var contains an invalid name, silently ignore it (same as CJS).
}
// Multi-repo project-root resolution (issue #2623).
//
// When the user launches `gsd-sdk` from inside a `sub_repos`-listed child repo,
// `projectDir` defaults to `process.cwd()` which points at the child, not the
// parent workspace that owns `.planning/`. Mirror the legacy `gsd-tools.cjs`
// walk-up semantics so handlers see the correct project root.
//
// Idempotent: if `projectDir` already has its own `.planning/` (including an
// explicit `--project-dir` pointing at the workspace root), findProjectRoot
// returns it unchanged.
{
const { findProjectRoot } = await import('./query/helpers.js');
args = { ...args, projectDir: findProjectRoot(args.projectDir) };
}
// ─── Query command ──────────────────────────────────────────────────────
if (args.command === 'query') {
const { createRegistry } = await import('./query/index.js');
const { runQueryDispatch } = await import('./query/query-dispatch.js');
const { resolveGsdToolsPath, GSDToolsError } = await import('./gsd-tools.js');
const { GSDError, exitCodeFor } = await import('./errors.js');
try {
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir: args.projectDir,
ws: args.ws,
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
resolveGsdToolsPath,
dispatchNative: (cmd, argv) => registry.dispatch(cmd, argv, args.projectDir, args.ws),
}, args.queryArgv ?? []);
for (const line of out.stderr) console.error(line);
if (!out.ok) {
console.error(out.error.message);
process.exitCode = out.exit_code;
return;
}
if (out.stdout) process.stdout.write(out.stdout);
} catch (err) {
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;
}
}
return;
}
if (args.command !== 'run' && args.command !== 'init' && args.command !== 'auto') {
console.error('Error: Expected "gsd-sdk run <prompt>", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query <command>"');
console.error(USAGE);

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const dispatchSpy = vi.hoisted(() => vi.fn());
const runQueryDispatchSpy = vi.hoisted(() => vi.fn());
vi.mock('./helpers.js', () => ({
findProjectRoot: (projectDir: string) => projectDir,
}));
vi.mock('./index.js', () => ({
createRegistry: () => ({ dispatch: dispatchSpy }),
}));
vi.mock('./query-dispatch.js', () => ({
runQueryDispatch: (...args: unknown[]) => runQueryDispatchSpy(...args),
}));
import { runQueryCliCommand } from './query-cli-adapter.js';
describe('query-cli-adapter', () => {
beforeEach(() => {
dispatchSpy.mockReset();
runQueryDispatchSpy.mockReset();
});
it('returns validation failure for missing query command', async () => {
runQueryDispatchSpy.mockResolvedValueOnce({
ok: false,
exit_code: 10,
stdout: '',
stderr: [],
error: { kind: 'validation_error', message: 'query requires a command', details: {} },
});
const out = await runQueryCliCommand({
projectDir: process.cwd(),
queryArgv: [],
});
expect(out.exitCode).toBe(10);
expect(out.stderrLines.join('\n')).toContain('requires a command');
});
it('forwards ws to registry.dispatch via dispatchNative', async () => {
runQueryDispatchSpy.mockImplementationOnce(async (input: any) => {
await input.dispatchNative('state', ['show']);
return { ok: true, exit_code: 0, stdout: '', stderr: [] };
});
await runQueryCliCommand({
projectDir: process.cwd(),
ws: 'alpha',
queryArgv: ['state', 'show'],
});
expect(dispatchSpy).toHaveBeenCalledWith('state', ['show'], process.cwd(), 'alpha');
});
});

View File

@@ -0,0 +1,71 @@
import { findProjectRoot } from './helpers.js';
import { createRegistry } from './index.js';
import { runQueryDispatch } from './query-dispatch.js';
import { resolveGsdToolsPath, GSDToolsError } from '../gsd-tools.js';
import { GSDError, exitCodeFor } from '../errors.js';
import { validateWorkstreamName } from '../workstream-utils.js';
export interface QueryCliAdapterInput {
projectDir: string;
ws?: string;
queryArgv?: string[];
}
export interface QueryCliAdapterOutput {
exitCode: number;
stdoutChunks: string[];
stderrLines: string[];
}
function queryFallbackToCjsEnabled(): boolean {
const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase();
if (v === 'off' || v === 'never' || v === 'false' || v === '0') return false;
return true;
}
function resolveQueryWorkstream(ws: string | undefined): string | undefined {
if (ws !== undefined) {
return validateWorkstreamName(ws) ? ws : undefined;
}
const envWs = process.env.GSD_WORKSTREAM;
if (!envWs) return undefined;
return validateWorkstreamName(envWs) ? envWs : undefined;
}
export async function runQueryCliCommand(input: QueryCliAdapterInput): Promise<QueryCliAdapterOutput> {
const stderrLines: string[] = [];
const stdoutChunks: string[] = [];
const ws = resolveQueryWorkstream(input.ws);
try {
const projectDir = findProjectRoot(input.projectDir);
const registry = createRegistry();
const out = await runQueryDispatch({
registry,
projectDir,
ws,
cjsFallbackEnabled: queryFallbackToCjsEnabled(),
resolveGsdToolsPath,
dispatchNative: (cmd, argv) => registry.dispatch(cmd, argv, projectDir, ws),
}, input.queryArgv ?? []);
stderrLines.push(...out.stderr);
if (!out.ok) {
stderrLines.push(out.error.message);
return { exitCode: out.exit_code, stdoutChunks, stderrLines };
}
if (out.stdout) stdoutChunks.push(out.stdout);
return { exitCode: 0, stdoutChunks, stderrLines };
} catch (err) {
if (err instanceof GSDError) {
stderrLines.push(`Error: ${err.message}`);
return { exitCode: exitCodeFor(err.classification), stdoutChunks, stderrLines };
}
if (err instanceof GSDToolsError) {
stderrLines.push(`Error: ${err.message}`);
return { exitCode: err.exitCode ?? 1, stdoutChunks, stderrLines };
}
stderrLines.push(`Error: ${err instanceof Error ? err.message : String(err)}`);
return { exitCode: 1, stdoutChunks, stderrLines };
}
}

View File

@@ -7,7 +7,7 @@
/**
* Bug #2524: gsd-sdk query --ws <name> silently ignores the workstream flag.
* Tests that --ws is forwarded through the call chain:
* cli.ts -> registry.dispatch() -> planningPaths()
* cli.ts -> runQueryCliCommand() -> registry.dispatch() -> planningPaths()
*
* Uses static source-file text assertions (no sdk/dist/ build required in CI).
*/
@@ -88,13 +88,6 @@ describe('QueryRegistry.dispatch() workstream threading', () => {
// ─── Layer 1: CLI forwards args.ws to registry.dispatch() ─────────────────
describe('CLI forwards --ws to registry.dispatch()', () => {
test('cli.ts passes args.ws as the workstream argument to registry.dispatch()', () => {
// The CLI now uses a dispatchNative callback pattern.
assert.ok(
cliTs.includes('dispatchNative') && cliTs.includes('args.ws'),
'cli.ts must forward args.ws to registry.dispatch() as the workstream argument',
);
});
test('cli.ts defines a ws field in ParsedCliArgs', () => {
assert.ok(
@@ -109,4 +102,5 @@ describe('CLI forwards --ws to registry.dispatch()', () => {
'cli.ts query permissive parser must handle the --ws flag',
);
});
});