fix(cjs): accept dotted canonical command form (#3243) (#3248)

* test: reproduce CJS dispatcher rejecting dotted form (#3243)

runGsdTools assertions confirm that generate-slug.hello-world,
current-timestamp.date, validate.plan, roadmap.analyze, phases.list, and
check.decision-coverage-plan all fail with "Unknown command: <dotted>" —
the dispatcher switch only accepts the spaced form.

Edge cases (no dots unchanged, leading-dot rejected, unknown dotted form
suggests spaced equivalent) are also specified; those three pass already
because the shim is not yet implemented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(cjs): accept dotted canonical command form (#3243)

Add a shim at the top of main() in gsd-tools.cjs that splits args[0] on
the first dot when present, normalizing "state.update" → command='state'
args=['state','update',...] before the switch statement is reached.

Any caller that bypasses the SDK (stale npm-installed binary, workflow
shell-out, third-party script) can now use the canonical dotted form
natively without hitting "Unknown command: <domain>.<subcommand>".

The shim guards against empty head/rest so ".hidden" and bare "." args
are unchanged and fall through to the existing "Unknown command" path.

Also improves the default "Unknown command" error message to suggest
the spaced equivalent when a dotted form was passed — e.g. for "foo.bar"
the error now reads: Unknown command: foo — did you mean: "foo bar"?

Parallel to dottedCommandToCjsArgv in sdk/src/query/query-fallback-bridge-adapter.ts;
intentionally kept separate to avoid SDK coupling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* changeset: pr=3248 for #3243

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: tighten dotted-form suggestion assertion (CR nitpick)

* fix(cjs): suggestion uses first-dot split (CR finding 1, multi-dot consistency)

The "did you mean" hint in the Unknown-command default case was replacing ALL
dots with spaces (state.update.foo → "state update foo"), but the dispatcher
shim only splits on the FIRST dot (state.update.foo → head=state, rest=update.foo).
Apply CR's exact patch to use indexOf+slice so suggestion matches dispatch
behavior. Add a multi-dot regression test (a.b.c must suggest "a b.c", not
"a b c").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-08 08:40:36 -04:00
committed by GitHub
parent b2f0fdf250
commit 97bde8615f
3 changed files with 235 additions and 7 deletions

View File

@@ -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}`);
}
}
}