Files
Martin Sumera 58017a03c7 fix(cli): exit on an unknown subcommand instead of blocking on stdin (#1444)
A typo'd subcommand fell through the dispatcher to the plan-hook path,
which reads the hook payload from stdin. From a terminal that stdin never
closes, so `plannotator annotatte README.md` hung with no output instead
of reporting the typo.

Reject an unrecognized first token before the dispatcher runs: print the
offending word, the nearest documented command, and a pointer to --help,
then exit 1. Flags and the no-argument hook invocation are left alone so
the real hook path is untouched.
2026-09-01 10:12:35 -07:00

92 lines
2.7 KiB
TypeScript

import { SUBCOMMAND_HELP, SUBCOMMAND_HELP_ALIASES } from "./cli";
const INTERNAL_SUBCOMMANDS = [
"install-runtime",
"opencode-plan",
"opencode-review",
"opencode-annotate-last",
"copilot-plan",
] as const;
const SUGGESTABLE_SUBCOMMANDS = [
...Object.keys(SUBCOMMAND_HELP),
...Object.keys(SUBCOMMAND_HELP_ALIASES),
];
export const KNOWN_SUBCOMMANDS: ReadonlySet<string> = new Set([
...SUGGESTABLE_SUBCOMMANDS,
...INTERNAL_SUBCOMMANDS,
]);
function levenshteinDistance(left: string, right: string): number {
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
const current = [leftIndex];
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
current[rightIndex] = Math.min(
previous[rightIndex] + 1,
current[rightIndex - 1] + 1,
previous[rightIndex - 1] +
(left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
);
}
previous = current;
}
return previous[right.length];
}
export function findClosestSubcommand(token: string): string | null {
const normalized = token.toLowerCase();
if (!normalized) return null;
if (normalized.length >= 3) {
const prefixMatch = SUGGESTABLE_SUBCOMMANDS.find((candidate) =>
candidate.startsWith(normalized),
);
if (prefixMatch) return prefixMatch;
}
const tolerance =
normalized.length <= 4 ? 1 : normalized.length <= 8 ? 2 : 3;
let closest: string | null = null;
let closestDistance = Infinity;
for (const candidate of SUGGESTABLE_SUBCOMMANDS) {
if (Math.abs(normalized.length - candidate.length) > tolerance) continue;
const distance = levenshteinDistance(normalized, candidate);
if (distance <= tolerance && distance < closestDistance) {
closest = candidate;
closestDistance = distance;
}
}
return closest;
}
export function findUnknownSubcommand(args: readonly string[]): string | null {
const first = args[0];
if (!first || first.startsWith("-")) return null;
return KNOWN_SUBCOMMANDS.has(first) ? null : first;
}
export function formatUnknownSubcommandError(subcommand: string): string {
const suggestion = findClosestSubcommand(subcommand);
return [
`Unknown command: ${subcommand}`,
...(suggestion ? ["", `Did you mean 'plannotator ${suggestion}'?`] : []),
"",
"Run 'plannotator --help' for the list of commands.",
].join("\n");
}
export function exitOnUnknownSubcommand(args: readonly string[]): void {
const unknownSubcommand = findUnknownSubcommand(args);
if (!unknownSubcommand) return;
console.error(formatUnknownSubcommandError(unknownSubcommand));
process.exit(1);
}