From ab59a94db10433471a995fa30820a67454792ede Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 14 Nov 2026 09:25:24 -0500 Subject: [PATCH] fix(cli): an unknown flag lists the command's real flags The error only said to run --help, so an agent guessed another flag. It now names every long flag the command declares, and --search points to --query. --- docs/packages/cli.mdx | 4 +-- .../src/utils/reject-unknown-flags.test.ts | 33 +++++++++++++++++-- .../cli/src/utils/reject-unknown-flags.ts | 32 ++++++++++++++---- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 092ea058ce5..9e2a87ad345 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -104,8 +104,8 @@ machine-readable `code` and, when there is a next step, a `hint`: "ok": false, "error": { "code": "unknown_flag", - "message": "Unknown flag: --out", - "hint": "Did you mean --output? Run 'hyperframes render --help' to see its flags." + "message": "Unknown flag: --search", + "hint": "Did you mean --query? Its flags: --type, --tag, --json, --human-friendly, --query, --yes, --on-device. Run 'hyperframes catalog --help' for what each does." } } ``` diff --git a/packages/cli/src/utils/reject-unknown-flags.test.ts b/packages/cli/src/utils/reject-unknown-flags.test.ts index aed4ecac859..7900c11f3be 100644 --- a/packages/cli/src/utils/reject-unknown-flags.test.ts +++ b/packages/cli/src/utils/reject-unknown-flags.test.ts @@ -73,7 +73,9 @@ describe("assertKnownFlags", () => { expect(() => assertKnownFlags(cmd, ["--out", "x"], "render")).toThrow( expect.objectContaining({ code: "unknown_flag", - hint: "Did you mean --output? Run 'hyperframes render --help' to see its flags.", + hint: + "Did you mean --output? Its flags: --output, --gif-loop, --docker, --workers, --json. " + + "Run 'hyperframes render --help' for what each does.", }), ); }); @@ -83,7 +85,8 @@ describe("assertKnownFlags", () => { args: { start: { type: "string" }, end: { type: "string" }, out: { type: "string" } }, } as unknown as CommandDef; const hint = (flag: string) => - `Did you mean --${flag}? Run 'hyperframes measure cuts --help' to see its flags.`; + `Did you mean --${flag}? Its flags: --start, --end, --out. ` + + "Run 'hyperframes measure cuts --help' for what each does."; expect(() => assertKnownFlags(cuts, ["--from", "1"], "measure cuts")).toThrow( expect.objectContaining({ hint: hint("start") }), ); @@ -92,6 +95,32 @@ describe("assertKnownFlags", () => { ); }); + // fails without the fix: the hint only said to run --help, so an agent guessed another flag. + it("lists the command's own flags, not its positionals, when nothing close matches", () => { + const snapshot = { + args: { + dir: { type: "positional" }, + at: { type: "string" }, + frames: { type: "string" }, + outDir: { type: "string" }, + }, + } as unknown as CommandDef; + expect(() => assertKnownFlags(snapshot, [".", "--width", "360"], "snapshot")).toThrow( + expect.objectContaining({ + hint: "Its flags: --at, --frames, --out-dir. Run 'hyperframes snapshot --help' for what each does.", + }), + ); + }); + + it("points --search at the query flag", () => { + const catalog = { + args: { words: { type: "positional" }, query: { type: "string" }, json: { type: "boolean" } }, + } as unknown as CommandDef; + expect(() => assertKnownFlags(catalog, ["--search", "light leak"], "catalog")).toThrow( + expect.objectContaining({ hint: expect.stringMatching(/^Did you mean --query\? /) }), + ); + }); + it("checks each char of a combined short group", () => { expect(ok(["-ow"])).not.toThrow(); // both known aliases expect(ok(["-ox"])).toThrow(/Unknown flag: -x/); // x unknown diff --git a/packages/cli/src/utils/reject-unknown-flags.ts b/packages/cli/src/utils/reject-unknown-flags.ts index 0080320f5c3..0fee2b70127 100644 --- a/packages/cli/src/utils/reject-unknown-flags.ts +++ b/packages/cli/src/utils/reject-unknown-flags.ts @@ -14,10 +14,19 @@ const ALWAYS_KNOWN = new Set(["help", "h", "version", "v"]); // A camelCase arg name (`gifLoop`) is passed as `--gif-loop`; a kebab name is // passed as-is. Accept both spellings so the validator matches citty's parsing. +function kebabCase(name: string): string { + return name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); +} + function nameVariants(name: string): string[] { - const kebab = name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); const camel = name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); - return [name, kebab, camel]; + return [name, kebabCase(name), camel]; +} + +function declaredLongFlags(args: ArgsDef | undefined): string[] { + return Object.entries(args ?? {}) + .filter(([, def]) => def?.type !== "positional") + .map(([name]) => `--${kebabCase(name)}`); } // Every spelling of one declared arg: name variants plus aliases. `def` may be undefined. @@ -74,9 +83,15 @@ const FLAG_SYNONYMS: Readonly> = { start: ["from"], end: ["to"], o: ["out", "output"], + search: ["query"], }; -function unknownFlagError(flag: string, known: Set, path: string): CliUsageError { +function unknownFlagError( + flag: string, + known: Set, + declared: string[], + path: string, +): CliUsageError { const command = `hyperframes ${path ? `${path} ` : ""}`.trim(); if (flag === "--json") { return new CliUsageError(`${command} has no JSON output.`, { @@ -89,8 +104,13 @@ function unknownFlagError(flag: string, known: Set, path: string): CliUs const match = FLAG_SYNONYMS[name]?.find((s) => known.has(s)) ?? (flag.startsWith("--") ? closestMatch(name, longNames) : undefined); - const help = `Run 'hyperframes ${path ? `${path} ` : ""}--help' to see its flags.`; - const hint = match ? `Did you mean --${match}? ${help}` : help; + const hint = [ + match && `Did you mean --${match}?`, + declared.length > 0 && `Its flags: ${declared.join(", ")}.`, + `Run 'hyperframes ${path ? `${path} ` : ""}--help' for what each does.`, + ] + .filter(Boolean) + .join(" "); return new CliUsageError(`Unknown flag: ${flag}`, { hint, code: "unknown_flag" }); } @@ -110,7 +130,7 @@ export function assertKnownFlags(cmd: CommandDef, rawArgs: string[], pa for (const tok of rawArgs) { if (tok === "--") break; const bad = unknownFlagIn(tok, known); - if (bad) throw unknownFlagError(bad, known, path); + if (bad) throw unknownFlagError(bad, known, declaredLongFlags(args), path); } }