From 8deffb5d1c91862cdd05ec3be8b8a374e9de3357 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 26 Aug 2026 08:26:40 -0400 Subject: [PATCH 1/2] refactor(cli): split session command dispatch --- .changeset/simplify-session-cli-dispatch.md | 2 + src/app/cli.test.ts | 27 + src/app/cli.ts | 837 +++++++++++--------- 3 files changed, 480 insertions(+), 386 deletions(-) create mode 100644 .changeset/simplify-session-cli-dispatch.md diff --git a/.changeset/simplify-session-cli-dispatch.md b/.changeset/simplify-session-cli-dispatch.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/simplify-session-cli-dispatch.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index c3f3dcf53..348274d11 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -494,6 +494,15 @@ describe("parseCli", () => { }); }); + test("parses session context by direct session id", async () => { + expect(await parseCli(["bun", "hunk", "session", "context", "session-1", "--json"])).toEqual({ + kind: "session", + action: "context", + selector: { sessionId: "session-1" }, + output: "json", + }); + }); + test("keeps --repo provider-neutral while canonicalizing the selected subdirectory", async () => { const repoRoot = realpathSync.native(createTempDir("hunk-cli-repo-")); mkdirSync(join(repoRoot, ".git")); @@ -1405,6 +1414,12 @@ describe("parseCli command help text", () => { test("renders help for each session subcommand", async () => { expect(await expectHelp(["session", "list", "--help"])).toContain("list live Hunk sessions"); expect(await expectHelp(["session", "get", "--help"])).toContain("show one live Hunk session"); + expect(await expectHelp(["session", "context", "--help"])).toContain( + "show the selected file and hunk", + ); + expect(await expectHelp(["session", "review", "--help"])).toContain( + "export the live review model", + ); expect(await expectHelp(["session", "navigate", "--help"])).toContain( "move a live Hunk session to one diff hunk", ); @@ -1443,6 +1458,18 @@ describe("parseCli command help text", () => { "clear inline review notes", ); }); + + test("renders the highlight overview and per-highlight-subcommand help", async () => { + const overview = await expectHelp(["session", "highlight"]); + expect(overview).toContain("hunk session highlight add"); + expect(overview).toBe(await expectHelp(["session", "highlight", "--help"])); + expect(await expectHelp(["session", "highlight", "add", "--help"])).toContain( + "paint one attention mark", + ); + expect(await expectHelp(["session", "highlight", "clear", "--help"])).toContain( + "clear agent attention marks", + ); + }); }); describe("parseCli argument validation", () => { diff --git a/src/app/cli.ts b/src/app/cli.ts index f761b7e97..dfd0b76db 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -980,469 +980,534 @@ function sessionUsageLines(specs: readonly AgentCommandSpec[]) { return specs.flatMap((spec) => spec.synopsis.map((line) => ` ${line}`)); } -/** Parse `hunk session ...` as live-session daemon-backed commands. */ -async function parseSessionCommand(tokens: string[]): Promise { - const [subcommand, ...rest] = tokens; - if (!subcommand || subcommand === "--help" || subcommand === "-h") { - return { - kind: "help", - text: - [ - "Usage: hunk session [options]", - "", - "Inspect and control live Hunk review sessions through the local daemon.", - "", - "Commands:", - ...sessionUsageLines(SESSION_AGENT_COMMAND_LIST), - ].join("\n") + "\n", - }; - } +/** Return whether one session command token list requests help. */ +function hasSessionHelpFlag(tokens: readonly string[]) { + return tokens.includes("--help") || tokens.includes("-h"); +} - if (subcommand === "list") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS.list); - let parsedOptions: SessionCommandOptions<"list"> = {}; +/** Render the top-level session command overview. */ +function sessionOverviewHelp(): HelpCommandInput { + return { + kind: "help", + text: + [ + "Usage: hunk session [options]", + "", + "Inspect and control live Hunk review sessions through the local daemon.", + "", + "Commands:", + ...sessionUsageLines(SESSION_AGENT_COMMAND_LIST), + ].join("\n") + "\n", + }; +} - command.action((options: SessionCommandOptions<"list">) => { - parsedOptions = options; - }); +/** Parse `hunk session list`. */ +async function parseSessionListCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS.list; + const command = buildSessionCommand(spec); + let parsedOptions: SessionCommandOptions<"list"> = {}; - if (rest.includes("--help") || rest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS.list); - } + command.action((options: SessionCommandOptions<"list">) => { + parsedOptions = options; + }); - await parseStandaloneCommand(command, rest); - return { - kind: "session", - action: "list", - output: resolveJsonOutput(parsedOptions), - }; + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); } - if (subcommand === "get" || subcommand === "context" || subcommand === "review") { - const spec = SESSION_AGENT_COMMANDS[subcommand]; - const command = buildSessionCommand(spec); - - let parsedSessionId: string | undefined; - // Review's parsed shape is a strict superset of get/context, so it types the shared branch. - let parsedOptions: SessionCommandOptions<"review"> = {}; - - command.action((sessionId: string | undefined, options: SessionCommandOptions<"review">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); - - if (rest.includes("--help") || rest.includes("-h")) { - return sessionCommandHelpText(command, spec); - } + await parseStandaloneCommand(command, tokens); + return { + kind: "session", + action: "list", + output: resolveJsonOutput(parsedOptions), + }; +} - await parseStandaloneCommand(command, rest); - if (subcommand === "review") { - return { - kind: "session", - action: "review", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - includePatch: parsedOptions.includePatch ?? false, - includeNotes: parsedOptions.includeNotes ?? false, - }; - } +/** Parse a session get or context command with their shared selector shape. */ +async function parseSessionReadCommand( + action: "get" | "context", + tokens: string[], +): Promise { + const spec = SESSION_AGENT_COMMANDS[action]; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"get"> = {}; + + command.action((sessionId: string | undefined, options: SessionCommandOptions<"get">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); - return { - kind: "session", - action: subcommand, - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - }; + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); } - if (subcommand === "navigate") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS.navigate); + await parseStandaloneCommand(command, tokens); + return { + kind: "session", + action, + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + }; +} - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"navigate"> = {}; +/** Parse `hunk session review`. */ +async function parseSessionReviewCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS.review; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"review"> = {}; - command.action((sessionId: string | undefined, options: SessionCommandOptions<"navigate">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); + command.action((sessionId: string | undefined, options: SessionCommandOptions<"review">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); - if (rest.includes("--help") || rest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS.navigate); - } + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - await parseStandaloneCommand(command, rest); + await parseStandaloneCommand(command, tokens); + return { + kind: "session", + action: "review", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + includePatch: parsedOptions.includePatch ?? false, + includeNotes: parsedOptions.includeNotes ?? false, + }; +} - /** Relative comment navigation mode. */ - if (parsedOptions.nextComment || parsedOptions.prevComment) { - enforceConstraint(COMMENT_DIRECTION_CONSTRAINT, parsedOptions); +/** Parse `hunk session navigate`. */ +async function parseSessionNavigateCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS.navigate; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"navigate"> = {}; - return { - kind: "session", - action: "navigate", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - commentDirection: parsedOptions.nextComment ? "next" : "prev", - } as const; - } + command.action((sessionId: string | undefined, options: SessionCommandOptions<"navigate">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); - /** Absolute navigation mode requires --file and a target. */ - if (!parsedOptions.file) { - throw new Error( - "Specify --file with a navigation target, or use --next-comment / --prev-comment.", - ); - } + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - enforceConstraint(NAVIGATE_TARGET_CONSTRAINT, parsedOptions); + await parseStandaloneCommand(command, tokens); + if (parsedOptions.nextComment || parsedOptions.prevComment) { + enforceConstraint(COMMENT_DIRECTION_CONSTRAINT, parsedOptions); return { kind: "session", action: "navigate", output: resolveJsonOutput(parsedOptions), selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - hunkNumber: parsedOptions.hunk, - side: - parsedOptions.oldLine !== undefined - ? "old" - : parsedOptions.newLine !== undefined - ? "new" - : undefined, - line: parsedOptions.oldLine ?? parsedOptions.newLine, - }; + commentDirection: parsedOptions.nextComment ? "next" : "prev", + } as const; } - if (subcommand === "reload") { - const separatorIndex = rest.indexOf("--"); - const outerTokens = separatorIndex === -1 ? rest : rest.slice(0, separatorIndex); - - const command = buildSessionCommand(SESSION_AGENT_COMMANDS.reload); - - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"reload"> = {}; - - command.action((sessionId: string | undefined, options: SessionCommandOptions<"reload">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); - - if (outerTokens.includes("--help") || outerTokens.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS.reload); - } - - if (separatorIndex === -1) { - throw new Error(RELOAD_SEPARATOR_MESSAGE); - } - - const nestedTokens = rest.slice(separatorIndex + 1); - if (nestedTokens.length === 0) { - throw new Error(RELOAD_SEPARATOR_MESSAGE); - } - - await parseStandaloneCommand(command, outerTokens); - const nextInput = requireReloadableCliInput(await parseCli(["bun", "hunk", ...nestedTokens])); - const resolvedReload = resolveReloadSelector( - parsedSessionId, - parsedOptions.sessionPath, - parsedOptions.repo, - parsedOptions.source, + if (!parsedOptions.file) { + throw new Error( + "Specify --file with a navigation target, or use --next-comment / --prev-comment.", ); - - return { - kind: "session", - action: "reload", - output: resolveJsonOutput(parsedOptions), - selector: resolvedReload.selector, - sourcePath: resolvedReload.sourcePath, - nextInput, - }; } - if (subcommand === "comment") { - const [commentSubcommand, ...commentRest] = rest; - if (!commentSubcommand || commentSubcommand === "--help" || commentSubcommand === "-h") { - return { - kind: "help", - text: ["Usage:", ...sessionUsageLines(SESSION_COMMENT_COMMAND_LIST)].join("\n") + "\n", - }; - } - - if (commentSubcommand === "add") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS["comment-add"]); - - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"comment-add"> = { - file: "", - summary: "", - }; - - command.action( - (sessionId: string | undefined, options: SessionCommandOptions<"comment-add">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }, - ); - - if (commentRest.includes("--help") || commentRest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["comment-add"]); - } + enforceConstraint(NAVIGATE_TARGET_CONSTRAINT, parsedOptions); + return { + kind: "session", + action: "navigate", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + hunkNumber: parsedOptions.hunk, + side: + parsedOptions.oldLine !== undefined + ? "old" + : parsedOptions.newLine !== undefined + ? "new" + : undefined, + line: parsedOptions.oldLine ?? parsedOptions.newLine, + }; +} - await parseStandaloneCommand(command, commentRest); +/** Parse `hunk session reload`, keeping nested command tokens isolated after `--`. */ +async function parseSessionReloadCommand(tokens: string[]): Promise { + const separatorIndex = tokens.indexOf("--"); + const outerTokens = separatorIndex === -1 ? tokens : tokens.slice(0, separatorIndex); + const spec = SESSION_AGENT_COMMANDS.reload; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"reload"> = {}; + + command.action((sessionId: string | undefined, options: SessionCommandOptions<"reload">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); - enforceConstraint(COMMENT_TARGET_CONSTRAINT, parsedOptions); + if (hasSessionHelpFlag(outerTokens)) { + return sessionCommandHelpText(command, spec); + } - return { - kind: "session", - action: "comment-add", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - side: parsedOptions.oldLine !== undefined ? "old" : "new", - line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, - summary: parsedOptions.summary, - rationale: parsedOptions.rationale, - markup: parsedOptions.markup, - author: parsedOptions.author, - reveal: parsedOptions.focus ?? false, - }; - } + if (separatorIndex === -1) { + throw new Error(RELOAD_SEPARATOR_MESSAGE); + } - if (commentSubcommand === "apply") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS["comment-apply"]); + const nestedTokens = tokens.slice(separatorIndex + 1); + if (nestedTokens.length === 0) { + throw new Error(RELOAD_SEPARATOR_MESSAGE); + } - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"comment-apply"> = {}; + await parseStandaloneCommand(command, outerTokens); + const nextInput = requireReloadableCliInput(await parseCli(["bun", "hunk", ...nestedTokens])); + const resolvedReload = resolveReloadSelector( + parsedSessionId, + parsedOptions.sessionPath, + parsedOptions.repo, + parsedOptions.source, + ); - command.action( - (sessionId: string | undefined, options: SessionCommandOptions<"comment-apply">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }, - ); + return { + kind: "session", + action: "reload", + output: resolveJsonOutput(parsedOptions), + selector: resolvedReload.selector, + sourcePath: resolvedReload.sourcePath, + nextInput, + }; +} - if (commentRest.includes("--help") || commentRest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["comment-apply"]); - } +/** Parse `hunk session comment add`. */ +async function parseSessionCommentAddCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS["comment-add"]; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"comment-add"> = { + file: "", + summary: "", + }; - await parseStandaloneCommand(command, commentRest); - if (!parsedOptions.stdin) { - throw new Error(COMMENT_APPLY_STDIN_MESSAGE); - } + command.action((sessionId: string | undefined, options: SessionCommandOptions<"comment-add">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); - const comments = parseSessionCommentApplyPayload( - await new Response(Bun.stdin.stream()).text(), - ); + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - return { - kind: "session", - action: "comment-apply", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - comments, - revealMode: parsedOptions.focus ? "first" : "none", - }; - } + await parseStandaloneCommand(command, tokens); + enforceConstraint(COMMENT_TARGET_CONSTRAINT, parsedOptions); - if (commentSubcommand === "list") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS["comment-list"]); + return { + kind: "session", + action: "comment-add", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + side: parsedOptions.oldLine !== undefined ? "old" : "new", + line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, + summary: parsedOptions.summary, + rationale: parsedOptions.rationale, + markup: parsedOptions.markup, + author: parsedOptions.author, + reveal: parsedOptions.focus ?? false, + }; +} - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"comment-list"> = {}; +/** Parse `hunk session comment apply`, reading stdin only after option validation. */ +async function parseSessionCommentApplyCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS["comment-apply"]; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"comment-apply"> = {}; - command.action( - (sessionId: string | undefined, options: SessionCommandOptions<"comment-list">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }, - ); + command.action( + (sessionId: string | undefined, options: SessionCommandOptions<"comment-apply">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); - if (commentRest.includes("--help") || commentRest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["comment-list"]); - } - - await parseStandaloneCommand(command, commentRest); - if ( - parsedOptions.type !== undefined && - parsedOptions.type !== "live" && - parsedOptions.type !== "all" && - parsedOptions.type !== "ai" && - parsedOptions.type !== "agent" && - parsedOptions.type !== "user" - ) { - throw new Error("Comment type must be one of live, all, ai, agent, or user."); - } + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - return { - kind: "session", - action: "comment-list", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - ...(parsedOptions.type ? { type: parsedOptions.type as SessionCommentListType } : {}), - }; - } + await parseStandaloneCommand(command, tokens); + if (!parsedOptions.stdin) { + throw new Error(COMMENT_APPLY_STDIN_MESSAGE); + } - if (commentSubcommand === "rm") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS["comment-rm"]); + const comments = parseSessionCommentApplyPayload(await new Response(Bun.stdin.stream()).text()); + return { + kind: "session", + action: "comment-apply", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + comments, + revealMode: parsedOptions.focus ? "first" : "none", + }; +} - let parsedTargets: string[] = []; - let parsedOptions: SessionCommandOptions<"comment-rm"> = {}; +/** Parse `hunk session comment list`. */ +async function parseSessionCommentListCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS["comment-list"]; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"comment-list"> = {}; - command.action((targets: string[], options: SessionCommandOptions<"comment-rm">) => { - parsedTargets = targets; - parsedOptions = options; - }); + command.action( + (sessionId: string | undefined, options: SessionCommandOptions<"comment-list">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); - if (commentRest.includes("--help") || commentRest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["comment-rm"]); - } + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - await parseStandaloneCommand(command, commentRest); + await parseStandaloneCommand(command, tokens); + if ( + parsedOptions.type !== undefined && + parsedOptions.type !== "live" && + parsedOptions.type !== "all" && + parsedOptions.type !== "ai" && + parsedOptions.type !== "agent" && + parsedOptions.type !== "user" + ) { + throw new Error("Comment type must be one of live, all, ai, agent, or user."); + } - const expectedTargetCount = parsedOptions.repo ? 1 : 2; - if (parsedTargets.length !== expectedTargetCount) { - throw new Error( - parsedOptions.repo - ? "Specify exactly one comment id with --repo ." - : "Specify a session id and comment id, or pass --repo with one comment id.", - ); - } + return { + kind: "session", + action: "comment-list", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + ...(parsedOptions.type ? { type: parsedOptions.type as SessionCommentListType } : {}), + }; +} - const parsedSessionId = parsedOptions.repo ? undefined : parsedTargets[0]; - const parsedCommentId = parsedOptions.repo ? parsedTargets[0] : parsedTargets[1]; +/** Parse `hunk session comment rm`. */ +async function parseSessionCommentRmCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS["comment-rm"]; + const command = buildSessionCommand(spec); + let parsedTargets: string[] = []; + let parsedOptions: SessionCommandOptions<"comment-rm"> = {}; - return { - kind: "session", - action: "comment-rm", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - commentId: parsedCommentId ?? "", - }; - } + command.action((targets: string[], options: SessionCommandOptions<"comment-rm">) => { + parsedTargets = targets; + parsedOptions = options; + }); - if (commentSubcommand === "clear") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS["comment-clear"]); + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"comment-clear"> = {}; + await parseStandaloneCommand(command, tokens); + const expectedTargetCount = parsedOptions.repo ? 1 : 2; + if (parsedTargets.length !== expectedTargetCount) { + throw new Error( + parsedOptions.repo + ? "Specify exactly one comment id with --repo ." + : "Specify a session id and comment id, or pass --repo with one comment id.", + ); + } - command.action( - (sessionId: string | undefined, options: SessionCommandOptions<"comment-clear">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }, - ); + const parsedSessionId = parsedOptions.repo ? undefined : parsedTargets[0]; + const parsedCommentId = parsedOptions.repo ? parsedTargets[0] : parsedTargets[1]; + return { + kind: "session", + action: "comment-rm", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + commentId: parsedCommentId ?? "", + }; +} - if (commentRest.includes("--help") || commentRest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["comment-clear"]); - } +/** Parse `hunk session comment clear`. */ +async function parseSessionCommentClearCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS["comment-clear"]; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"comment-clear"> = {}; - await parseStandaloneCommand(command, commentRest); - if (!parsedOptions.yes) { - throw new Error("Pass --yes to clear comments."); - } + command.action( + (sessionId: string | undefined, options: SessionCommandOptions<"comment-clear">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); - return { - kind: "session", - action: "comment-clear", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - ...(parsedOptions.includeUser || parsedOptions.all ? { includeUser: true } : {}), - confirmed: true, - }; - } + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - throw new Error("Supported comment subcommands are add, apply, list, rm, and clear."); + await parseStandaloneCommand(command, tokens); + if (!parsedOptions.yes) { + throw new Error("Pass --yes to clear comments."); } - if (subcommand === "highlight") { - const [highlightSubcommand, ...highlightRest] = rest; - if (!highlightSubcommand || highlightSubcommand === "--help" || highlightSubcommand === "-h") { - return { - kind: "help", - text: ["Usage:", ...sessionUsageLines(SESSION_HIGHLIGHT_COMMAND_LIST)].join("\n") + "\n", - }; - } + return { + kind: "session", + action: "comment-clear", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + ...(parsedOptions.includeUser || parsedOptions.all ? { includeUser: true } : {}), + confirmed: true, + }; +} - if (highlightSubcommand === "add") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS["highlight-add"]); +/** Dispatch one command under the session comment namespace. */ +function parseSessionCommentCommand(tokens: string[]): Promise | ParsedCliInput { + const [subcommand, ...rest] = tokens; + if (!subcommand || subcommand === "--help" || subcommand === "-h") { + return { + kind: "help", + text: ["Usage:", ...sessionUsageLines(SESSION_COMMENT_COMMAND_LIST)].join("\n") + "\n", + }; + } - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"highlight-add"> = { - file: "", - start: 0, - end: 0, - }; + switch (subcommand) { + case "add": + return parseSessionCommentAddCommand(rest); + case "apply": + return parseSessionCommentApplyCommand(rest); + case "list": + return parseSessionCommentListCommand(rest); + case "rm": + return parseSessionCommentRmCommand(rest); + case "clear": + return parseSessionCommentClearCommand(rest); + default: + throw new Error("Supported comment subcommands are add, apply, list, rm, and clear."); + } +} - command.action( - (sessionId: string | undefined, options: SessionCommandOptions<"highlight-add">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }, - ); +/** Parse `hunk session highlight add`. */ +async function parseSessionHighlightAddCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS["highlight-add"]; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"highlight-add"> = { + file: "", + start: 0, + end: 0, + }; - if (highlightRest.includes("--help") || highlightRest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["highlight-add"]); - } + command.action( + (sessionId: string | undefined, options: SessionCommandOptions<"highlight-add">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); - await parseStandaloneCommand(command, highlightRest); + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - enforceConstraint(HIGHLIGHT_TARGET_CONSTRAINT, parsedOptions); - if (parsedOptions.end <= parsedOptions.start) { - throw new Error(HIGHLIGHT_RANGE_MESSAGE); - } - const tone = parsedOptions.tone; - if (tone !== undefined && !isHighlightTone(tone)) { - throw new Error(`Highlight tone must be one of ${HIGHLIGHT_TONES.join(", ")}.`); - } + await parseStandaloneCommand(command, tokens); + enforceConstraint(HIGHLIGHT_TARGET_CONSTRAINT, parsedOptions); + if (parsedOptions.end <= parsedOptions.start) { + throw new Error(HIGHLIGHT_RANGE_MESSAGE); + } - return { - kind: "session", - action: "highlight-add", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - side: parsedOptions.oldLine !== undefined ? "old" : "new", - line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, - start: parsedOptions.start, - end: parsedOptions.end, - ...(tone !== undefined && isHighlightTone(tone) ? { tone } : {}), - reveal: parsedOptions.focus ?? false, - }; - } + const tone = parsedOptions.tone; + if (tone !== undefined && !isHighlightTone(tone)) { + throw new Error(`Highlight tone must be one of ${HIGHLIGHT_TONES.join(", ")}.`); + } - if (highlightSubcommand === "clear") { - const command = buildSessionCommand(SESSION_AGENT_COMMANDS["highlight-clear"]); + return { + kind: "session", + action: "highlight-add", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + side: parsedOptions.oldLine !== undefined ? "old" : "new", + line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, + start: parsedOptions.start, + end: parsedOptions.end, + ...(tone !== undefined && isHighlightTone(tone) ? { tone } : {}), + reveal: parsedOptions.focus ?? false, + }; +} - let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"highlight-clear"> = {}; +/** Parse `hunk session highlight clear`. */ +async function parseSessionHighlightClearCommand(tokens: string[]): Promise { + const spec = SESSION_AGENT_COMMANDS["highlight-clear"]; + const command = buildSessionCommand(spec); + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"highlight-clear"> = {}; - command.action( - (sessionId: string | undefined, options: SessionCommandOptions<"highlight-clear">) => { - parsedSessionId = sessionId; - parsedOptions = options; - }, - ); + command.action( + (sessionId: string | undefined, options: SessionCommandOptions<"highlight-clear">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); - if (highlightRest.includes("--help") || highlightRest.includes("-h")) { - return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["highlight-clear"]); - } + if (hasSessionHelpFlag(tokens)) { + return sessionCommandHelpText(command, spec); + } - await parseStandaloneCommand(command, highlightRest); + await parseStandaloneCommand(command, tokens); + return { + kind: "session", + action: "highlight-clear", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + }; +} - return { - kind: "session", - action: "highlight-clear", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - }; - } +/** Dispatch one command under the session highlight namespace. */ +function parseSessionHighlightCommand(tokens: string[]): Promise | ParsedCliInput { + const [subcommand, ...rest] = tokens; + if (!subcommand || subcommand === "--help" || subcommand === "-h") { + return { + kind: "help", + text: ["Usage:", ...sessionUsageLines(SESSION_HIGHLIGHT_COMMAND_LIST)].join("\n") + "\n", + }; + } - throw new Error("Supported highlight subcommands are add and clear."); + switch (subcommand) { + case "add": + return parseSessionHighlightAddCommand(rest); + case "clear": + return parseSessionHighlightClearCommand(rest); + default: + throw new Error("Supported highlight subcommands are add and clear."); } +} - throw new Error(`Unknown session command: ${subcommand}`); +/** Dispatch `hunk session ...` to one focused live-session command parser. */ +function parseSessionCommand(tokens: string[]): Promise | ParsedCliInput { + const [subcommand, ...rest] = tokens; + if (!subcommand || subcommand === "--help" || subcommand === "-h") { + return sessionOverviewHelp(); + } + + switch (subcommand) { + case "list": + return parseSessionListCommand(rest); + case "get": + case "context": + return parseSessionReadCommand(subcommand, rest); + case "review": + return parseSessionReviewCommand(rest); + case "navigate": + return parseSessionNavigateCommand(rest); + case "reload": + return parseSessionReloadCommand(rest); + case "comment": + return parseSessionCommentCommand(rest); + case "highlight": + return parseSessionHighlightCommand(rest); + default: + throw new Error(`Unknown session command: ${subcommand}`); + } } const MARKUP_HELP = [ From fbfa072e2f6dfb2bcbdffd9c68929e69253a8769 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 26 Aug 2026 09:04:45 -0400 Subject: [PATCH 2/2] refactor(cli): tighten session command typing --- src/app/cli.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/app/cli.ts | 7 +++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index 348274d11..49b65d827 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -1630,6 +1630,46 @@ describe("parseCli argument validation", () => { }); describe("parseCli session reload validation", () => { + test("scopes reload help flags around the nested command separator", async () => { + const outerHelp = await parseCli([ + "bun", + "hunk", + "session", + "reload", + "--help", + "--", + "show", + "--help", + ]); + expect(outerHelp).toMatchObject({ + kind: "help", + text: expect.stringContaining("replace the contents of one live Hunk session"), + }); + + await expect( + parseCli(["bun", "hunk", "session", "reload", "session-1", "--", "show", "--help"]), + ).rejects.toThrow("Session reload requires a Hunk review command after --"); + + expect( + await parseCli([ + "bun", + "hunk", + "session", + "reload", + "session-1", + "--", + "show", + "HEAD", + "--", + "--help", + ]), + ).toMatchObject({ + kind: "session", + action: "reload", + nextInput: { kind: "show", ref: "HEAD", pathspecs: ["--help"] }, + }); + }); + test("rejects a reload with the `--` separator but no nested command", async () => { await expect(parseCli(["bun", "hunk", "session", "reload", "session-1", "--"])).rejects.toThrow( "Pass the replacement Hunk command after `--`", diff --git a/src/app/cli.ts b/src/app/cli.ts index dfd0b76db..fda1702e5 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -1023,6 +1023,9 @@ async function parseSessionListCommand(tokens: string[]): Promise | SessionCommandOptions<"context">; + /** Parse a session get or context command with their shared selector shape. */ async function parseSessionReadCommand( action: "get" | "context", @@ -1031,9 +1034,9 @@ async function parseSessionReadCommand( const spec = SESSION_AGENT_COMMANDS[action]; const command = buildSessionCommand(spec); let parsedSessionId: string | undefined; - let parsedOptions: SessionCommandOptions<"get"> = {}; + let parsedOptions: SessionReadCommandOptions = {}; - command.action((sessionId: string | undefined, options: SessionCommandOptions<"get">) => { + command.action((sessionId: string | undefined, options: SessionReadCommandOptions) => { parsedSessionId = sessionId; parsedOptions = options; });