Skip to content
Open
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
56 changes: 56 additions & 0 deletions apps/hook/server/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
formatTopLevelHelp,
formatVersion,
isInteractiveNoArgInvocation,
isReservedTopLevelCommand,
isTopLevelHelpInvocation,
isVersionInvocation,
shouldAliasToAnnotate,
} from "./cli";

describe("CLI top-level help", () => {
Expand Down Expand Up @@ -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);
});
});
38 changes: 38 additions & 0 deletions apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
33 changes: 33 additions & 0 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import {
isInteractiveNoArgInvocation,
isTopLevelHelpInvocation,
isVersionInvocation,
shouldAliasToAnnotate,
} from "./cli";
import path from "path";
import { tmpdir } from "os";
Expand Down Expand Up @@ -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);
Expand All @@ -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());

Expand Down