From 46da8a1a55a8ab1a2206ec8fcf13fb0feb06fc93 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 22 Mar 2026 14:05:09 -0400 Subject: [PATCH] feat: add session control CLI --- src/core/cli.ts | 238 ++++++++++++++++++++++++++++++ src/core/startup.ts | 13 +- src/core/types.ts | 53 ++++++- src/main.tsx | 6 + src/session/commands.ts | 307 ++++++++++++++++++++++++++++++++++++++ test/cli.test.ts | 107 ++++++++++++++ test/help-output.test.ts | 1 + test/session-cli.test.ts | 309 +++++++++++++++++++++++++++++++++++++++ test/startup.test.ts | 18 +++ 9 files changed, 1050 insertions(+), 2 deletions(-) create mode 100644 src/session/commands.ts create mode 100644 test/session-cli.test.ts diff --git a/src/core/cli.ts b/src/core/cli.ts index f923d62..000f4c1 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -12,6 +12,16 @@ function parseLayoutMode(value: string): LayoutMode { throw new Error(`Invalid layout mode: ${value}`); } +/** Parse one required positive integer CLI value. */ +function parsePositiveInt(value: string) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid positive integer: ${value}`); + } + + return parsed; +} + /** Read one paired positive/negative boolean flag directly from raw argv. */ function resolveBooleanFlag(argv: string[], enabledFlag: string, disabledFlag: string) { let resolved: boolean | undefined; @@ -116,6 +126,7 @@ function renderCliHelp() { " hunk patch [file] review a patch file or stdin", " hunk pager general Git pager wrapper with diff detection", " hunk difftool [path] review Git difftool file pairs", + " hunk session inspect or control a live Hunk session", " hunk mcp serve run the local Hunk MCP daemon", "", "Options:", @@ -132,6 +143,7 @@ function renderCliHelp() { " hunk show abc123 -- README.md", " hunk patch -", " hunk pager", + " hunk session list", " hunk mcp serve", "", ].join("\n"); @@ -175,6 +187,26 @@ function createCommand(name: string, description: string) { return applyCommonOptions(new Command(name).description(description)); } +/** Resolve whether one nested CLI command requested JSON output. */ +function resolveJsonOutput(options: { json?: boolean }) { + return options.json ? "json" : "text"; +} + +/** Normalize one explicit session selector from either session id or repo root. */ +function resolveExplicitSessionSelector(sessionId: string | undefined, repoRoot: string | undefined) { + if (sessionId && repoRoot) { + throw new Error("Specify either or --repo , not both."); + } + + if (!sessionId && !repoRoot) { + throw new Error("Specify one live Hunk session with or --repo ."); + } + + return sessionId + ? { sessionId } + : { repoRoot: resolve(repoRoot!) }; +} + /** Parse the overloaded `hunk diff` command. */ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); @@ -341,6 +373,210 @@ async function parseDifftoolCommand(tokens: string[], argv: 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:", + " hunk session list", + " hunk session get ", + " hunk session get --repo ", + " hunk session context ", + " hunk session context --repo ", + " hunk session navigate --file (--hunk | --old-line | --new-line )", + " hunk session comment add --file (--old-line | --new-line ) --summary ", + ].join("\n") + "\n", + }; + } + + if (subcommand === "list") { + const command = new Command("session list").description("list live Hunk sessions").option("--json", "emit structured JSON"); + let parsedOptions: { json?: boolean } = {}; + + command.action((options: { json?: boolean }) => { + parsedOptions = options; + }); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + return { + kind: "session", + action: "list", + output: resolveJsonOutput(parsedOptions), + }; + } + + if (subcommand === "get" || subcommand === "context") { + const command = new Command(`session ${subcommand}`) + .description(subcommand === "get" ? "show one live Hunk session" : "show the selected file and hunk for one live Hunk session") + .argument("[sessionId]") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { repo?: string; json?: boolean } = {}; + + command.action((sessionId: string | undefined, options: { repo?: string; json?: boolean }) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + return { + kind: "session", + action: subcommand, + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + }; + } + + if (subcommand === "navigate") { + const command = new Command("session navigate") + .description("move a live Hunk session to one diff hunk") + .argument("[sessionId]") + .requiredOption("--file ", "diff file path as shown by Hunk") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--hunk ", "1-based hunk number within the file", parsePositiveInt) + .option("--old-line ", "1-based line number on the old side", parsePositiveInt) + .option("--new-line ", "1-based line number on the new side", parsePositiveInt) + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean } = { file: "" }; + + command.action((sessionId: string | undefined, options: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean }) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + + const selectors = [parsedOptions.hunk !== undefined, parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean); + if (selectors.length !== 1) { + throw new Error("Specify exactly one navigation target: --hunk , --old-line , or --new-line ."); + } + + 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, + }; + } + + if (subcommand === "comment") { + const [commentSubcommand, ...commentRest] = rest; + if (!commentSubcommand || commentSubcommand === "--help" || commentSubcommand === "-h") { + return { + kind: "help", + text: [ + "Usage: hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ", + "", + "Attach one live inline review note to a diff line.", + ].join("\n") + "\n", + }; + } + + if (commentSubcommand !== "add") { + throw new Error("Only `hunk session comment add` is supported."); + } + + const command = new Command("session comment add") + .description("attach one live inline review note") + .argument("[sessionId]") + .requiredOption("--file ", "diff file path as shown by Hunk") + .requiredOption("--summary ", "short review note") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--old-line ", "1-based line number on the old side", parsePositiveInt) + .option("--new-line ", "1-based line number on the new side", parsePositiveInt) + .option("--rationale ", "optional longer explanation") + .option("--author ", "optional author label") + .option("--reveal", "jump to and reveal the note") + .option("--no-reveal", "add the note without moving focus") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { + repo?: string; + file: string; + summary: string; + oldLine?: number; + newLine?: number; + rationale?: string; + author?: string; + reveal?: boolean; + json?: boolean; + } = { + file: "", + summary: "", + }; + + command.action((sessionId: string | undefined, options: { + repo?: string; + file: string; + summary: string; + oldLine?: number; + newLine?: number; + rationale?: string; + author?: string; + reveal?: boolean; + json?: boolean; + }) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); + + if (commentRest.includes("--help") || commentRest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, commentRest); + + const selectors = [parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean); + if (selectors.length !== 1) { + throw new Error("Specify exactly one comment target: --old-line or --new-line ."); + } + + 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, + author: parsedOptions.author, + reveal: parsedOptions.reveal ?? true, + }; + } + + throw new Error(`Unknown session command: ${subcommand}`); +} + /** Parse `hunk mcp serve` as the local daemon entrypoint. */ async function parseMcpCommand(tokens: string[]): Promise { const [subcommand, ...rest] = tokens; @@ -450,6 +686,8 @@ export async function parseCli(argv: string[]): Promise { return parseDifftoolCommand(rest, argv); case "stash": return parseStashCommand(rest, argv); + case "session": + return parseSessionCommand(rest); case "mcp": return parseMcpCommand(rest); default: diff --git a/src/core/startup.ts b/src/core/startup.ts index ef63b7f..d6d36de 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -2,7 +2,7 @@ import { resolveConfiguredCliInput } from "./config"; import { loadAppBootstrap } from "./loaders"; import { looksLikePatchInput } from "./pager"; import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput, type ControllingTerminal } from "./terminal"; -import type { AppBootstrap, CliInput, ParsedCliInput } from "./types"; +import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types"; import { parseCli } from "./cli"; export type StartupPlan = @@ -13,6 +13,10 @@ export type StartupPlan = | { kind: "mcp-serve"; } + | { + kind: "session-command"; + input: SessionCommandInput; + } | { kind: "plain-text-pager"; text: string; @@ -64,6 +68,13 @@ export async function prepareStartupPlan( }; } + if (parsedCliInput.kind === "session") { + return { + kind: "session-command", + input: parsedCliInput, + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await readStdinText(); diff --git a/src/core/types.ts b/src/core/types.ts index 56afec1..1eb071c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -84,6 +84,57 @@ export interface McpServeCommandInput { kind: "mcp-serve"; } +export type SessionCommandOutput = "text" | "json"; + +export interface SessionSelectorInput { + sessionId?: string; + repoRoot?: string; +} + +export interface SessionListCommandInput { + kind: "session"; + action: "list"; + output: SessionCommandOutput; +} + +export interface SessionGetCommandInput { + kind: "session"; + action: "get" | "context"; + output: SessionCommandOutput; + selector: SessionSelectorInput; +} + +export interface SessionNavigateCommandInput { + kind: "session"; + action: "navigate"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + filePath: string; + hunkNumber?: number; + side?: "old" | "new"; + line?: number; +} + +export interface SessionCommentAddCommandInput { + kind: "session"; + action: "comment-add"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + filePath: string; + side: "old" | "new"; + line: number; + summary: string; + rationale?: string; + author?: string; + reveal: boolean; +} + +export type SessionCommandInput = + | SessionListCommandInput + | SessionGetCommandInput + | SessionNavigateCommandInput + | SessionCommentAddCommandInput; + export interface GitCommandInput { kind: "git"; range?: string; @@ -135,7 +186,7 @@ export type CliInput = | PatchCommandInput | DiffToolCommandInput; -export type ParsedCliInput = CliInput | HelpCommandInput | PagerCommandInput | McpServeCommandInput; +export type ParsedCliInput = CliInput | HelpCommandInput | PagerCommandInput | McpServeCommandInput | SessionCommandInput; export interface AppBootstrap { input: CliInput; diff --git a/src/main.tsx b/src/main.tsx index 8973227..be4bcf7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,7 @@ import { App } from "./ui/App"; import { HunkHostClient } from "./mcp/client"; import { serveHunkMcpServer } from "./mcp/server"; import { createInitialSessionSnapshot, createSessionRegistration } from "./mcp/sessionRegistration"; +import { runSessionCommand } from "./session/commands"; const startupPlan = await prepareStartupPlan(); @@ -22,6 +23,11 @@ if (startupPlan.kind === "mcp-serve") { await new Promise(() => {}); } +if (startupPlan.kind === "session-command") { + process.stdout.write(await runSessionCommand(startupPlan.input)); + process.exit(0); +} + if (startupPlan.kind === "plain-text-pager") { await pagePlainText(startupPlan.text); process.exit(0); diff --git a/src/session/commands.ts b/src/session/commands.ts new file mode 100644 index 0000000..bfb8a81 --- /dev/null +++ b/src/session/commands.ts @@ -0,0 +1,307 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { resolve } from "node:path"; +import type { + SessionCommandInput, + SessionCommandOutput, + SessionCommentAddCommandInput, + SessionGetCommandInput, + SessionNavigateCommandInput, + SessionSelectorInput, +} from "../core/types"; +import { isHunkDaemonHealthy, isLoopbackPortReachable } from "../mcp/daemonLauncher"; +import { resolveHunkMcpConfig } from "../mcp/config"; +import type { AppliedCommentResult, ListedSession, NavigatedSelectionResult, SelectedSessionContext } from "../mcp/types"; + +interface HunkDaemonCliClient { + connect(): Promise; + close(): Promise; + listSessions(): Promise; + getSession(selector: SessionSelectorInput): Promise; + getSelectedContext(selector: SessionSelectorInput): Promise; + navigateToHunk(input: SessionNavigateCommandInput): Promise; + addComment(input: SessionCommentAddCommandInput): Promise; +} + +function extractToolValue( + result: Awaited>, + key: string, +): ResultType | undefined { + const structured = result.structuredContent as Record | undefined; + if (structured && key in structured) { + return structured[key]; + } + + const content = (result.content ?? []) as Array<{ type?: string; text?: string }>; + const text = content.find((entry) => entry.type === "text")?.text; + if (!text) { + return undefined; + } + + try { + const parsed = JSON.parse(text) as ResultType | Record; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && key in parsed) { + return (parsed as Record)[key]; + } + + return parsed as ResultType; + } catch { + return undefined; + } +} + +class McpHunkDaemonCliClient implements HunkDaemonCliClient { + private readonly transport = new StreamableHTTPClientTransport(new URL(`${resolveHunkMcpConfig().httpOrigin}/mcp`)); + private readonly client = new Client({ name: "hunk-session-cli", version: "1.0.0" }); + + async connect() { + await this.client.connect(this.transport); + } + + async close() { + await this.transport.close().catch(() => undefined); + } + + async listSessions() { + const result = await this.client.callTool({ + name: "list_sessions", + arguments: {}, + }); + + return extractToolValue(result, "sessions") ?? []; + } + + async getSession(selector: SessionSelectorInput) { + const result = await this.client.callTool({ + name: "get_session", + arguments: selector as Record, + }); + + const session = extractToolValue(result, "session"); + if (!session) { + throw new Error("The Hunk daemon returned no session payload."); + } + + return session; + } + + async getSelectedContext(selector: SessionSelectorInput) { + const result = await this.client.callTool({ + name: "get_selected_context", + arguments: selector as Record, + }); + + const context = extractToolValue(result, "context"); + if (!context) { + throw new Error("The Hunk daemon returned no selected-context payload."); + } + + return context; + } + + async navigateToHunk(input: SessionNavigateCommandInput) { + const result = await this.client.callTool({ + name: "navigate_to_hunk", + arguments: { + ...input.selector, + filePath: input.filePath, + hunkIndex: input.hunkNumber !== undefined ? input.hunkNumber - 1 : undefined, + side: input.side, + line: input.line, + }, + }); + + const navigated = extractToolValue(result, "result"); + if (!navigated) { + throw new Error("The Hunk daemon returned no navigation result."); + } + + return navigated; + } + + async addComment(input: SessionCommentAddCommandInput) { + const result = await this.client.callTool({ + name: "comment", + arguments: { + ...input.selector, + filePath: input.filePath, + side: input.side, + line: input.line, + summary: input.summary, + rationale: input.rationale, + author: input.author, + reveal: input.reveal, + }, + }); + + const comment = extractToolValue(result, "result"); + if (!comment) { + throw new Error("The Hunk daemon returned no comment result."); + } + + return comment; + } +} + +function stringifyJson(value: unknown) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function formatSelector(selector: SessionSelectorInput) { + if (selector.sessionId) { + return `session ${selector.sessionId}`; + } + + if (selector.repoRoot) { + return `repo ${selector.repoRoot}`; + } + + return "session"; +} + +function formatSelectedSummary(session: ListedSession) { + const filePath = session.snapshot.selectedFilePath ?? "(none)"; + const hunkNumber = session.snapshot.selectedFilePath ? session.snapshot.selectedHunkIndex + 1 : 0; + return filePath === "(none)" ? filePath : `${filePath} hunk ${hunkNumber}`; +} + +function formatListOutput(sessions: ListedSession[]) { + if (sessions.length === 0) { + return "No active Hunk sessions.\n"; + } + + return `${sessions + .map((session) => [ + `${session.sessionId} ${session.title}`, + ` repo: ${session.repoRoot ?? session.cwd}`, + ` focus: ${formatSelectedSummary(session)}`, + ` files: ${session.fileCount}`, + ` comments: ${session.snapshot.liveCommentCount}`, + ].join("\n")) + .join("\n\n")}\n`; +} + +function formatSessionOutput(session: ListedSession) { + return [ + `Session: ${session.sessionId}`, + `Title: ${session.title}`, + `Source: ${session.sourceLabel}`, + `Repo: ${session.repoRoot ?? session.cwd}`, + `Input: ${session.inputKind}`, + `Launched: ${session.launchedAt}`, + `Selected: ${formatSelectedSummary(session)}`, + `Agent notes visible: ${session.snapshot.showAgentNotes ? "yes" : "no"}`, + `Live comments: ${session.snapshot.liveCommentCount}`, + "Files:", + ...session.files.map((file) => ` - ${file.path} (+${file.additions} -${file.deletions}, hunks: ${file.hunkCount})`), + "", + ].join("\n"); +} + +function formatContextOutput(context: SelectedSessionContext) { + const selectedFile = context.selectedFile?.path ?? "(none)"; + const hunkNumber = context.selectedHunk ? context.selectedHunk.index + 1 : 0; + const oldRange = context.selectedHunk?.oldRange ? `${context.selectedHunk.oldRange[0]}..${context.selectedHunk.oldRange[1]}` : "-"; + const newRange = context.selectedHunk?.newRange ? `${context.selectedHunk.newRange[0]}..${context.selectedHunk.newRange[1]}` : "-"; + + return [ + `Session: ${context.sessionId}`, + `Title: ${context.title}`, + `Repo: ${context.repoRoot ?? "-"}`, + `File: ${selectedFile}`, + `Hunk: ${context.selectedHunk ? hunkNumber : "-"}`, + `Old range: ${oldRange}`, + `New range: ${newRange}`, + `Agent notes visible: ${context.showAgentNotes ? "yes" : "no"}`, + `Live comments: ${context.liveCommentCount}`, + "", + ].join("\n"); +} + +function formatNavigationOutput(selector: SessionSelectorInput, result: NavigatedSelectionResult) { + return `Focused ${result.filePath} hunk ${result.hunkIndex + 1} in ${formatSelector(selector)}.\n`; +} + +function formatCommentOutput(selector: SessionSelectorInput, result: AppliedCommentResult) { + return `Added live comment ${result.commentId} on ${result.filePath}:${result.line} (${result.side}) in hunk ${result.hunkIndex + 1} for ${formatSelector(selector)}.\n`; +} + +function normalizeRepoRoot(selector: SessionSelectorInput) { + if (!selector.repoRoot) { + return selector; + } + + return { + ...selector, + repoRoot: resolve(selector.repoRoot), + }; +} + +async function resolveDaemonAvailability(action: SessionCommandInput["action"]) { + const config = resolveHunkMcpConfig(); + const healthy = await isHunkDaemonHealthy(config); + if (healthy) { + return true; + } + + const portReachable = await isLoopbackPortReachable(config); + if (portReachable) { + throw new Error( + `Hunk MCP port ${config.host}:${config.port} is already in use by another process. ` + + `Stop the conflicting process or set HUNK_MCP_PORT to a different loopback port.`, + ); + } + + if (action === "list") { + return false; + } + + throw new Error("No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect."); +} + +function renderOutput(output: SessionCommandOutput, value: unknown, formatText: () => string) { + return output === "json" ? stringifyJson(value) : formatText(); +} + +export async function runSessionCommand(input: SessionCommandInput) { + const daemonAvailable = await resolveDaemonAvailability(input.action); + if (!daemonAvailable && input.action === "list") { + return renderOutput(input.output, { sessions: [] }, () => formatListOutput([])); + } + + const client = new McpHunkDaemonCliClient(); + await client.connect(); + + try { + switch (input.action) { + case "list": { + const sessions = await client.listSessions(); + return renderOutput(input.output, { sessions }, () => formatListOutput(sessions)); + } + case "get": { + const session = await client.getSession(normalizeRepoRoot(input.selector)); + return renderOutput(input.output, { session }, () => formatSessionOutput(session)); + } + case "context": { + const context = await client.getSelectedContext(normalizeRepoRoot(input.selector)); + return renderOutput(input.output, { context }, () => formatContextOutput(context)); + } + case "navigate": { + const result = await client.navigateToHunk({ + ...input, + selector: normalizeRepoRoot(input.selector), + }); + return renderOutput(input.output, { result }, () => formatNavigationOutput(input.selector, result)); + } + case "comment-add": { + const result = await client.addComment({ + ...input, + selector: normalizeRepoRoot(input.selector), + }); + return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result)); + } + } + } finally { + await client.close(); + } +} diff --git a/test/cli.test.ts b/test/cli.test.ts index e84fcea..78a714a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -152,6 +152,113 @@ describe("parseCli", () => { }); }); + test("parses session list mode", async () => { + const parsed = await parseCli(["bun", "hunk", "session", "list", "--json"]); + + expect(parsed).toEqual({ + kind: "session", + action: "list", + output: "json", + }); + }); + + test("parses session get by repo", async () => { + const parsed = await parseCli(["bun", "hunk", "session", "get", "--repo", "."]); + + expect(parsed).toMatchObject({ + kind: "session", + action: "get", + selector: { + repoRoot: process.cwd(), + }, + output: "text", + }); + }); + + test("parses session navigate by hunk number", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "navigate", + "session-1", + "--file", + "README.md", + "--hunk", + "2", + "--json", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "navigate", + selector: { sessionId: "session-1" }, + filePath: "README.md", + hunkNumber: 2, + output: "json", + }); + }); + + test("parses session comment add", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "comment", + "add", + "session-1", + "--file", + "README.md", + "--new-line", + "103", + "--summary", + "Frame this as MCP-first", + "--rationale", + "Live review is the main value.", + "--author", + "Pi", + "--no-reveal", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "comment-add", + selector: { sessionId: "session-1" }, + filePath: "README.md", + side: "new", + line: 103, + summary: "Frame this as MCP-first", + rationale: "Live review is the main value.", + author: "Pi", + reveal: false, + output: "text", + }); + }); + + test("rejects session commands without an explicit target", async () => { + await expect(parseCli(["bun", "hunk", "session", "get"])).rejects.toThrow( + "Specify one live Hunk session with or --repo .", + ); + }); + + test("rejects session navigation with multiple target selectors", async () => { + await expect( + parseCli([ + "bun", + "hunk", + "session", + "navigate", + "session-1", + "--file", + "README.md", + "--hunk", + "1", + "--new-line", + "103", + ]), + ).rejects.toThrow("Specify exactly one navigation target"); + }); + test("parses stash show mode", async () => { const parsed = await parseCli(["bun", "hunk", "stash", "show", "stash@{1}"]); diff --git a/test/help-output.test.ts b/test/help-output.test.ts index a6a0a28..4407827 100644 --- a/test/help-output.test.ts +++ b/test/help-output.test.ts @@ -18,6 +18,7 @@ describe("CLI help output", () => { expect(stdout).toContain("hunk diff"); expect(stdout).toContain("hunk show"); expect(stdout).toContain("hunk pager"); + expect(stdout).toContain("hunk session "); expect(stdout).toContain("hunk mcp serve"); expect(stdout).not.toContain("hunk git"); expect(stdout).not.toContain("\u001b[?1049h"); diff --git a/test/session-cli.test.ts b/test/session-cli.test.ts new file mode 100644 index 0000000..47ddc61 --- /dev/null +++ b/test/session-cli.test.ts @@ -0,0 +1,309 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const repoRoot = process.cwd(); +const sourceEntrypoint = join(repoRoot, "src/main.tsx"); +const tempDirs: string[] = []; +const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", +}).exitCode === 0; + +interface SessionListJson { + sessions: Array<{ + sessionId: string; + files: Array<{ + path: string; + }>; + }>; +} + +function cleanupTempDirs() { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +} + +function shellQuote(value: string) { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function stripTerminalControl(text: string) { + return text + .replace(/^Script started.*?\n/s, "") + .replace(/\nScript done.*$/s, "") + .replace(/\x1bP[\s\S]*?\x1b\\/g, "") + .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b[@-_]/g, ""); +} + +function waitUntil(label: string, poll: () => T | null | Promise, timeoutMs = 10_000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + + return new Promise(async (resolve, reject) => { + while (Date.now() < deadline) { + const value = await poll(); + if (value !== null) { + resolve(value); + return; + } + + await Bun.sleep(intervalMs); + } + + reject(new Error(`Timed out waiting for ${label}.`)); + }); +} + +function createFixtureFiles(name: string, beforeLines: string[], afterLines: string[]) { + const dir = mkdtempSync(join(tmpdir(), `hunk-session-cli-${name}-`)); + tempDirs.push(dir); + + const beforeName = `${name}-before.ts`; + const afterName = `${name}-after.ts`; + const before = join(dir, beforeName); + const after = join(dir, afterName); + const transcript = join(dir, `${name}-transcript.txt`); + + writeFileSync(before, [...beforeLines, ""].join("\n")); + writeFileSync(after, [...afterLines, ""].join("\n")); + + return { dir, before, after, transcript, afterName }; +} + +function spawnHunkSession( + fixture: ReturnType, + { + port, + quitAfterSeconds = 8, + timeoutSeconds = 10, + }: { + port: number; + quitAfterSeconds?: number; + timeoutSeconds?: number; + }, +) { + const innerCommand = `bun run ${shellQuote(sourceEntrypoint)} diff ${shellQuote(fixture.before)} ${shellQuote(fixture.after)}`; + const hunkCommand = [ + `(sleep ${quitAfterSeconds}; printf q) | timeout ${timeoutSeconds} script -q -f -e -c`, + shellQuote(innerCommand), + shellQuote(fixture.transcript), + ].join(" "); + + return Bun.spawn(["bash", "-lc", hunkCommand], { + cwd: fixture.dir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HUNK_MCP_PORT: `${port}`, + }, + }); +} + +function runSessionCli(args: string[], port: number) { + const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", ...args], { + cwd: repoRoot, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HUNK_MCP_PORT: `${port}`, + }, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + return { proc, stdout, stderr }; +} + +afterEach(() => { + cleanupTempDirs(); +}); + +describe("session CLI", () => { + test("list/get/context expose live Hunk sessions through the daemon", async () => { + if (!ttyToolsAvailable) { + return; + } + + const port = 48961; + const fixture = createFixtureFiles( + "inspect", + ["export const value = 1;", "console.log(value);"], + ["export const value = 2;", "console.log(value * 2);"], + ); + const session = spawnHunkSession(fixture, { port }); + + try { + const listed = await waitUntil("registered live session", () => { + const { proc, stdout } = runSessionCli(["list", "--json"], port); + if (proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(stdout) as SessionListJson; + return parsed.sessions.length > 0 ? parsed.sessions : null; + }); + + const sessionId = listed[0]!.sessionId; + const get = runSessionCli(["get", sessionId, "--json"], port); + expect(get.proc.exitCode).toBe(0); + expect(get.stderr).toBe(""); + expect(JSON.parse(get.stdout)).toMatchObject({ + session: { + sessionId, + files: [ + { + path: fixture.afterName, + }, + ], + }, + }); + + const context = runSessionCli(["context", sessionId, "--json"], port); + expect(context.proc.exitCode).toBe(0); + expect(context.stderr).toBe(""); + expect(JSON.parse(context.stdout)).toMatchObject({ + context: { + sessionId, + selectedFile: { + path: fixture.afterName, + }, + selectedHunk: { + index: 0, + }, + }, + }); + } finally { + session.kill(); + await session.exited; + } + }); + + test("navigate and comment add control a live Hunk session", async () => { + if (!ttyToolsAvailable) { + return; + } + + const port = 48962; + const fixture = createFixtureFiles( + "mutate", + [ + "export const one = 1;", + "export const two = 2;", + "export const three = 3;", + "export const four = 4;", + "export const five = 5;", + "export const six = 6;", + "export const seven = 7;", + "export const eight = 8;", + "export const nine = 9;", + "export const ten = 10;", + "export const eleven = 11;", + "export const twelve = 12;", + "export const thirteen = 13;", + ], + [ + "export const one = 1;", + "export const two = 20;", + "export const three = 3;", + "export const four = 4;", + "export const five = 5;", + "export const six = 6;", + "export const seven = 7;", + "export const eight = 8;", + "export const nine = 9;", + "export const ten = 10;", + "export const eleven = 11;", + "export const twelve = 12;", + "export const thirteen = 130;", + ], + ); + const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 10, timeoutSeconds: 12 }); + + try { + const listed = await waitUntil("registered live session", () => { + const { proc, stdout } = runSessionCli(["list", "--json"], port); + if (proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(stdout) as SessionListJson; + return parsed.sessions.length > 0 ? parsed.sessions : null; + }); + + const sessionId = listed[0]!.sessionId; + + const navigate = runSessionCli( + ["navigate", sessionId, "--file", fixture.afterName, "--hunk", "2", "--json"], + port, + ); + expect(navigate.proc.exitCode).toBe(0); + expect(navigate.stderr).toBe(""); + expect(JSON.parse(navigate.stdout)).toMatchObject({ + result: { + filePath: fixture.afterName, + hunkIndex: 1, + }, + }); + + await waitUntil("updated session context", () => { + const context = runSessionCli(["context", sessionId, "--json"], port); + if (context.proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(context.stdout) as { context?: { selectedHunk?: { index: number } } }; + return parsed.context?.selectedHunk?.index === 1 ? parsed : null; + }); + + const comment = runSessionCli( + [ + "comment", + "add", + sessionId, + "--file", + fixture.afterName, + "--new-line", + "10", + "--summary", + "Second hunk note", + "--rationale", + "Added through the session CLI.", + "--author", + "Pi", + "--json", + ], + port, + ); + expect(comment.proc.exitCode).toBe(0); + expect(comment.stderr).toBe(""); + expect(JSON.parse(comment.stdout)).toMatchObject({ + result: { + filePath: fixture.afterName, + hunkIndex: 1, + side: "new", + line: 10, + }, + }); + + await waitUntil("rendered live comment", () => { + const transcript = stripTerminalControl(readFileSync(fixture.transcript, "utf8")); + return transcript.includes("Second hunk note") ? transcript : null; + }); + } finally { + session.kill(); + await session.exited; + } + }); +}); diff --git a/test/startup.test.ts b/test/startup.test.ts index 393a681..0ab6c9d 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -46,6 +46,24 @@ describe("startup planning", () => { expect(loaded).toBe(false); }); + test("passes session commands through without app bootstrap work", async () => { + let loaded = false; + + const plan = await prepareStartupPlan(["bun", "hunk", "session", "list"], { + parseCliImpl: async () => ({ kind: "session", action: "list", output: "text" }), + loadAppBootstrapImpl: async () => { + loaded = true; + throw new Error("unreachable"); + }, + }); + + expect(plan).toEqual({ + kind: "session-command", + input: { kind: "session", action: "list", output: "text" }, + }); + expect(loaded).toBe(false); + }); + test("routes non-diff pager stdin to the plain-text pager path", async () => { let loaded = false;