Skip to content

Commit ff12658

Browse files
authored
feat: add session control CLI (#50)
1 parent bb8d9dd commit ff12658

9 files changed

Lines changed: 1050 additions & 2 deletions

File tree

src/core/cli.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ function parseLayoutMode(value: string): LayoutMode {
1212
throw new Error(`Invalid layout mode: ${value}`);
1313
}
1414

15+
/** Parse one required positive integer CLI value. */
16+
function parsePositiveInt(value: string) {
17+
const parsed = Number.parseInt(value, 10);
18+
if (!Number.isInteger(parsed) || parsed <= 0) {
19+
throw new Error(`Invalid positive integer: ${value}`);
20+
}
21+
22+
return parsed;
23+
}
24+
1525
/** Read one paired positive/negative boolean flag directly from raw argv. */
1626
function resolveBooleanFlag(argv: string[], enabledFlag: string, disabledFlag: string) {
1727
let resolved: boolean | undefined;
@@ -116,6 +126,7 @@ function renderCliHelp() {
116126
" hunk patch [file] review a patch file or stdin",
117127
" hunk pager general Git pager wrapper with diff detection",
118128
" hunk difftool <left> <right> [path] review Git difftool file pairs",
129+
" hunk session <subcommand> inspect or control a live Hunk session",
119130
" hunk mcp serve run the local Hunk MCP daemon",
120131
"",
121132
"Options:",
@@ -132,6 +143,7 @@ function renderCliHelp() {
132143
" hunk show abc123 -- README.md",
133144
" hunk patch -",
134145
" hunk pager",
146+
" hunk session list",
135147
" hunk mcp serve",
136148
"",
137149
].join("\n");
@@ -175,6 +187,26 @@ function createCommand(name: string, description: string) {
175187
return applyCommonOptions(new Command(name).description(description));
176188
}
177189

190+
/** Resolve whether one nested CLI command requested JSON output. */
191+
function resolveJsonOutput(options: { json?: boolean }) {
192+
return options.json ? "json" : "text";
193+
}
194+
195+
/** Normalize one explicit session selector from either session id or repo root. */
196+
function resolveExplicitSessionSelector(sessionId: string | undefined, repoRoot: string | undefined) {
197+
if (sessionId && repoRoot) {
198+
throw new Error("Specify either <session-id> or --repo <path>, not both.");
199+
}
200+
201+
if (!sessionId && !repoRoot) {
202+
throw new Error("Specify one live Hunk session with <session-id> or --repo <path>.");
203+
}
204+
205+
return sessionId
206+
? { sessionId }
207+
: { repoRoot: resolve(repoRoot!) };
208+
}
209+
178210
/** Parse the overloaded `hunk diff` command. */
179211
async function parseDiffCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
180212
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
@@ -341,6 +373,210 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise<P
341373
};
342374
}
343375

376+
/** Parse `hunk session ...` as live-session daemon-backed commands. */
377+
async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
378+
const [subcommand, ...rest] = tokens;
379+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
380+
return {
381+
kind: "help",
382+
text: [
383+
"Usage: hunk session <subcommand> [options]",
384+
"",
385+
"Inspect and control live Hunk review sessions through the local daemon.",
386+
"",
387+
"Commands:",
388+
" hunk session list",
389+
" hunk session get <session-id>",
390+
" hunk session get --repo <path>",
391+
" hunk session context <session-id>",
392+
" hunk session context --repo <path>",
393+
" hunk session navigate <session-id> --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
394+
" hunk session comment add <session-id> --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
395+
].join("\n") + "\n",
396+
};
397+
}
398+
399+
if (subcommand === "list") {
400+
const command = new Command("session list").description("list live Hunk sessions").option("--json", "emit structured JSON");
401+
let parsedOptions: { json?: boolean } = {};
402+
403+
command.action((options: { json?: boolean }) => {
404+
parsedOptions = options;
405+
});
406+
407+
if (rest.includes("--help") || rest.includes("-h")) {
408+
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
409+
}
410+
411+
await parseStandaloneCommand(command, rest);
412+
return {
413+
kind: "session",
414+
action: "list",
415+
output: resolveJsonOutput(parsedOptions),
416+
};
417+
}
418+
419+
if (subcommand === "get" || subcommand === "context") {
420+
const command = new Command(`session ${subcommand}`)
421+
.description(subcommand === "get" ? "show one live Hunk session" : "show the selected file and hunk for one live Hunk session")
422+
.argument("[sessionId]")
423+
.option("--repo <path>", "target the live session whose repo root matches this path")
424+
.option("--json", "emit structured JSON");
425+
426+
let parsedSessionId: string | undefined;
427+
let parsedOptions: { repo?: string; json?: boolean } = {};
428+
429+
command.action((sessionId: string | undefined, options: { repo?: string; json?: boolean }) => {
430+
parsedSessionId = sessionId;
431+
parsedOptions = options;
432+
});
433+
434+
if (rest.includes("--help") || rest.includes("-h")) {
435+
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
436+
}
437+
438+
await parseStandaloneCommand(command, rest);
439+
return {
440+
kind: "session",
441+
action: subcommand,
442+
output: resolveJsonOutput(parsedOptions),
443+
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
444+
};
445+
}
446+
447+
if (subcommand === "navigate") {
448+
const command = new Command("session navigate")
449+
.description("move a live Hunk session to one diff hunk")
450+
.argument("[sessionId]")
451+
.requiredOption("--file <path>", "diff file path as shown by Hunk")
452+
.option("--repo <path>", "target the live session whose repo root matches this path")
453+
.option("--hunk <n>", "1-based hunk number within the file", parsePositiveInt)
454+
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
455+
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
456+
.option("--json", "emit structured JSON");
457+
458+
let parsedSessionId: string | undefined;
459+
let parsedOptions: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean } = { file: "" };
460+
461+
command.action((sessionId: string | undefined, options: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean }) => {
462+
parsedSessionId = sessionId;
463+
parsedOptions = options;
464+
});
465+
466+
if (rest.includes("--help") || rest.includes("-h")) {
467+
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
468+
}
469+
470+
await parseStandaloneCommand(command, rest);
471+
472+
const selectors = [parsedOptions.hunk !== undefined, parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean);
473+
if (selectors.length !== 1) {
474+
throw new Error("Specify exactly one navigation target: --hunk <n>, --old-line <n>, or --new-line <n>.");
475+
}
476+
477+
return {
478+
kind: "session",
479+
action: "navigate",
480+
output: resolveJsonOutput(parsedOptions),
481+
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
482+
filePath: parsedOptions.file,
483+
hunkNumber: parsedOptions.hunk,
484+
side: parsedOptions.oldLine !== undefined ? "old" : parsedOptions.newLine !== undefined ? "new" : undefined,
485+
line: parsedOptions.oldLine ?? parsedOptions.newLine,
486+
};
487+
}
488+
489+
if (subcommand === "comment") {
490+
const [commentSubcommand, ...commentRest] = rest;
491+
if (!commentSubcommand || commentSubcommand === "--help" || commentSubcommand === "-h") {
492+
return {
493+
kind: "help",
494+
text: [
495+
"Usage: hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
496+
"",
497+
"Attach one live inline review note to a diff line.",
498+
].join("\n") + "\n",
499+
};
500+
}
501+
502+
if (commentSubcommand !== "add") {
503+
throw new Error("Only `hunk session comment add` is supported.");
504+
}
505+
506+
const command = new Command("session comment add")
507+
.description("attach one live inline review note")
508+
.argument("[sessionId]")
509+
.requiredOption("--file <path>", "diff file path as shown by Hunk")
510+
.requiredOption("--summary <text>", "short review note")
511+
.option("--repo <path>", "target the live session whose repo root matches this path")
512+
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
513+
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
514+
.option("--rationale <text>", "optional longer explanation")
515+
.option("--author <name>", "optional author label")
516+
.option("--reveal", "jump to and reveal the note")
517+
.option("--no-reveal", "add the note without moving focus")
518+
.option("--json", "emit structured JSON");
519+
520+
let parsedSessionId: string | undefined;
521+
let parsedOptions: {
522+
repo?: string;
523+
file: string;
524+
summary: string;
525+
oldLine?: number;
526+
newLine?: number;
527+
rationale?: string;
528+
author?: string;
529+
reveal?: boolean;
530+
json?: boolean;
531+
} = {
532+
file: "",
533+
summary: "",
534+
};
535+
536+
command.action((sessionId: string | undefined, options: {
537+
repo?: string;
538+
file: string;
539+
summary: string;
540+
oldLine?: number;
541+
newLine?: number;
542+
rationale?: string;
543+
author?: string;
544+
reveal?: boolean;
545+
json?: boolean;
546+
}) => {
547+
parsedSessionId = sessionId;
548+
parsedOptions = options;
549+
});
550+
551+
if (commentRest.includes("--help") || commentRest.includes("-h")) {
552+
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
553+
}
554+
555+
await parseStandaloneCommand(command, commentRest);
556+
557+
const selectors = [parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean);
558+
if (selectors.length !== 1) {
559+
throw new Error("Specify exactly one comment target: --old-line <n> or --new-line <n>.");
560+
}
561+
562+
return {
563+
kind: "session",
564+
action: "comment-add",
565+
output: resolveJsonOutput(parsedOptions),
566+
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
567+
filePath: parsedOptions.file,
568+
side: parsedOptions.oldLine !== undefined ? "old" : "new",
569+
line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0,
570+
summary: parsedOptions.summary,
571+
rationale: parsedOptions.rationale,
572+
author: parsedOptions.author,
573+
reveal: parsedOptions.reveal ?? true,
574+
};
575+
}
576+
577+
throw new Error(`Unknown session command: ${subcommand}`);
578+
}
579+
344580
/** Parse `hunk mcp serve` as the local daemon entrypoint. */
345581
async function parseMcpCommand(tokens: string[]): Promise<ParsedCliInput> {
346582
const [subcommand, ...rest] = tokens;
@@ -451,6 +687,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
451687
return parseDifftoolCommand(rest, argv);
452688
case "stash":
453689
return parseStashCommand(rest, argv);
690+
case "session":
691+
return parseSessionCommand(rest);
454692
case "mcp":
455693
return parseMcpCommand(rest);
456694
default:

src/core/startup.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { resolveConfiguredCliInput } from "./config";
22
import { loadAppBootstrap } from "./loaders";
33
import { looksLikePatchInput } from "./pager";
44
import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput, type ControllingTerminal } from "./terminal";
5-
import type { AppBootstrap, CliInput, ParsedCliInput } from "./types";
5+
import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types";
66
import { parseCli } from "./cli";
77

88
export type StartupPlan =
@@ -13,6 +13,10 @@ export type StartupPlan =
1313
| {
1414
kind: "mcp-serve";
1515
}
16+
| {
17+
kind: "session-command";
18+
input: SessionCommandInput;
19+
}
1620
| {
1721
kind: "plain-text-pager";
1822
text: string;
@@ -64,6 +68,13 @@ export async function prepareStartupPlan(
6468
};
6569
}
6670

71+
if (parsedCliInput.kind === "session") {
72+
return {
73+
kind: "session-command",
74+
input: parsedCliInput,
75+
};
76+
}
77+
6778
if (parsedCliInput.kind === "pager") {
6879
const stdinText = await readStdinText();
6980

src/core/types.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,57 @@ export interface McpServeCommandInput {
8484
kind: "mcp-serve";
8585
}
8686

87+
export type SessionCommandOutput = "text" | "json";
88+
89+
export interface SessionSelectorInput {
90+
sessionId?: string;
91+
repoRoot?: string;
92+
}
93+
94+
export interface SessionListCommandInput {
95+
kind: "session";
96+
action: "list";
97+
output: SessionCommandOutput;
98+
}
99+
100+
export interface SessionGetCommandInput {
101+
kind: "session";
102+
action: "get" | "context";
103+
output: SessionCommandOutput;
104+
selector: SessionSelectorInput;
105+
}
106+
107+
export interface SessionNavigateCommandInput {
108+
kind: "session";
109+
action: "navigate";
110+
output: SessionCommandOutput;
111+
selector: SessionSelectorInput;
112+
filePath: string;
113+
hunkNumber?: number;
114+
side?: "old" | "new";
115+
line?: number;
116+
}
117+
118+
export interface SessionCommentAddCommandInput {
119+
kind: "session";
120+
action: "comment-add";
121+
output: SessionCommandOutput;
122+
selector: SessionSelectorInput;
123+
filePath: string;
124+
side: "old" | "new";
125+
line: number;
126+
summary: string;
127+
rationale?: string;
128+
author?: string;
129+
reveal: boolean;
130+
}
131+
132+
export type SessionCommandInput =
133+
| SessionListCommandInput
134+
| SessionGetCommandInput
135+
| SessionNavigateCommandInput
136+
| SessionCommentAddCommandInput;
137+
87138
export interface GitCommandInput {
88139
kind: "git";
89140
range?: string;
@@ -135,7 +186,7 @@ export type CliInput =
135186
| PatchCommandInput
136187
| DiffToolCommandInput;
137188

138-
export type ParsedCliInput = CliInput | HelpCommandInput | PagerCommandInput | McpServeCommandInput;
189+
export type ParsedCliInput = CliInput | HelpCommandInput | PagerCommandInput | McpServeCommandInput | SessionCommandInput;
139190

140191
export interface AppBootstrap {
141192
input: CliInput;

0 commit comments

Comments
 (0)