3131REPO_ROOT = os .path .dirname (SCRIPT_DIR )
3232sys .path .insert (0 , REPO_ROOT )
3333
34+ from models .error_codes import ErrorCode
3435from utils .exclusion_rules import load_rules , resolve_exclusion_rules_path
3536from utils .export_engine import (
3637 BulkExportResult ,
38+ ExportFailure ,
3739 ExportFormat ,
3840 NoopSink ,
3941 SinceMode ,
4042 ZipSink ,
43+ bulk_export_exit_code ,
44+ dominant_failure_code ,
45+ failure_message_for_code ,
4146 run_bulk_export ,
4247 serialize_manifest_jsonl ,
4348)
@@ -177,7 +182,7 @@ def cmd_list(args):
177182 project_filter = getattr (args , "project" , None )
178183
179184 if not os .path .isdir (base_dir ):
180- _die (f"Claude Code projects directory not found: { base_dir } " )
185+ _die (ErrorCode . INTERNAL_ERROR , detail = f"Claude Code projects directory not found: { base_dir } " )
181186
182187 projects = list_projects (base_dir )
183188 if project_filter :
@@ -236,7 +241,7 @@ def cmd_stats(args):
236241 fmt = getattr (args , "format" , "text" ) or "text"
237242
238243 if not os .path .isdir (base_dir ):
239- _die (f"Claude Code projects directory not found: { base_dir } " )
244+ _die (ErrorCode . INTERNAL_ERROR , detail = f"Claude Code projects directory not found: { base_dir } " )
240245
241246 if session_id :
242247 _session_stats (session_id , base_dir , fmt )
@@ -248,7 +253,7 @@ def _session_stats(session_id: str, base_dir: str, fmt: str):
248253 """Detailed breakdown for one session: tokens, files, commands, cost."""
249254 filepath = _find_session (session_id , base_dir )
250255 if not filepath :
251- _die (f"Session not found: { session_id } " )
256+ _die (ErrorCode . SESSION_NOT_FOUND , detail = session_id )
252257
253258 session = parse_session (filepath )
254259 stats = compute_stats (session )
@@ -406,22 +411,27 @@ def _aggregate_stats(base_dir: str, project_filter: str, fmt: str):
406411
407412
408413def _exit_bulk_export (result : BulkExportResult ) -> None :
409- """Map bulk-export counts to process exit code (CLI wrapper only).
414+ """Map structured bulk-export failures to process exit code (CLI wrapper only).
410415
411416 Prints a summary to stderr on any failure, stdout on clean success.
412417 Raises SystemExit(1) for total failure, SystemExit(2) for partial.
413418 """
414419 n = result .exported_session_count
415- k = result .failure_count
420+ failures = result .failures
421+ k = len (failures )
416422 # "attempted" = exported + failed; excludes untitled/excluded/mtime-skipped
417423 m = n + k
418424 if n > 0 or k > 0 :
419425 dest = sys .stderr if k > 0 else sys .stdout
420426 print (f"Exported { n } of { m } sessions ({ k } failed)" , file = dest )
421- if n == 0 and k > 0 : # total failure
422- sys .exit (1 )
423- elif k > 0 : # partial failure
424- sys .exit (2 )
427+ exit_code = bulk_export_exit_code (result )
428+ if exit_code != 0 :
429+ code = dominant_failure_code (failures )
430+ print (
431+ f" { code .value } : { failure_message_for_code (code )} " ,
432+ file = sys .stderr ,
433+ )
434+ sys .exit (exit_code )
425435
426436
427437def cmd_export (args ):
@@ -436,7 +446,7 @@ def cmd_export(args):
436446 exclusion_rules_path = getattr (args , "exclude_rules" , None )
437447
438448 if not os .path .isdir (base_dir ):
439- _die (f"Claude Code projects directory not found: { base_dir } " )
449+ _die (ErrorCode . INTERNAL_ERROR , detail = f"Claude Code projects directory not found: { base_dir } " )
440450
441451 rules = load_rules (resolve_exclusion_rules_path (exclusion_rules_path ))
442452
@@ -447,7 +457,7 @@ def cmd_export(args):
447457 if session_filter :
448458 filepath = _find_session (session_filter , base_dir )
449459 if not filepath :
450- _die (f"Session not found: { session_filter } " )
460+ _die (ErrorCode . SESSION_NOT_FOUND , detail = session_filter )
451461 session = parse_session (filepath )
452462 stats = compute_stats (session )
453463 _export_single (session , stats , fmt , out_dir )
@@ -465,8 +475,12 @@ def cmd_export(args):
465475
466476 skipped_mtime_unchanged = 0
467477
468- def _on_export_error (sid : str , exc : Exception ) -> None :
469- print (f" Warning: failed to export { sid } : { exc } " , file = sys .stderr )
478+ def _on_export_error (failure : ExportFailure ) -> None :
479+ print (
480+ f" Warning: failed to export { failure .session_id } : "
481+ f"{ failure .code .value } — { failure .message } " ,
482+ file = sys .stderr ,
483+ )
470484
471485 collect_sink = NoopSink ()
472486 export_result = run_bulk_export (
@@ -694,8 +708,11 @@ def _find_session(session_id: str, base_dir: str) -> str | None:
694708 return matches [0 ]["path" ]
695709 if len (matches ) > 1 :
696710 _die (
697- f"Ambiguous prefix '{ session_id } ' matches { len (matches )} sessions:\n "
698- + "\n " .join (f" { m ['id' ]} " for m in matches )
711+ ErrorCode .SESSION_NOT_FOUND ,
712+ detail = (
713+ f"Ambiguous prefix '{ session_id } ' matches { len (matches )} sessions:\n "
714+ + "\n " .join (f" { m ['id' ]} " for m in matches )
715+ ),
699716 )
700717 return None
701718
@@ -740,8 +757,10 @@ def _save_state(sessions: dict, count: int, out_dir: str):
740757 atomic_write_export_state (disk , STATE_FILE )
741758
742759
743- def _die (msg : str ):
744- print (f"Error: { msg } " , file = sys .stderr )
760+ def _die (code : ErrorCode , * , detail : str | None = None ) -> None :
761+ print (f"Error: { code .value } — { failure_message_for_code (code )} " , file = sys .stderr )
762+ if detail :
763+ print (detail , file = sys .stderr )
745764 sys .exit (1 )
746765
747766
0 commit comments