Skip to content

Commit 78aed47

Browse files
fix(cli): tighten CLI errors and apply review fixes
1 parent 27b7f8b commit 78aed47

6 files changed

Lines changed: 73 additions & 71 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ packages = ["api", "utils", "models"]
55
exclude = ["tests/"]
66

77
[tool.pytest.ini_options]
8+
pythonpath = ["."]
89
addopts = "--cov=api --cov=utils --cov-report=term-missing --cov-report=xml:coverage.xml --benchmark-skip"
910
testpaths = ["tests"]
1011
markers = [

scripts/export.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
ZipSink,
4343
bulk_export_exit_code,
4444
dominant_failure_code,
45+
failure_code_for_exception,
4546
failure_message_for_code,
4647
run_bulk_export,
4748
serialize_manifest_jsonl,
@@ -182,7 +183,10 @@ def cmd_list(args):
182183
project_filter = getattr(args, "project", None)
183184

184185
if not os.path.isdir(base_dir):
185-
_die(ErrorCode.INTERNAL_ERROR, detail=f"Claude Code projects directory not found: {base_dir}")
186+
_die(
187+
ErrorCode.INTERNAL_ERROR,
188+
detail=f"Claude Code projects directory not found: {base_dir}",
189+
)
186190

187191
projects = list_projects(base_dir)
188192
if project_filter:
@@ -228,8 +232,13 @@ def _list_sessions(project: dict):
228232
tokens = meta["total_input_tokens"] + meta["total_output_tokens"]
229233
tools = meta["total_tool_calls"]
230234
print(f" {ts:<12} {title:<50} {sid:>10} {tokens:>10,} {tools:>6}")
231-
except Exception as e:
232-
print(f" Warning: failed to parse {s['id'][:10]}: {e}", file=sys.stderr)
235+
except Exception as exc:
236+
code = failure_code_for_exception(exc)
237+
print(
238+
f" Warning: failed to parse {s['id'][:10]}: "
239+
f"{code.value}{failure_message_for_code(code)}",
240+
file=sys.stderr,
241+
)
233242
continue
234243

235244

@@ -241,7 +250,10 @@ def cmd_stats(args):
241250
fmt = getattr(args, "format", "text") or "text"
242251

243252
if not os.path.isdir(base_dir):
244-
_die(ErrorCode.INTERNAL_ERROR, detail=f"Claude Code projects directory not found: {base_dir}")
253+
_die(
254+
ErrorCode.INTERNAL_ERROR,
255+
detail=f"Claude Code projects directory not found: {base_dir}",
256+
)
245257

246258
if session_id:
247259
_session_stats(session_id, base_dir, fmt)
@@ -366,9 +378,11 @@ def _aggregate_stats(base_dir: str, project_filter: str, fmt: str):
366378
if cost is not None:
367379
totals["total_cost"] += cost
368380
totals["has_cost"] = True
369-
except Exception as e:
381+
except Exception as exc:
382+
code = failure_code_for_exception(exc)
370383
print(
371-
f" Warning: failed to parse {s['id'][:10]} in {project['name']}: {e}",
384+
f" Warning: failed to parse {s['id'][:10]} in {project['name']}: "
385+
f"{code.value}{failure_message_for_code(code)}",
372386
file=sys.stderr,
373387
)
374388
continue
@@ -446,7 +460,10 @@ def cmd_export(args):
446460
exclusion_rules_path = getattr(args, "exclude_rules", None)
447461

448462
if not os.path.isdir(base_dir):
449-
_die(ErrorCode.INTERNAL_ERROR, detail=f"Claude Code projects directory not found: {base_dir}")
463+
_die(
464+
ErrorCode.INTERNAL_ERROR,
465+
detail=f"Claude Code projects directory not found: {base_dir}",
466+
)
450467

451468
rules = load_rules(resolve_exclusion_rules_path(exclusion_rules_path))
452469

@@ -757,8 +774,15 @@ def _save_state(sessions: dict, count: int, out_dir: str):
757774
atomic_write_export_state(disk, STATE_FILE)
758775

759776

777+
def _cli_die_message(code: ErrorCode) -> str:
778+
"""Stable CLI stderr label (not export-scoped)."""
779+
if code == ErrorCode.INTERNAL_ERROR:
780+
return "Command failed"
781+
return failure_message_for_code(code)
782+
783+
760784
def _die(code: ErrorCode, *, detail: str | None = None) -> None:
761-
print(f"Error: {code.value}{failure_message_for_code(code)}", file=sys.stderr)
785+
print(f"Error: {code.value}{_cli_die_message(code)}", file=sys.stderr)
762786
if detail:
763787
print(detail, file=sys.stderr)
764788
sys.exit(1)

tests/test_cli_export_exit_codes.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
from __future__ import annotations
44

55
import re
6-
import sys
76
import types
87
from pathlib import Path
98

109
import pytest
1110

12-
REPO_ROOT = Path(__file__).resolve().parent.parent
13-
sys.path.insert(0, str(REPO_ROOT))
14-
1511
import scripts.export as export
1612
from models.error_codes import ErrorCode
1713
from tests.test_cli_e2e import _run_cli, _seed_base_dir
18-
from utils.export_engine import BulkExportResult, ExportFailure, bulk_export_exit_code, dominant_failure_code
14+
from utils.export_engine import (
15+
BulkExportResult,
16+
ExportFailure,
17+
bulk_export_exit_code,
18+
dominant_failure_code,
19+
)
1920
from utils.jsonl_parser import parse_session
2021

2122
_SUMMARY_RE = re.compile(

tests/test_error_codes.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,16 @@
33
from __future__ import annotations
44

55
import json
6-
import sys
76
import types
8-
from pathlib import Path
97

108
import pytest
119

10+
import scripts.export as export_cli
1211
from api.error_codes import ErrorCode
1312
from api.search import _IndexSearchOutcome
1413
from tests.conftest import assert_error_response
1514
from tests.test_cli_e2e import _run_cli
1615

17-
REPO_ROOT = Path(__file__).resolve().parent.parent
18-
sys.path.insert(0, str(REPO_ROOT))
19-
20-
import scripts.export as export_cli
21-
2216

2317
@pytest.mark.parametrize(
2418
"method,path,kwargs,status,code",
@@ -108,13 +102,16 @@ def _raise_live_scan_failure(*_args, **_kwargs):
108102
[
109103
(["export", "--base-dir"], "INTERNAL_ERROR"),
110104
(["stats", "--base-dir"], "INTERNAL_ERROR"),
105+
(["list", "--base-dir"], "INTERNAL_ERROR"),
111106
],
112107
)
113108
def test_cli_missing_projects_dir_surfaces_error_code(tmp_path, argv: list[str], code: str) -> None:
114109
missing = tmp_path / "missing-claude-dir"
115110
proc = _run_cli([*argv, str(missing)])
116111
assert proc.returncode == 1
117112
assert code in proc.stderr
113+
assert "Failed to export session" not in proc.stderr
114+
assert "Command failed" in proc.stderr
118115
assert "Traceback" not in proc.stderr
119116

120117

tests/test_tool_dispatch_sync.py

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,12 @@ def _format_set_diff(expected: frozenset[str], actual: frozenset[str], site: str
4242
return "; ".join(parts)
4343

4444

45-
def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]:
46-
"""Extract ``TOOL_USE_RENDERERS`` keys.
47-
48-
Assumes values are bare identifiers (``Bash: renderBashUse``). Brace-depth
49-
parsing avoids truncating the object body if a value ever contains ``}``.
50-
"""
45+
def _parse_frontend_registry_keys(path: Path, marker: str) -> frozenset[str]:
46+
"""Extract object keys from a ``registry.js`` export block (brace-depth safe)."""
5147
text = path.read_text(encoding="utf-8")
52-
marker = "export const TOOL_USE_RENDERERS = {"
5348
start = text.find(marker)
5449
if start == -1:
55-
msg = f"Could not find TOOL_USE_RENDERERS in {path}"
50+
msg = f"Could not find {marker!r} in {path}"
5651
raise ValueError(msg)
5752
i = start + len(marker)
5853
depth = 1
@@ -65,37 +60,21 @@ def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]:
6560
depth -= 1
6661
i += 1
6762
if depth != 0:
68-
msg = f"Unbalanced braces in TOOL_USE_RENDERERS in {path}"
63+
msg = f"Unbalanced braces after {marker!r} in {path}"
6964
raise ValueError(msg)
7065
body = text[body_start : i - 1]
7166
keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE)
7267
return frozenset(keys)
7368

7469

70+
def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]:
71+
"""Extract ``TOOL_USE_RENDERERS`` keys."""
72+
return _parse_frontend_registry_keys(path, "export const TOOL_USE_RENDERERS = {")
73+
74+
7575
def _parse_frontend_tool_result_renderers(path: Path) -> frozenset[str]:
76-
"""Extract ``TOOL_RESULT_RENDERERS`` keys (brace-depth safe)."""
77-
text = path.read_text(encoding="utf-8")
78-
marker = "export const TOOL_RESULT_RENDERERS = {"
79-
start = text.find(marker)
80-
if start == -1:
81-
msg = f"Could not find TOOL_RESULT_RENDERERS in {path}"
82-
raise ValueError(msg)
83-
i = start + len(marker)
84-
depth = 1
85-
body_start = i
86-
while i < len(text) and depth > 0:
87-
ch = text[i]
88-
if ch == "{":
89-
depth += 1
90-
elif ch == "}":
91-
depth -= 1
92-
i += 1
93-
if depth != 0:
94-
msg = f"Unbalanced braces in TOOL_RESULT_RENDERERS in {path}"
95-
raise ValueError(msg)
96-
body = text[body_start : i - 1]
97-
keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE)
98-
return frozenset(keys)
76+
"""Extract ``TOOL_RESULT_RENDERERS`` keys."""
77+
return _parse_frontend_registry_keys(path, "export const TOOL_RESULT_RENDERERS = {")
9978

10079

10180
def _parse_dispatch_builder_result_types(path: Path) -> frozenset[str]:

utils/export_engine.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import json
66
import posixpath
77
import zipfile
8+
from collections import Counter
89
from dataclasses import dataclass, field
910
from datetime import date, datetime, timezone
10-
from collections import Counter
1111
from typing import Any, Callable, Literal, Protocol
1212

1313
from models.error_codes import ErrorCode
@@ -95,24 +95,6 @@ def failure_message_for_code(code: ErrorCode) -> str:
9595
return "Export failed"
9696

9797

98-
def dominant_failure_code(failures: list[ExportFailure]) -> ErrorCode:
99-
"""Return the most common structured failure code in a bulk export run."""
100-
if not failures:
101-
msg = "failures must not be empty"
102-
raise ValueError(msg)
103-
counts = Counter(item.code for item in failures)
104-
return counts.most_common(1)[0][0]
105-
106-
107-
def bulk_export_exit_code(result: BulkExportResult) -> int:
108-
"""Map structured bulk-export failures to CLI POSIX exit code (0/1/2)."""
109-
if not result.failures:
110-
return 0
111-
if result.exported_session_count == 0:
112-
return 1
113-
return 2
114-
115-
11698
@dataclass
11799
class BulkExportResult:
118100
"""Outcome of a bulk export run."""
@@ -136,6 +118,24 @@ def failure_count(self) -> int:
136118
return len(self.failures)
137119

138120

121+
def dominant_failure_code(failures: list[ExportFailure]) -> ErrorCode:
122+
"""Return the most common structured failure code in a bulk export run."""
123+
if not failures:
124+
msg = "failures must not be empty"
125+
raise ValueError(msg)
126+
counts = Counter(item.code for item in failures)
127+
return counts.most_common(1)[0][0]
128+
129+
130+
def bulk_export_exit_code(result: BulkExportResult) -> int:
131+
"""Map structured bulk-export failures to CLI POSIX exit code (0/1/2)."""
132+
if not result.failures:
133+
return 0
134+
if result.exported_session_count == 0:
135+
return 1
136+
return 2
137+
138+
139139
class ExportSink(Protocol):
140140
"""Receives exported session files and final manifest."""
141141

0 commit comments

Comments
 (0)