From d342fd620c45bbdc1c66e457ed3b7bf65a9c37f9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 19 Jun 2026 10:23:43 -0700 Subject: [PATCH] feat(cli): alias bare annotate targets --- apps/hook/server/cli.test.ts | 56 ++++++++++++++++++++++++++++++++++++ apps/hook/server/cli.ts | 38 ++++++++++++++++++++++++ apps/hook/server/index.ts | 33 +++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index 9a2e14f8c..29c5a5d85 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -4,8 +4,10 @@ import { formatTopLevelHelp, formatVersion, isInteractiveNoArgInvocation, + isReservedTopLevelCommand, isTopLevelHelpInvocation, isVersionInvocation, + shouldAliasToAnnotate, } from "./cli"; describe("CLI top-level help", () => { @@ -63,3 +65,57 @@ describe("interactive no-arg invocation", () => { expect(output).toContain("Run 'plannotator --help' for top-level usage."); }); }); + +describe("annotate target shorthand", () => { + const annotatableTargets = new Set([ + "./", + "docs/", + "README.md", + "page.html", + "https://example.com", + ]); + const isAnnotatableTarget = (arg: string) => annotatableTargets.has(arg); + const applyAlias = (args: string[]) => + shouldAliasToAnnotate(args, isAnnotatableTarget) ? ["annotate", ...args] : args; + + test("routes bare annotatable targets to annotate", () => { + expect(applyAlias(["./"])).toEqual(["annotate", "./"]); + expect(applyAlias(["docs/"])).toEqual(["annotate", "docs/"]); + expect(applyAlias(["README.md"])).toEqual(["annotate", "README.md"]); + expect(applyAlias(["https://example.com"])).toEqual(["annotate", "https://example.com"]); + }); + + test("preserves trailing flags when aliasing", () => { + expect(applyAlias(["page.html", "--markdown"])).toEqual([ + "annotate", + "page.html", + "--markdown", + ]); + }); + + test("keeps existing commands and bare invocation unchanged", () => { + expect(shouldAliasToAnnotate([], isAnnotatableTarget)).toBe(false); + expect(shouldAliasToAnnotate(["review"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["annotate"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["annotate-last"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["archive"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["sessions"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["setup-goal"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["improve-context"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["install-runtime"], () => true)).toBe(false); + }); + + test("keeps internal bridge command families unchanged", () => { + expect(isReservedTopLevelCommand("opencode-plan")).toBe(true); + expect(isReservedTopLevelCommand("opencode-anything")).toBe(true); + expect(isReservedTopLevelCommand("copilot-plan")).toBe(true); + expect(isReservedTopLevelCommand("copilot-anything")).toBe(true); + expect(shouldAliasToAnnotate(["opencode-plan"], () => true)).toBe(false); + expect(shouldAliasToAnnotate(["copilot-plan"], () => true)).toBe(false); + }); + + test("does not alias typo commands or targets hidden behind unknown flags", () => { + expect(shouldAliasToAnnotate(["revieew"], isAnnotatableTarget)).toBe(false); + expect(shouldAliasToAnnotate(["--unknown", "README.md"], isAnnotatableTarget)).toBe(false); + }); +}); diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index ab7621e91..43ff89c00 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -19,6 +19,44 @@ export function isInteractiveNoArgInvocation( return args.length === 0 && stdinIsTTY === true; } +const RESERVED_TOP_LEVEL_COMMANDS = new Set([ + "annotate", + "annotate-last", + "archive", + "copilot-last", + "copilot-plan", + "improve-context", + "install-runtime", + "last", + "opencode-annotate-last", + "opencode-plan", + "opencode-review", + "review", + "sessions", + "setup-goal", +]); + +export function isReservedTopLevelCommand(command: string): boolean { + return ( + RESERVED_TOP_LEVEL_COMMANDS.has(command) || + command.startsWith("opencode-") || + command.startsWith("copilot-") + ); +} + +export function shouldAliasToAnnotate( + args: string[], + isAnnotatableTarget: (arg: string) => boolean, +): boolean { + const firstNonFlagIndex = args.findIndex((arg) => !arg.startsWith("-")); + if (firstNonFlagIndex === -1 || firstNonFlagIndex !== 0) return false; + + const target = args[firstNonFlagIndex]; + if (!target || isReservedTopLevelCommand(target)) return false; + + return isAnnotatableTarget(target); +} + export function formatTopLevelHelp(): string { return [ "Usage:", diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index fbaf545e0..2d891889b 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -136,6 +136,7 @@ import { isInteractiveNoArgInvocation, isTopLevelHelpInvocation, isVersionInvocation, + shouldAliasToAnnotate, } from "./cli"; import path from "path"; import { tmpdir } from "os"; @@ -246,6 +247,34 @@ async function loadGoalSetupBundle( return normalizeGoalSetupBundle(JSON.parse(raw), stage); } +const ANNOTATE_ALIAS_SUPPORTED_FILE_REGEX = /\.(mdx?|txt|html?)$/i; +const ANNOTATE_ALIAS_MARKDOWN_FILE_REGEX = /\.(mdx?|txt)$/i; + +function isAnnotatableExistingPath(candidate: string, projectRoot: string): boolean { + const resolvedPath = resolveUserPath(candidate, projectRoot); + try { + const stat = statSync(resolvedPath); + if (stat.isDirectory()) return true; + if (stat.isFile() && ANNOTATE_ALIAS_SUPPORTED_FILE_REGEX.test(resolvedPath)) return true; + } catch { + // Fall through to markdown's richer resolver for bare filenames. + } + + if (!ANNOTATE_ALIAS_MARKDOWN_FILE_REGEX.test(candidate)) return false; + return resolveMarkdownFile(candidate, projectRoot).kind === "found"; +} + +function isAnnotateAliasTarget(rawTarget: string): boolean { + const target = stripAtPrefix(rawTarget); + if (/^https?:\/\//i.test(target)) return true; + if (/[\\/]$/.test(target)) return true; + + const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); + return resolveAtReference(rawTarget, (candidate) => + isAnnotatableExistingPath(candidate, projectRoot) + ) !== null; +} + if (isVersionInvocation(args)) { console.log(formatVersion()); process.exit(0); @@ -272,6 +301,10 @@ if (isInteractiveNoArgInvocation(args, process.stdin.isTTY)) { process.exit(0); } +if (shouldAliasToAnnotate(args, isAnnotateAliasTarget)) { + args.unshift("annotate"); +} + // Ensure session cleanup on exit process.on("exit", () => unregisterSession());