@@ -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. */
1626function 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. */
179211async 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. */
345581async 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 :
0 commit comments