Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
```
Expand Down
33 changes: 31 additions & 2 deletions packages/cli/src/utils/reject-unknown-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}),
);
});
Expand All @@ -83,7 +85,8 @@ describe("assertKnownFlags", () => {
args: { start: { type: "string" }, end: { type: "string" }, out: { type: "string" } },
} as unknown as CommandDef<ArgsDef>;
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") }),
);
Expand All @@ -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<ArgsDef>;
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<ArgsDef>;
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
Expand Down
32 changes: 26 additions & 6 deletions packages/cli/src/utils/reject-unknown-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -74,9 +83,15 @@ const FLAG_SYNONYMS: Readonly<Record<string, readonly string[]>> = {
start: ["from"],
end: ["to"],
o: ["out", "output"],
search: ["query"],
};

function unknownFlagError(flag: string, known: Set<string>, path: string): CliUsageError {
function unknownFlagError(
flag: string,
known: Set<string>,
declared: string[],
path: string,
): CliUsageError {
const command = `hyperframes ${path ? `${path} ` : ""}`.trim();
if (flag === "--json") {
return new CliUsageError(`${command} has no JSON output.`, {
Expand All @@ -89,8 +104,13 @@ function unknownFlagError(flag: string, known: Set<string>, 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" });
}

Expand All @@ -110,7 +130,7 @@ export function assertKnownFlags(cmd: CommandDef<ArgsDef>, 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);
}
}

Expand Down
Loading