Skip to content

Commit 82acc4b

Browse files
authored
fix(cli): print per-subcommand help instead of launching the UI (#974)
* fix(cli): print per-subcommand help instead of launching the UI `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 * fix(cli): handle `improve-context --help` too The top-level help advertises `plannotator <command> --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.
1 parent 6982d99 commit 82acc4b

3 files changed

Lines changed: 206 additions & 1 deletion

File tree

apps/hook/server/cli.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { describe, expect, test } from "bun:test";
22
import {
33
formatInteractiveNoArgClarification,
4+
formatSubcommandHelp,
45
formatTopLevelHelp,
56
formatVersion,
7+
hasHelpFlag,
68
isInteractiveNoArgInvocation,
9+
isSubcommandHelpInvocation,
710
isTopLevelHelpInvocation,
811
isVersionInvocation,
912
} from "./cli";
1013

1114
describe("CLI top-level help", () => {
1215
test("recognizes top-level --help", () => {
1316
expect(isTopLevelHelpInvocation(["--help"])).toBe(true);
17+
expect(isTopLevelHelpInvocation(["-h"])).toBe(true);
1418
expect(isTopLevelHelpInvocation([])).toBe(false);
1519
expect(isTopLevelHelpInvocation(["review", "--help"])).toBe(false);
1620
});
@@ -26,10 +30,80 @@ describe("CLI top-level help", () => {
2630
expect(output).toContain("[--markdown] [--no-jina]");
2731
expect(output).toContain("plannotator annotate-last [--stdin]");
2832
expect(output).toContain("plannotator setup-goal <interview|facts>");
33+
expect(output).toContain("Run 'plannotator <command> --help' for command-specific usage.");
2934
expect(output).toContain("running 'plannotator' without arguments is for hook integration");
3035
});
3136
});
3237

38+
describe("CLI subcommand help", () => {
39+
test("hasHelpFlag detects --help / -h anywhere", () => {
40+
expect(hasHelpFlag(["--help"])).toBe(true);
41+
expect(hasHelpFlag(["-h"])).toBe(true);
42+
expect(hasHelpFlag(["file.md", "--help"])).toBe(true);
43+
expect(hasHelpFlag(["--git"])).toBe(false);
44+
expect(hasHelpFlag([])).toBe(false);
45+
});
46+
47+
test("recognizes `review --help` as a subcommand help invocation", () => {
48+
expect(isSubcommandHelpInvocation(["review", "--help"])).toBe("review");
49+
expect(isSubcommandHelpInvocation(["review", "-h"])).toBe("review");
50+
// help flag may appear after other args (agents probe in various ways)
51+
expect(isSubcommandHelpInvocation(["annotate", "file.md", "--help"])).toBe(
52+
"annotate",
53+
);
54+
});
55+
56+
test("does not treat a real review invocation as help", () => {
57+
expect(isSubcommandHelpInvocation(["review"])).toBeNull();
58+
expect(isSubcommandHelpInvocation(["review", "--git"])).toBeNull();
59+
expect(
60+
isSubcommandHelpInvocation([
61+
"review",
62+
"https://github.com/owner/repo/pull/1",
63+
]),
64+
).toBeNull();
65+
});
66+
67+
test("resolves the `last` alias to annotate-last help", () => {
68+
expect(isSubcommandHelpInvocation(["last", "--help"])).toBe("annotate-last");
69+
expect(isSubcommandHelpInvocation(["annotate-last", "--help"])).toBe(
70+
"annotate-last",
71+
);
72+
});
73+
74+
test("covers every command advertised in top-level help", () => {
75+
// Each command listed in formatTopLevelHelp() must respond to --help so the
76+
// advertised "run 'plannotator <command> --help'" contract holds.
77+
for (const sub of [
78+
"annotate",
79+
"setup-goal",
80+
"archive",
81+
"sessions",
82+
"improve-context",
83+
]) {
84+
expect(isSubcommandHelpInvocation([sub, "--help"])).toBe(sub);
85+
}
86+
});
87+
88+
test("ignores help flags for unknown / internal subcommands", () => {
89+
expect(isSubcommandHelpInvocation(["opencode-review", "--help"])).toBeNull();
90+
expect(isSubcommandHelpInvocation(["install-runtime", "--help"])).toBeNull();
91+
expect(isSubcommandHelpInvocation(["--help"])).toBeNull();
92+
expect(isSubcommandHelpInvocation([])).toBeNull();
93+
});
94+
95+
test("renders subcommand-specific usage", () => {
96+
expect(formatSubcommandHelp("review")).toContain(
97+
"plannotator review [--git]",
98+
);
99+
expect(formatSubcommandHelp("review")).toContain("PR_URL");
100+
expect(formatSubcommandHelp("annotate")).toContain("--no-jina");
101+
expect(formatSubcommandHelp("sessions")).toContain("--open [N]");
102+
// unknown key falls back to top-level help
103+
expect(formatSubcommandHelp("nope")).toBe(formatTopLevelHelp());
104+
});
105+
});
106+
33107
describe("CLI --version", () => {
34108
test("recognizes --version and -v", () => {
35109
expect(isVersionInvocation(["--version"])).toBe(true);

apps/hook/server/cli.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
const HELP_FLAGS = new Set(["--help", "-h"]);
2+
3+
/** True when any token is a help flag (`--help` / `-h`). */
4+
export function hasHelpFlag(args: string[]): boolean {
5+
return args.some((arg) => HELP_FLAGS.has(arg));
6+
}
7+
18
export function isTopLevelHelpInvocation(args: string[]): boolean {
2-
return args[0] === "--help";
9+
return args.length > 0 && HELP_FLAGS.has(args[0]);
310
}
411

512
export function isVersionInvocation(args: string[]): boolean {
@@ -34,11 +41,123 @@ export function formatTopLevelHelp(): string {
3441
" plannotator sessions",
3542
" plannotator improve-context",
3643
"",
44+
"Run 'plannotator <command> --help' for command-specific usage.",
45+
"",
3746
"Note:",
3847
" running 'plannotator' without arguments is for hook integration and expects JSON on stdin",
3948
].join("\n");
4049
}
4150

51+
// Per-subcommand usage text. Keyed by the canonical subcommand token; aliases
52+
// (e.g. `last` → `annotate-last`) are resolved in formatSubcommandHelp().
53+
//
54+
// These exist so an agent (or human) probing `plannotator <sub> --help` gets
55+
// usage on stdout instead of accidentally launching the browser UI — running
56+
// `review --help` used to fall through to local review mode and open a tab.
57+
const SUBCOMMAND_HELP: Record<string, string> = {
58+
review: [
59+
"Usage:",
60+
" plannotator review [--git] [--local | --no-local] [PR_URL]",
61+
"",
62+
"Review local VCS changes (git/jj) or a GitHub/GitLab pull request in the browser.",
63+
"",
64+
"Options:",
65+
" --git Force git as the VCS (skip auto-detection)",
66+
" --local For PR review, prepare a local checkout for full file access (default)",
67+
" --no-local For PR review, skip the local checkout (diff only)",
68+
" PR_URL GitHub PR or GitLab MR URL to review",
69+
"",
70+
"Examples:",
71+
" plannotator review",
72+
" plannotator review --git",
73+
" plannotator review https://github.com/owner/repo/pull/123",
74+
].join("\n"),
75+
annotate: [
76+
"Usage:",
77+
" plannotator annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--gate] [--json] [--hook]",
78+
"",
79+
"Open a markdown/text/HTML file, a URL, or a folder of documents in the annotation UI.",
80+
"",
81+
"Options:",
82+
" --markdown Convert HTML input to markdown instead of rendering it raw",
83+
" --no-jina Fetch URLs with fetch+Turndown instead of Jina Reader",
84+
" --gate Add an Approve button (review-gate UX)",
85+
" --json Emit a structured decision JSON on stdout",
86+
" --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks",
87+
].join("\n"),
88+
"annotate-last": [
89+
"Usage:",
90+
" plannotator annotate-last [--stdin] [--gate] [--json] [--hook]",
91+
" plannotator last [--stdin] [--gate] [--json] [--hook]",
92+
"",
93+
"Annotate the last assistant message from the current agent session.",
94+
"",
95+
"Options:",
96+
" --stdin Read the message content from stdin instead of session logs",
97+
" --gate Add an Approve button (review-gate UX)",
98+
" --json Emit a structured decision JSON on stdout",
99+
" --hook Emit hook-native JSON (block/pass) for PostToolUse/Stop hooks",
100+
].join("\n"),
101+
"setup-goal": [
102+
"Usage:",
103+
" plannotator setup-goal <interview|facts> <bundle.json | -> [--json]",
104+
"",
105+
"Open the goal-setup question (interview) or facts-acceptance UI for /goal workflows.",
106+
"Pass '-' to read the bundle JSON from stdin.",
107+
"",
108+
"Options:",
109+
" --json Emit compact JSON instead of pretty-printed output",
110+
].join("\n"),
111+
archive: [
112+
"Usage:",
113+
" plannotator archive",
114+
"",
115+
"Open a read-only browser for saved plan decisions in ~/.plannotator/plans/.",
116+
].join("\n"),
117+
"improve-context": [
118+
"Usage:",
119+
" plannotator improve-context",
120+
"",
121+
"Hook-integration command spawned by the PreToolUse hook on EnterPlanMode.",
122+
"Reads the hook event on stdin and emits additionalContext JSON (PFM reminder",
123+
"and/or compound improvement hook), or exits silently when nothing is enabled.",
124+
"Not intended to be run directly.",
125+
].join("\n"),
126+
sessions: [
127+
"Usage:",
128+
" plannotator sessions [--open [N]] [--clean]",
129+
"",
130+
"List active Plannotator server sessions.",
131+
"",
132+
"Options:",
133+
" --open [N] Reopen session #N (default 1) in the browser",
134+
" --clean Remove stale session entries",
135+
].join("\n"),
136+
};
137+
138+
// Aliases share another subcommand's help text.
139+
const SUBCOMMAND_HELP_ALIASES: Record<string, string> = {
140+
last: "annotate-last",
141+
};
142+
143+
/**
144+
* Returns the canonical subcommand name when `args` is a `<sub> ... --help`
145+
* invocation for a user-facing subcommand, or null otherwise. Lets the CLI
146+
* print usage and exit before a subcommand branch can launch the UI.
147+
*/
148+
export function isSubcommandHelpInvocation(args: string[]): string | null {
149+
const sub = args[0];
150+
if (!sub) return null;
151+
const canonical = SUBCOMMAND_HELP_ALIASES[sub] ?? sub;
152+
if (!(canonical in SUBCOMMAND_HELP)) return null;
153+
return hasHelpFlag(args.slice(1)) ? canonical : null;
154+
}
155+
156+
/** Usage text for a canonical subcommand (falls back to top-level help). */
157+
export function formatSubcommandHelp(subcommand: string): string {
158+
return SUBCOMMAND_HELP[subcommand] ?? formatTopLevelHelp();
159+
}
160+
42161
export function formatInteractiveNoArgClarification(): string {
43162
return [
44163
"plannotator (without arguments) is usually launched automatically by Claude Code hooks.",

apps/hook/server/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,11 @@ import { findCodexRolloutByThreadId, getLatestCodexPlan, getRecentCodexMessages
131131
import { findCopilotPlanContent, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session";
132132
import {
133133
formatInteractiveNoArgClarification,
134+
formatSubcommandHelp,
134135
formatTopLevelHelp,
135136
formatVersion,
136137
isInteractiveNoArgInvocation,
138+
isSubcommandHelpInvocation,
137139
isTopLevelHelpInvocation,
138140
isVersionInvocation,
139141
} from "./cli";
@@ -256,6 +258,16 @@ if (isTopLevelHelpInvocation(args)) {
256258
process.exit(0);
257259
}
258260

261+
// Per-subcommand help must be handled before the subcommand branches below —
262+
// otherwise `plannotator review --help` (commonly run by agents probing the
263+
// CLI) falls through to local review mode and launches the browser UI,
264+
// spawning a stray tab whose close injects a bogus "no feedback" signal.
265+
const helpSubcommand = isSubcommandHelpInvocation(args);
266+
if (helpSubcommand) {
267+
console.log(formatSubcommandHelp(helpSubcommand));
268+
process.exit(0);
269+
}
270+
259271
if (args[0] === "install-runtime") {
260272
const runtime = args[1];
261273
if (runtime !== "agent-terminal") {

0 commit comments

Comments
 (0)