diff --git a/.changeset/silly-badgers-frolic.md b/.changeset/silly-badgers-frolic.md new file mode 100644 index 000000000..2cf4b3dc6 --- /dev/null +++ b/.changeset/silly-badgers-frolic.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3248 +--- +**`gsd-tools .` (dotted form) now accepted natively by the CJS dispatcher** — previously only worked when invoked via the SDK, which split the form client-side. Stale SDK binaries and direct CJS callers no longer hit "Unknown command" on the canonical form. diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 19cc1dfaf..e1739d72e 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -241,7 +241,7 @@ function parseMultiwordArg(args, flag) { // ─── CLI Router ─────────────────────────────────────────────────────────────── async function main() { - const args = process.argv.slice(2); + let args = process.argv.slice(2); // Optional cwd override for sandboxed subagents running outside project root. let cwd = process.cwd(); @@ -339,7 +339,30 @@ async function main() { args.splice(defaultIdx, 2); } - const command = args[0]; + let command = args[0]; + + // #3243: accept dotted canonical form (e.g. `state.update`) as well as the + // spaced form (`state update`). Workflow files and stale SDK binaries pass + // the dotted canonical form directly; any caller that bypasses the SDK + // client-side split hit "Unknown command" before this shim. + // + // Split on the FIRST dot only — `check.decision-coverage-plan` becomes + // command='check', args=['check','decision-coverage-plan',...rest]. + // Parallel to dottedCommandToCjsArgv in sdk/src/query/query-fallback-bridge-adapter.ts; + // kept separate here to avoid SDK coupling (see TODO: extract to shared helper). + // + // Guard: head and rest must both be non-empty (rejects leading-dot args like + // ".hidden" and bare-dot "."). + const originalCommand = command; // preserved for "Unknown command" suggestion + if (typeof command === 'string' && command.includes('.')) { + const dotIdx = command.indexOf('.'); + const head = command.slice(0, dotIdx); + const rest = command.slice(dotIdx + 1); + if (head && rest) { + command = head; + args = [head, rest, ...args.slice(1)]; + } + } // Top-level usage string — emitted by `gsd-tools` (no args) and by // `gsd-tools --help` / any `--help` request below. @@ -424,7 +447,7 @@ async function main() { } }; try { - await runCommand(command, args, cwd, raw, defaultValue); + await runCommand(command, args, cwd, raw, defaultValue, originalCommand); cleanup(); } catch (e) { fs.writeSync = origWriteSync; @@ -445,7 +468,7 @@ async function main() { return origWriteSync2.call(fs, fd, data, ...rest); }; try { - await runCommand(command, args, cwd, raw, defaultValue); + await runCommand(command, args, cwd, raw, defaultValue, originalCommand); } finally { fs.writeSync = origWriteSync2; } @@ -479,7 +502,7 @@ function extractField(obj, fieldPath) { return current; } -async function runCommand(command, args, cwd, raw, defaultValue) { +async function runCommand(command, args, cwd, raw, defaultValue, originalCommand) { switch (command) { case 'state': { routeStateCommand({ @@ -1163,8 +1186,25 @@ async function runCommand(command, args, cwd, raw, defaultValue) { break; } - default: - error(`Unknown command: ${command}`); + default: { + // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim + // above split it so `command` here is the head ("foo"). Use + // originalCommand to reconstruct the original dotted form and suggest + // the spaced equivalent — surfacing a useful diagnostic instead of just + // "Unknown command: foo". + const wasDotted = + typeof originalCommand === 'string' && + originalCommand !== command && + originalCommand.includes('.'); + let suggestion = ''; + if (wasDotted) { + const dotIdx = originalCommand.indexOf('.'); + const head = originalCommand.slice(0, dotIdx); + const rest = originalCommand.slice(dotIdx + 1); + suggestion = ` — did you mean: "${head} ${rest}"?`; + } + error(`Unknown command: ${command}${suggestion}`); + } } } diff --git a/tests/bug-3243-dotted-command-form.test.cjs b/tests/bug-3243-dotted-command-form.test.cjs new file mode 100644 index 000000000..9da9005bf --- /dev/null +++ b/tests/bug-3243-dotted-command-form.test.cjs @@ -0,0 +1,183 @@ +/** + * Regression tests for bug #3243. + * + * The CJS dispatcher (gsd-tools.cjs) must accept dotted canonical command + * form (e.g. `state.update`) as well as the spaced form (`state update`). + * Workflow markdown files emit `gsd-sdk query .` calls, + * and any caller that bypasses the SDK (stale npm binary, direct shell-out, + * third-party script) would hit "Unknown command: .". + * + * The fix: a top-of-main() shim that splits args[0] on the first `.` when + * present and normalizes to the spaced form before the switch is reached. + * + * This test file uses runGsdTools() — never readFileSync + .includes(). + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('bug #3243: CJS dispatcher accepts dotted canonical command form', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // ── generate-slug: no project structure needed, deterministic output ──── + + test('generate-slug.hello-world (dotted) produces the same slug as spaced form', () => { + const spaced = runGsdTools(['generate-slug', 'hello-world'], tmpDir); + assert.strictEqual(spaced.success, true, [ + 'control (spaced form) failed:', + spaced.error, + ].join(' ')); + + const dotted = runGsdTools(['generate-slug.hello-world'], tmpDir); + // Before the fix this errors: "Unknown command: generate-slug.hello-world" + assert.strictEqual(dotted.success, true, [ + 'dotted form must not emit "Unknown command":', + dotted.error, + ].join(' ')); + assert.strictEqual(dotted.output, spaced.output, + 'dotted form must produce identical output to spaced form'); + }); + + test('current-timestamp.date (dotted) produces the same output as spaced form', () => { + const spaced = runGsdTools(['current-timestamp', 'date'], tmpDir); + assert.strictEqual(spaced.success, true, [ + 'control (spaced form) failed:', + spaced.error, + ].join(' ')); + + const dotted = runGsdTools(['current-timestamp.date'], tmpDir); + assert.strictEqual(dotted.success, true, [ + 'dotted form must not emit "Unknown command":', + dotted.error, + ].join(' ')); + assert.strictEqual(dotted.output, spaced.output, + 'dotted form must produce identical output to spaced form'); + }); + + // ── Commands with subcommands that need a project ──────────────────────── + + test('validate.plan (dotted) routes into validate handler, not "Unknown command"', () => { + const dotted = runGsdTools(['validate.plan'], tmpDir); + // Before the fix: success=false, error contains "Unknown command: validate.plan" + // After the fix: success=false is still possible (validate needs a PLAN.md), + // but the error must NOT mention "Unknown command". + const errText = dotted.error || ''; + assert.ok( + !errText.includes('Unknown command: validate.plan'), + [ + 'dotted form must not produce "Unknown command: validate.plan".', + 'Got error:', errText, + ].join('\n') + ); + }); + + test('roadmap.analyze (dotted) routes into roadmap handler, not "Unknown command"', () => { + const dotted = runGsdTools(['roadmap.analyze'], tmpDir); + // success=true means it reached the handler (even if handler reports no ROADMAP.md). + // success=false means dispatcher rejected it — assert the error is NOT "Unknown command". + const errText = dotted.error || ''; + assert.ok( + !errText.includes('Unknown command: roadmap.analyze'), + [ + 'dotted form must not produce "Unknown command: roadmap.analyze".', + 'Got error:', errText, + ].join('\n') + ); + }); + + test('phases.list (dotted) routes into phases handler, not "Unknown command"', () => { + const dotted = runGsdTools(['phases.list'], tmpDir); + const errText = dotted.error || ''; + assert.ok( + !errText.includes('Unknown command: phases.list'), + [ + 'dotted form must not produce "Unknown command: phases.list".', + 'Got error:', errText, + ].join('\n') + ); + }); + + // ── Multi-dot commands: split on first dot only ────────────────────────── + + test('check.decision-coverage-plan (multi-dot-safe: first dot splits)', () => { + const dotted = runGsdTools(['check.decision-coverage-plan'], tmpDir); + // "check" is not a known top-level command currently, so this will still + // fail — but the error must NOT say "Unknown command: check.decision-coverage-plan" + // (the dotted form); it should say something about "check" (the split result). + const errText = dotted.error || ''; + assert.ok( + !errText.includes('Unknown command: check.decision-coverage-plan'), + [ + 'multi-dot dotted form must not be passed verbatim to "Unknown command".', + 'Got error:', errText, + ].join('\n') + ); + }); + + // ── Edge cases ──────────────────────────────────────────────────────────── + + test('command without dots is unchanged (existing behaviour preserved)', () => { + const result = runGsdTools(['generate-slug', 'no-dots-here'], tmpDir); + assert.strictEqual(result.success, true, [ + 'spaced-only invocation must still work:', + result.error, + ].join(' ')); + assert.ok(result.output.length > 0, 'output must be non-empty'); + }); + + test('leading-dot arg (e.g. .hidden) is not mis-routed by the shim', () => { + // A leading dot in args[0] like ".hidden" has head="" (empty) after split, + // so the shim must reject it and fall through to the existing "Unknown command" + // path (not silently reroute to an empty-string command). + const result = runGsdTools(['.hidden'], tmpDir); + assert.strictEqual(result.success, false, 'leading-dot arg must not succeed'); + }); + + // ── "Unknown command" error message improvement ────────────────────────── + + test('"Unknown command" error for dotted form suggests spaced equivalent', () => { + // A genuinely unknown dotted command (e.g. "foo.bar") should include a + // "did you mean" hint pointing at the spaced form "foo bar". + const result = runGsdTools(['foo.bar'], tmpDir); + assert.strictEqual(result.success, false, '"foo.bar" must fail'); + assert.ok( + result.error.includes('foo bar'), + [ + 'error for unknown dotted command should suggest spaced form "foo bar".', + 'Got:', result.error, + ].join('\n') + ); + }); + + test('multi-dot unknown command suggestion uses first-dot split only (a.b.c → "a b.c")', () => { + // The shim splits only on the FIRST dot, so the suggestion must mirror that: + // "a.b.c" → head="a", rest="b.c" → suggest "a b.c", NOT "a b c". + const result = runGsdTools(['a.b.c'], tmpDir); + assert.strictEqual(result.success, false, '"a.b.c" must fail'); + assert.ok( + result.error.includes('a b.c'), + [ + 'suggestion for multi-dot unknown command must use first-dot split: "a b.c".', + 'Got:', result.error, + ].join('\n') + ); + assert.ok( + !result.error.includes('a b c'), + [ + 'suggestion must NOT replace all dots ("a b c" is wrong — only first dot splits).', + 'Got:', result.error, + ].join('\n') + ); + }); +});