Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Use it to:
- optionally include raw patch text when an agent truly needs it
- jump to a file, hunk, or line
- reload the current window with a different `diff` or `show` command
- add, list, and remove inline comments
- add, batch-apply, list, and remove inline comments

Most users only need `hunk session ...`. Use `hunk mcp serve` only for manual startup or debugging of the local daemon.

Expand All @@ -181,6 +181,8 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
hunk session reload --repo . -- show HEAD~1 -- README.md
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording"
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" --focus
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin
printf '%s\n' '{"comments":[{"filePath":"README.md","hunk":2,"summary":"Explain this hunk"}]}' | hunk session comment apply --repo . --stdin --focus
hunk session comment list --repo .
hunk session comment rm --repo . <comment-id>
hunk session comment clear --repo . --file README.md --yes
Expand All @@ -189,7 +191,9 @@ hunk session comment clear --repo . --file README.md --yes
`hunk session review --json` returns file and hunk structure by default. Add `--include-patch` only when a caller truly needs raw unified diff text in the response.

`hunk session reload ... -- <hunk command>` swaps what a live session is showing without opening a new TUI window.
Pass `--focus` to jump the live session to the new note.
Pass `--focus` to jump the live session to the new note, or to the first note in a batch apply.

`hunk session comment apply` reads one stdin JSON object with a top-level `comments` array. Each item needs `filePath`, `summary`, and exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`.

- `--repo <path>` selects the live session by its current loaded repo root.
- `--source <path>` is reload-only: it changes where the nested `diff` / `show` command runs, but does not select the session.
Expand Down
13 changes: 10 additions & 3 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ If no session exists, ask the user to launch Hunk in their terminal first.
5. hunk session context --repo . # check current focus when needed
6. hunk session navigate ... # move to the right place
7. hunk session reload -- <command> # swap contents if needed
8. hunk session comment add ... # leave review notes
8. hunk session comment add ... # leave one review note
9. hunk session comment apply ... # apply many agent notes in one stdin batch
```

## Session selection
Expand Down Expand Up @@ -96,13 +97,17 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-

```bash
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--author "agent"] [--focus]
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin [--focus]
hunk session comment list --repo . [--file README.md]
hunk session comment rm --repo . <comment-id>
hunk session comment clear --repo . --yes [--file README.md]
```

- `comment add` is best for one note; `comment apply` is best when an agent already has several notes ready
- `comment add` requires `--file`, `--summary`, and exactly one of `--old-line` or `--new-line`
- Pass `--focus` when you want to jump to the new note
- `comment apply` payload items require `filePath`, `summary`, and exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`
- `comment apply` reads a JSON batch from stdin and validates the full batch before mutating the live session
- Pass `--focus` when you want to jump to the new note or the first note in a batch
- `comment list` and `comment clear` accept optional `--file`
- Quote `--summary` and `--rationale` defensively in the shell

Expand All @@ -125,13 +130,14 @@ Typical flow:
1. Load the right content (`reload` if needed)
2. Navigate to the first interesting file / hunk
3. Add a comment explaining what's happening and why
4. Move to the next point of interest -- repeat
4. If you already have several notes ready, prefer one `comment apply` batch over many separate shell invocations
5. Summarize when done

Guidelines:

- Work in the order that tells the clearest story, not necessarily file order
- Navigate before commenting so the user sees the code you're discussing
- Use `comment apply` for agent-generated batches and `comment add` for one-off notes
- Use `--focus` sparingly when the note itself should actively steer the review
- Keep comments focused: intent, structure, risks, or follow-ups
- Don't comment on every hunk -- highlight what the user wouldn't spot themselves
Expand All @@ -143,5 +149,6 @@ Guidelines:
- **"Multiple active sessions match"** -- pass `<session-id>` explicitly.
- **"No active Hunk session matches session path ..."** -- for advanced split-path reloads, verify the live window `Path` via `hunk session get` or `list`, then use `--session-path`.
- **"Pass the replacement Hunk command after `--`"** -- include `--` before the nested `diff` / `show` command.
- **"Pass --stdin to read batch comments from stdin JSON."** -- `comment apply` only reads its batch payload from stdin.
- **"Specify exactly one navigation target"** -- pick one of `--hunk`, `--old-line`, or `--new-line`.
- **"Specify either --next-comment or --prev-comment, not both."** -- choose one comment-navigation direction.
88 changes: 88 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,94 @@ describe("parseCli", () => {
});
});

test("parses session comment apply with --focus", async () => {
const originalStdin = Bun.stdin.stream;
Bun.stdin.stream = () =>
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'{"comments":[{"filePath":"README.md","hunk":2,"summary":"Explain this hunk"}]}',
),
);
controller.close();
},
});

try {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"comment",
"apply",
"session-1",
"--stdin",
"--focus",
"--json",
]);

expect(parsed).toEqual({
kind: "session",
action: "comment-apply",
selector: { sessionId: "session-1" },
comments: [
{
filePath: "README.md",
hunkNumber: 2,
summary: "Explain this hunk",
},
],
revealMode: "first",
output: "json",
});
} finally {
Bun.stdin.stream = originalStdin;
}
});

test("rejects session comment apply with an empty comments array", async () => {
const originalStdin = Bun.stdin.stream;
Bun.stdin.stream = () =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"comments":[]}'));
controller.close();
},
});

try {
await expect(
parseCli(["bun", "hunk", "session", "comment", "apply", "session-1", "--stdin"]),
).rejects.toThrow("Session comment apply expected at least one comment.");
} finally {
Bun.stdin.stream = originalStdin;
}
});

test("rejects session comment apply when both hunk aliases are present", async () => {
const originalStdin = Bun.stdin.stream;
Bun.stdin.stream = () =>
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'{"comments":[{"filePath":"README.md","hunk":2,"hunkNumber":2,"summary":"Explain this hunk"}]}',
),
);
controller.close();
},
});

try {
await expect(
parseCli(["bun", "hunk", "session", "comment", "apply", "session-1", "--stdin"]),
).rejects.toThrow("Comment 1 must not specify both `hunk` and `hunkNumber`.");
} finally {
Bun.stdin.stream = originalStdin;
}
});

test("parses session comment list with file filter", async () => {
const parsed = await parseCli([
"bun",
Expand Down
170 changes: 169 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
LayoutMode,
PagerCommandInput,
ParsedCliInput,
SessionCommentApplyItemInput,
} from "./types";
import { resolveCliVersion } from "./version";

Expand Down Expand Up @@ -192,6 +193,97 @@ function resolveJsonOutput(options: { json?: boolean }) {
return options.json ? "json" : "text";
}

function parsePositiveJsonInt(
value: unknown,
{ field, itemNumber }: { field: string; itemNumber: number },
) {
if (value === undefined) {
return undefined;
}

if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
throw new Error(`Comment ${itemNumber} field \`${field}\` must be a positive integer.`);
}

return value;
}

/** Parse one stdin JSON payload for `session comment apply`. */
function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemInput[] {
if (raw.trim().length === 0) {
throw new Error("Session comment apply expected one JSON object on stdin.");
}

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Session comment apply expected valid JSON on stdin.");
}

if (!parsed || typeof parsed !== "object") {
throw new Error("Session comment apply expected one JSON object with a comments array.");
}

const value = parsed as Record<string, unknown>;
if (!Array.isArray(value.comments)) {
throw new Error("Session comment apply expected a top-level `comments` array.");
}

Comment thread
benvinegar marked this conversation as resolved.
if (value.comments.length === 0) {
throw new Error("Session comment apply expected at least one comment.");
}

return value.comments.map((comment, index) => {
const itemNumber = index + 1;
if (!comment || typeof comment !== "object") {
throw new Error(`Comment ${itemNumber} must be a JSON object.`);
}

const item = comment as Record<string, unknown>;
const filePath = item.filePath;
if (typeof filePath !== "string" || filePath.length === 0) {
throw new Error(`Comment ${itemNumber} requires a non-empty \`filePath\`.`);
}

const summary = item.summary;
if (typeof summary !== "string" || summary.length === 0) {
throw new Error(`Comment ${itemNumber} requires a non-empty \`summary\`.`);
}

const hunk = parsePositiveJsonInt(item.hunk, { field: "hunk", itemNumber });
const hunkNumber = parsePositiveJsonInt(item.hunkNumber, { field: "hunkNumber", itemNumber });
if (hunk !== undefined && hunkNumber !== undefined) {
throw new Error(`Comment ${itemNumber} must not specify both \`hunk\` and \`hunkNumber\`.`);
}
Comment thread
benvinegar marked this conversation as resolved.

const oldLine = parsePositiveJsonInt(item.oldLine, { field: "oldLine", itemNumber });
const newLine = parsePositiveJsonInt(item.newLine, { field: "newLine", itemNumber });
const resolvedHunkNumber = hunk ?? hunkNumber;

const selectors = [
resolvedHunkNumber !== undefined,
oldLine !== undefined,
newLine !== undefined,
].filter(Boolean);
if (selectors.length !== 1) {
throw new Error(
`Comment ${itemNumber} must specify exactly one of \`hunk\`, \`hunkNumber\`, \`oldLine\`, or \`newLine\`.`,
);
}

return {
filePath,
hunkNumber: resolvedHunkNumber,
side: oldLine !== undefined ? "old" : newLine !== undefined ? "new" : undefined,
line: oldLine ?? newLine,
summary,
rationale: typeof item.rationale === "string" ? item.rationale : undefined,
author: typeof item.author === "string" ? item.author : undefined,
};
});
}

/** Normalize one explicit session selector from either session id or repo root. */
function resolveExplicitSessionSelector(
sessionId: string | undefined,
Expand Down Expand Up @@ -487,6 +579,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- diff [ref] [-- <pathspec...>]",
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- show [ref] [-- <pathspec...>]",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text> [--focus]",
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
" hunk session comment list (<session-id> | --repo <path>)",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) --yes",
Expand Down Expand Up @@ -755,6 +848,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
[
"Usage:",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text> [--focus]",
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
" hunk session comment list (<session-id> | --repo <path>) [--file <path>]",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) [--file <path>] --yes",
Expand Down Expand Up @@ -841,6 +935,80 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
};
}

if (commentSubcommand === "apply") {
const command = new Command("session comment apply")
.description("apply many live inline review notes from stdin JSON")
.argument("[sessionId]")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--stdin", "read the comment batch from stdin as JSON")
.option("--focus", "apply the batch and focus the first note")
.option("--json", "emit structured JSON");

let parsedSessionId: string | undefined;
let parsedOptions: {
repo?: string;
stdin?: boolean;
focus?: boolean;
json?: boolean;
} = {};

command.action(
(
sessionId: string | undefined,
options: {
repo?: string;
stdin?: boolean;
focus?: boolean;
json?: boolean;
},
) => {
parsedSessionId = sessionId;
parsedOptions = options;
},
);

if (commentRest.includes("--help") || commentRest.includes("-h")) {
return {
kind: "help",
text:
`${command.helpInformation().trimEnd()}\n\n` +
[
"Stdin JSON shape:",
" {",
' "comments": [',
" {",
' "filePath": "README.md",',
' "hunk": 2,',
' "summary": "Explain this hunk",',
' "rationale": "Optional detail",',
' "author": "Pi"',
" }",
" ]",
" }",
].join("\n") +
"\n",
};
}

await parseStandaloneCommand(command, commentRest);
if (!parsedOptions.stdin) {
throw new Error("Pass --stdin to read batch comments from stdin JSON.");
}

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",
};
}

if (commentSubcommand === "list") {
const command = new Command("session comment list")
.description("list live inline review notes")
Expand Down Expand Up @@ -967,7 +1135,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
};
}

throw new Error("Supported comment subcommands are add, list, rm, and clear.");
throw new Error("Supported comment subcommands are add, apply, list, rm, and clear.");
}

throw new Error(`Unknown session command: ${subcommand}`);
Expand Down
Loading
Loading