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
238 changes: 238 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <left> <right> [path] review Git difftool file pairs",
" hunk session <subcommand> inspect or control a live Hunk session",
" hunk mcp serve run the local Hunk MCP daemon",
"",
"Options:",
Expand All @@ -132,6 +143,7 @@ function renderCliHelp() {
" hunk show abc123 -- README.md",
" hunk patch -",
" hunk pager",
" hunk session list",
" hunk mcp serve",
"",
].join("\n");
Expand Down Expand Up @@ -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 <session-id> or --repo <path>, not both.");
}

if (!sessionId && !repoRoot) {
throw new Error("Specify one live Hunk session with <session-id> or --repo <path>.");
}

return sessionId
? { sessionId }
: { repoRoot: resolve(repoRoot!) };
}

/** Parse the overloaded `hunk diff` command. */
async function parseDiffCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
Expand Down Expand Up @@ -341,6 +373,210 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise<P
};
}

/** Parse `hunk session ...` as live-session daemon-backed commands. */
async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
const [subcommand, ...rest] = tokens;
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
return {
kind: "help",
text: [
"Usage: hunk session <subcommand> [options]",
"",
"Inspect and control live Hunk review sessions through the local daemon.",
"",
"Commands:",
" hunk session list",
" hunk session get <session-id>",
" hunk session get --repo <path>",
" hunk session context <session-id>",
" hunk session context --repo <path>",
" hunk session navigate <session-id> --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
" hunk session comment add <session-id> --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
].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 <path>", "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 <path>", "diff file path as shown by Hunk")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--hunk <n>", "1-based hunk number within the file", parsePositiveInt)
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
.option("--new-line <n>", "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 <n>, --old-line <n>, or --new-line <n>.");
}

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 (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
"",
"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 <path>", "diff file path as shown by Hunk")
.requiredOption("--summary <text>", "short review note")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
.option("--rationale <text>", "optional longer explanation")
.option("--author <name>", "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 <n> or --new-line <n>.");
}

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<ParsedCliInput> {
const [subcommand, ...rest] = tokens;
Expand Down Expand Up @@ -450,6 +686,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
return parseDifftoolCommand(rest, argv);
case "stash":
return parseStashCommand(rest, argv);
case "session":
return parseSessionCommand(rest);
case "mcp":
return parseMcpCommand(rest);
default:
Expand Down
13 changes: 12 additions & 1 deletion src/core/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -13,6 +13,10 @@ export type StartupPlan =
| {
kind: "mcp-serve";
}
| {
kind: "session-command";
input: SessionCommandInput;
}
| {
kind: "plain-text-pager";
text: string;
Expand Down Expand Up @@ -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();

Expand Down
53 changes: 52 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading