From 56f6f510d412e6a67af99c94ef3b354eea6c9f2d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 26 Jun 2026 12:32:39 -0700 Subject: [PATCH 1/2] fix(cli): print per-subcommand help instead of launching the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plannotator review --help` (and other subcommands) fell through to their command branch because only top-level `--help` was handled. For `review`, `--help` was parsed as a non-URL positional, dropping into local review mode and opening a browser tab. When Claude Code probes the CLI with `--help`, that stray tab's close injects a bogus "no feedback → proceed" signal into the session. Handle `--help`/`-h` for every user-facing subcommand (review, annotate, annotate-last/last, setup-goal, archive, sessions) before any subcommand branch can run: print command-specific usage on stdout and exit 0. Also accept `-h` at the top level and advertise per-command help there. Fixes #964 --- apps/hook/server/cli.test.ts | 66 +++++++++++++++++++++ apps/hook/server/cli.ts | 112 ++++++++++++++++++++++++++++++++++- apps/hook/server/index.ts | 12 ++++ 3 files changed, 189 insertions(+), 1 deletion(-) diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index 9a2e14f8c..97013606a 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { formatInteractiveNoArgClarification, + formatSubcommandHelp, formatTopLevelHelp, formatVersion, + hasHelpFlag, isInteractiveNoArgInvocation, + isSubcommandHelpInvocation, isTopLevelHelpInvocation, isVersionInvocation, } from "./cli"; @@ -11,6 +14,7 @@ import { 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); }); @@ -26,10 +30,72 @@ describe("CLI top-level help", () => { expect(output).toContain("[--markdown] [--no-jina]"); expect(output).toContain("plannotator annotate-last [--stdin]"); expect(output).toContain("plannotator setup-goal "); + expect(output).toContain("Run 'plannotator --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 user-facing subcommand", () => { + for (const sub of ["annotate", "setup-goal", "archive", "sessions"]) { + 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); diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index ab7621e91..dab7472f0 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -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 { @@ -34,11 +41,114 @@ export function formatTopLevelHelp(): string { " plannotator sessions", " plannotator improve-context", "", + "Run 'plannotator --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 --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 = { + 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 [--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 [--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"), + 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 = { + last: "annotate-last", +}; + +/** + * Returns the canonical subcommand name when `args` is a ` ... --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.", diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index fbaf545e0..98f0c47a0 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -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"; @@ -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") { From 6e09b70d9e836e4061a59b6e3389e7fabe7621ca Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 26 Jun 2026 13:04:42 -0700 Subject: [PATCH 2/2] fix(cli): handle `improve-context --help` too The top-level help advertises `plannotator --help`, but `improve-context` (the only internal hook command listed there) had no help entry, so `improve-context --help` fell through to the hook branch and emitted additionalContext JSON instead of usage. Add a help entry so every advertised command responds to --help. --- apps/hook/server/cli.test.ts | 12 ++++++++++-- apps/hook/server/cli.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index 97013606a..2c8a8035c 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -71,8 +71,16 @@ describe("CLI subcommand help", () => { ); }); - test("covers every user-facing subcommand", () => { - for (const sub of ["annotate", "setup-goal", "archive", "sessions"]) { + test("covers every command advertised in top-level help", () => { + // Each command listed in formatTopLevelHelp() must respond to --help so the + // advertised "run 'plannotator --help'" contract holds. + for (const sub of [ + "annotate", + "setup-goal", + "archive", + "sessions", + "improve-context", + ]) { expect(isSubcommandHelpInvocation([sub, "--help"])).toBe(sub); } }); diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index dab7472f0..49591fda2 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -114,6 +114,15 @@ const SUBCOMMAND_HELP: Record = { "", "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]",