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
3 changes: 3 additions & 0 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ import {
isUninstallConfirmationAccepted,
parseUninstallOptions,
} from "./cli";
import { exitOnUnknownSubcommand } from "./unknown-subcommand";
import { completeAnnotateCommand } from "./annotate-command";
import {
annotateStartupFailureExitCode,
Expand Down Expand Up @@ -384,6 +385,8 @@ if (helpSubcommand) {
process.exit(0);
}

exitOnUnknownSubcommand(args);

if (args[0] === "uninstall") {
let options: ReturnType<typeof parseUninstallOptions>;
try {
Expand Down
77 changes: 77 additions & 0 deletions apps/hook/server/unknown-subcommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
findClosestSubcommand,
findUnknownSubcommand,
formatUnknownSubcommandError,
KNOWN_SUBCOMMANDS,
} from "./unknown-subcommand";

describe("unknown subcommand", () => {
test("the known command registry matches the dispatcher", () => {
const source = readFileSync(resolve(import.meta.dir, "index.ts"), "utf8");
const dispatched = new Set(
[...source.matchAll(/args\[0\] === "([^"]+)"/g)].map((match) => match[1]),
);

expect(dispatched.size).toBeGreaterThan(10);
expect(dispatched).toEqual(KNOWN_SUBCOMMANDS);
});

test("leaves flags and the no-argument hook invocation to the dispatcher", () => {
expect(findUnknownSubcommand([])).toBeNull();
expect(findUnknownSubcommand(["--help"])).toBeNull();
expect(findUnknownSubcommand(["-v"])).toBeNull();
expect(findUnknownSubcommand(["review", "--nonsense"])).toBeNull();
});

test("reports the offending token", () => {
expect(findUnknownSubcommand(["annotatte", "README.md"])).toBe("annotatte");
expect(findUnknownSubcommand(["xyzzy"])).toBe("xyzzy");
});

test("suggests the nearest documented command", () => {
expect(findClosestSubcommand("annotatte")).toBe("annotate");
expect(findClosestSubcommand("revieww")).toBe("review");
expect(findClosestSubcommand("guid")).toBe("guide");
expect(findClosestSubcommand("annot")).toBe("annotate");
expect(findClosestSubcommand("a")).toBeNull();
expect(findClosestSubcommand("xyzzy")).toBeNull();
expect(findClosestSubcommand("x".repeat(10_000))).toBeNull();
});

test("error text names the typo and points at --help", () => {
const message = formatUnknownSubcommandError("annotatte");
expect(message).toContain("Unknown command: annotatte");
expect(message).toContain("plannotator annotate");
expect(message).toContain("--help");
});

test("a typo'd subcommand exits instead of blocking on an open stdin", async () => {
const proc = Bun.spawn(
[
"bun",
"run",
resolve(import.meta.dir, "index.ts"),
"annotatte",
"README.md",
],
{
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
},
);
const timeout = setTimeout(() => proc.kill(), 15_000);
const [stderr, code] = await Promise.all([
new Response(proc.stderr).text(),
proc.exited,
]);
clearTimeout(timeout);

expect(code).toBe(1);
expect(stderr).toContain("Unknown command: annotatte");
expect(stderr).toContain("plannotator annotate");
}, 30_000);
});
91 changes: 91 additions & 0 deletions apps/hook/server/unknown-subcommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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);
}