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
74 changes: 74 additions & 0 deletions apps/hook/server/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { describe, expect, test } from "bun:test";
import {
formatInteractiveNoArgClarification,
formatSubcommandHelp,
formatTopLevelHelp,
formatVersion,
hasHelpFlag,
isInteractiveNoArgInvocation,
isSubcommandHelpInvocation,
isTopLevelHelpInvocation,
isVersionInvocation,
} from "./cli";

describe("CLI top-level help", () => {
test("recognizes top-level --help", () => {
expect(isTopLevelHelpInvocation(["--help"])).toBe(true);
expect(isTopLevelHelpInvocation(["-h"])).toBe(true);
expect(isTopLevelHelpInvocation([])).toBe(false);
expect(isTopLevelHelpInvocation(["review", "--help"])).toBe(false);
});
Expand All @@ -26,10 +30,80 @@ describe("CLI top-level help", () => {
expect(output).toContain("[--markdown] [--no-jina]");
expect(output).toContain("plannotator annotate-last [--stdin]");
expect(output).toContain("plannotator setup-goal <interview|facts>");
expect(output).toContain("Run 'plannotator <command> --help' for command-specific usage.");
expect(output).toContain("running 'plannotator' without arguments is for hook integration");
});
});

describe("CLI subcommand help", () => {
test("hasHelpFlag detects --help / -h anywhere", () => {
expect(hasHelpFlag(["--help"])).toBe(true);
expect(hasHelpFlag(["-h"])).toBe(true);
expect(hasHelpFlag(["file.md", "--help"])).toBe(true);
expect(hasHelpFlag(["--git"])).toBe(false);
expect(hasHelpFlag([])).toBe(false);
});

test("recognizes `review --help` as a subcommand help invocation", () => {
expect(isSubcommandHelpInvocation(["review", "--help"])).toBe("review");
expect(isSubcommandHelpInvocation(["review", "-h"])).toBe("review");
// help flag may appear after other args (agents probe in various ways)
expect(isSubcommandHelpInvocation(["annotate", "file.md", "--help"])).toBe(
"annotate",
);
});

test("does not treat a real review invocation as help", () => {
expect(isSubcommandHelpInvocation(["review"])).toBeNull();
expect(isSubcommandHelpInvocation(["review", "--git"])).toBeNull();
expect(
isSubcommandHelpInvocation([
"review",
"https://github.com/owner/repo/pull/1",
]),
).toBeNull();
});

test("resolves the `last` alias to annotate-last help", () => {
expect(isSubcommandHelpInvocation(["last", "--help"])).toBe("annotate-last");
expect(isSubcommandHelpInvocation(["annotate-last", "--help"])).toBe(
"annotate-last",
);
});

test("covers every command advertised in top-level help", () => {
// Each command listed in formatTopLevelHelp() must respond to --help so the
// advertised "run 'plannotator <command> --help'" contract holds.
for (const sub of [
"annotate",
"setup-goal",
"archive",
"sessions",
"improve-context",
]) {
expect(isSubcommandHelpInvocation([sub, "--help"])).toBe(sub);
}
});

test("ignores help flags for unknown / internal subcommands", () => {
expect(isSubcommandHelpInvocation(["opencode-review", "--help"])).toBeNull();
expect(isSubcommandHelpInvocation(["install-runtime", "--help"])).toBeNull();
expect(isSubcommandHelpInvocation(["--help"])).toBeNull();
expect(isSubcommandHelpInvocation([])).toBeNull();
});

test("renders subcommand-specific usage", () => {
expect(formatSubcommandHelp("review")).toContain(
"plannotator review [--git]",
);
expect(formatSubcommandHelp("review")).toContain("PR_URL");
expect(formatSubcommandHelp("annotate")).toContain("--no-jina");
expect(formatSubcommandHelp("sessions")).toContain("--open [N]");
// unknown key falls back to top-level help
expect(formatSubcommandHelp("nope")).toBe(formatTopLevelHelp());
});
});

describe("CLI --version", () => {
test("recognizes --version and -v", () => {
expect(isVersionInvocation(["--version"])).toBe(true);
Expand Down
121 changes: 120 additions & 1 deletion apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
const HELP_FLAGS = new Set(["--help", "-h"]);

/** True when any token is a help flag (`--help` / `-h`). */
export function hasHelpFlag(args: string[]): boolean {
return args.some((arg) => HELP_FLAGS.has(arg));
}

export function isTopLevelHelpInvocation(args: string[]): boolean {
return args[0] === "--help";
return args.length > 0 && HELP_FLAGS.has(args[0]);
}

export function isVersionInvocation(args: string[]): boolean {
Expand Down Expand Up @@ -34,11 +41,123 @@ export function formatTopLevelHelp(): string {
" plannotator sessions",
" plannotator improve-context",
"",
"Run 'plannotator <command> --help' for command-specific usage.",
"",
"Note:",
" running 'plannotator' without arguments is for hook integration and expects JSON on stdin",
].join("\n");
}

// Per-subcommand usage text. Keyed by the canonical subcommand token; aliases
// (e.g. `last` → `annotate-last`) are resolved in formatSubcommandHelp().
//
// These exist so an agent (or human) probing `plannotator <sub> --help` gets
// usage on stdout instead of accidentally launching the browser UI — running
// `review --help` used to fall through to local review mode and open a tab.
const SUBCOMMAND_HELP: Record<string, string> = {
review: [
"Usage:",
" plannotator review [--git] [--local | --no-local] [PR_URL]",
"",
"Review local VCS changes (git/jj) or a GitHub/GitLab pull request in the browser.",
"",
"Options:",
" --git Force git as the VCS (skip auto-detection)",
" --local For PR review, prepare a local checkout for full file access (default)",
" --no-local For PR review, skip the local checkout (diff only)",
" PR_URL GitHub PR or GitLab MR URL to review",
"",
"Examples:",
" plannotator review",
" plannotator review --git",
" plannotator review https://github.com/owner/repo/pull/123",
].join("\n"),
annotate: [
"Usage:",
" plannotator annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--gate] [--json] [--hook]",
"",
"Open a markdown/text/HTML file, a URL, or a folder of documents in the annotation UI.",
"",
"Options:",
" --markdown Convert HTML input to markdown instead of rendering it raw",
" --no-jina Fetch URLs with fetch+Turndown instead of Jina Reader",
" --gate Add an Approve button (review-gate UX)",
" --json Emit a structured decision JSON on stdout",
" --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks",
].join("\n"),
"annotate-last": [
"Usage:",
" plannotator annotate-last [--stdin] [--gate] [--json] [--hook]",
" plannotator last [--stdin] [--gate] [--json] [--hook]",
"",
"Annotate the last assistant message from the current agent session.",
"",
"Options:",
" --stdin Read the message content from stdin instead of session logs",
" --gate Add an Approve button (review-gate UX)",
" --json Emit a structured decision JSON on stdout",
" --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks",
].join("\n"),
"setup-goal": [
"Usage:",
" plannotator setup-goal <interview|facts> <bundle.json | -> [--json]",
"",
"Open the goal-setup question (interview) or facts-acceptance UI for /goal workflows.",
"Pass '-' to read the bundle JSON from stdin.",
"",
"Options:",
" --json Emit compact JSON instead of pretty-printed output",
].join("\n"),
archive: [
"Usage:",
" plannotator archive",
"",
"Open a read-only browser for saved plan decisions in ~/.plannotator/plans/.",
].join("\n"),
"improve-context": [
"Usage:",
" plannotator improve-context",
"",
"Hook-integration command spawned by the PreToolUse hook on EnterPlanMode.",
"Reads the hook event on stdin and emits additionalContext JSON (PFM reminder",
"and/or compound improvement hook), or exits silently when nothing is enabled.",
"Not intended to be run directly.",
].join("\n"),
sessions: [
"Usage:",
" plannotator sessions [--open [N]] [--clean]",
"",
"List active Plannotator server sessions.",
"",
"Options:",
" --open [N] Reopen session #N (default 1) in the browser",
" --clean Remove stale session entries",
].join("\n"),
};

// Aliases share another subcommand's help text.
const SUBCOMMAND_HELP_ALIASES: Record<string, string> = {
last: "annotate-last",
};

/**
* Returns the canonical subcommand name when `args` is a `<sub> ... --help`
* invocation for a user-facing subcommand, or null otherwise. Lets the CLI
* print usage and exit before a subcommand branch can launch the UI.
*/
export function isSubcommandHelpInvocation(args: string[]): string | null {
const sub = args[0];
if (!sub) return null;
const canonical = SUBCOMMAND_HELP_ALIASES[sub] ?? sub;
if (!(canonical in SUBCOMMAND_HELP)) return null;
return hasHelpFlag(args.slice(1)) ? canonical : null;
}

/** Usage text for a canonical subcommand (falls back to top-level help). */
export function formatSubcommandHelp(subcommand: string): string {
return SUBCOMMAND_HELP[subcommand] ?? formatTopLevelHelp();
}

export function formatInteractiveNoArgClarification(): string {
return [
"plannotator (without arguments) is usually launched automatically by Claude Code hooks.",
Expand Down
12 changes: 12 additions & 0 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,11 @@ import { findCodexRolloutByThreadId, getLatestCodexPlan, getRecentCodexMessages
import { findCopilotPlanContent, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session";
import {
formatInteractiveNoArgClarification,
formatSubcommandHelp,
formatTopLevelHelp,
formatVersion,
isInteractiveNoArgInvocation,
isSubcommandHelpInvocation,
isTopLevelHelpInvocation,
isVersionInvocation,
} from "./cli";
Expand Down Expand Up @@ -256,6 +258,16 @@ if (isTopLevelHelpInvocation(args)) {
process.exit(0);
}

// Per-subcommand help must be handled before the subcommand branches below —
// otherwise `plannotator review --help` (commonly run by agents probing the
// CLI) falls through to local review mode and launches the browser UI,
// spawning a stray tab whose close injects a bogus "no feedback" signal.
const helpSubcommand = isSubcommandHelpInvocation(args);
if (helpSubcommand) {
console.log(formatSubcommandHelp(helpSubcommand));
process.exit(0);
}

if (args[0] === "install-runtime") {
const runtime = args[1];
if (runtime !== "agent-terminal") {
Expand Down