Skip to content

Commit 395cfed

Browse files
Address Timon's feedback
1 parent 78aed47 commit 395cfed

5 files changed

Lines changed: 57 additions & 41 deletions

File tree

models/error_codes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class ErrorCode(StrEnum):
1414
SEARCH_INDEX_UNAVAILABLE = "SEARCH_INDEX_UNAVAILABLE"
1515
INVALID_PATH = "INVALID_PATH"
1616
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
17+
SESSION_AMBIGUOUS_PREFIX = "SESSION_AMBIGUOUS_PREFIX"
18+
PROJECTS_DIR_NOT_FOUND = "PROJECTS_DIR_NOT_FOUND"
1719
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"
1820
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
1921
PARSE_ERROR = "PARSE_ERROR"

scripts/export.py

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import sys
2525
import zipfile
2626
from datetime import datetime
27-
from typing import cast
27+
from typing import NoReturn, cast
2828

2929
# Allow running from repo root or scripts/ directory
3030
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -182,11 +182,7 @@ def cmd_list(args):
182182
base_dir = getattr(args, "base_dir", None) or get_claude_projects_dir()
183183
project_filter = getattr(args, "project", None)
184184

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

191187
projects = list_projects(base_dir)
192188
if project_filter:
@@ -249,11 +245,7 @@ def cmd_stats(args):
249245
session_id = getattr(args, "session", None)
250246
fmt = getattr(args, "format", "text") or "text"
251247

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

258250
if session_id:
259251
_session_stats(session_id, base_dir, fmt)
@@ -459,11 +451,7 @@ def cmd_export(args):
459451
session_filter = getattr(args, "session", None)
460452
exclusion_rules_path = getattr(args, "exclude_rules", None)
461453

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

468456
rules = load_rules(resolve_exclusion_rules_path(exclusion_rules_path))
469457

@@ -725,7 +713,7 @@ def _find_session(session_id: str, base_dir: str) -> str | None:
725713
return matches[0]["path"]
726714
if len(matches) > 1:
727715
_die(
728-
ErrorCode.SESSION_NOT_FOUND,
716+
ErrorCode.SESSION_AMBIGUOUS_PREFIX,
729717
detail=(
730718
f"Ambiguous prefix '{session_id}' matches {len(matches)} sessions:\n"
731719
+ "\n".join(f" {m['id']}" for m in matches)
@@ -774,14 +762,22 @@ def _save_state(sessions: dict, count: int, out_dir: str):
774762
atomic_write_export_state(disk, STATE_FILE)
775763

776764

765+
def _require_projects_dir(base_dir: str) -> None:
766+
if not os.path.isdir(base_dir):
767+
_die(
768+
ErrorCode.PROJECTS_DIR_NOT_FOUND,
769+
detail=f"Claude Code projects directory not found: {base_dir}",
770+
)
771+
772+
777773
def _cli_die_message(code: ErrorCode) -> str:
778774
"""Stable CLI stderr label (not export-scoped)."""
779775
if code == ErrorCode.INTERNAL_ERROR:
780776
return "Command failed"
781777
return failure_message_for_code(code)
782778

783779

784-
def _die(code: ErrorCode, *, detail: str | None = None) -> None:
780+
def _die(code: ErrorCode, *, detail: str | None = None) -> NoReturn:
785781
print(f"Error: {code.value}{_cli_die_message(code)}", file=sys.stderr)
786782
if detail:
787783
print(detail, file=sys.stderr)

tests/test_error_codes.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from api.error_codes import ErrorCode
1212
from api.search import _IndexSearchOutcome
1313
from tests.conftest import assert_error_response
14-
from tests.test_cli_e2e import _run_cli
14+
from tests.test_cli_e2e import FIXTURES, _run_cli
1515

1616

1717
@pytest.mark.parametrize(
@@ -100,18 +100,19 @@ def _raise_live_scan_failure(*_args, **_kwargs):
100100
@pytest.mark.parametrize(
101101
"argv,code",
102102
[
103-
(["export", "--base-dir"], "INTERNAL_ERROR"),
104-
(["stats", "--base-dir"], "INTERNAL_ERROR"),
105-
(["list", "--base-dir"], "INTERNAL_ERROR"),
103+
(["export", "--base-dir"], "PROJECTS_DIR_NOT_FOUND"),
104+
(["stats", "--base-dir"], "PROJECTS_DIR_NOT_FOUND"),
105+
(["list", "--base-dir"], "PROJECTS_DIR_NOT_FOUND"),
106106
],
107107
)
108108
def test_cli_missing_projects_dir_surfaces_error_code(tmp_path, argv: list[str], code: str) -> None:
109109
missing = tmp_path / "missing-claude-dir"
110110
proc = _run_cli([*argv, str(missing)])
111111
assert proc.returncode == 1
112112
assert code in proc.stderr
113+
assert "Claude projects directory not found" in proc.stderr
113114
assert "Failed to export session" not in proc.stderr
114-
assert "Command failed" in proc.stderr
115+
assert "Command failed" not in proc.stderr
115116
assert "Traceback" not in proc.stderr
116117

117118

@@ -134,3 +135,18 @@ def test_cli_export_session_not_found_surfaces_code(tmp_path, capsys) -> None:
134135
assert exc_info.value.code == 1
135136
captured = capsys.readouterr()
136137
assert "SESSION_NOT_FOUND" in captured.err
138+
139+
140+
def test_cli_ambiguous_session_prefix_surfaces_code(tmp_path) -> None:
141+
base = tmp_path / "projects"
142+
project = base / "test-project"
143+
project.mkdir(parents=True)
144+
content = (FIXTURES / "session_minimal.jsonl").read_text(encoding="utf-8")
145+
content = content.replace("demo-project", "test-project")
146+
(project / "session_aaa111.jsonl").write_text(content, encoding="utf-8")
147+
(project / "session_aaa222.jsonl").write_text(content, encoding="utf-8")
148+
149+
proc = _run_cli(["stats", "--base-dir", str(base), "--session", "session_aaa"])
150+
assert proc.returncode == 1
151+
assert "SESSION_AMBIGUOUS_PREFIX" in proc.stderr
152+
assert "SESSION_NOT_FOUND" not in proc.stderr.split("SESSION_AMBIGUOUS_PREFIX")[0]

tests/test_tool_dispatch_sync.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,41 +77,39 @@ def _parse_frontend_tool_result_renderers(path: Path) -> frozenset[str]:
7777
return _parse_frontend_registry_keys(path, "export const TOOL_RESULT_RENDERERS = {")
7878

7979

80+
def _parse_top_level_function_body(text: str, func_name: str) -> str:
81+
"""Slice a module-level ``def`` through the next top-level ``def`` (if any)."""
82+
match = re.search(
83+
rf"^def {re.escape(func_name)}\(.*?(?=^\ndef )",
84+
text,
85+
re.DOTALL | re.MULTILINE,
86+
)
87+
if not match:
88+
msg = f"Could not find {func_name!r} in module"
89+
raise ValueError(msg)
90+
return match.group(0)
91+
92+
8093
def _parse_dispatch_builder_result_types(path: Path) -> frozenset[str]:
8194
"""Extract ``result_type`` literals from tool-result dispatch builders."""
8295
text = path.read_text(encoding="utf-8")
8396
types = set(re.findall(r'result\["result_type"\]\s*=\s*"([^"]+)"', text))
97+
# "unknown" is the dispatch fallback when no builder sets result_type; no renderer.
8498
types.discard("unknown")
8599
return frozenset(types)
86100

87101

88102
def _parse_md_exporter_tool_result_handlers(path: Path) -> frozenset[str]:
89103
"""Extract ``result_type`` values handled by ``_render_tool_result`` branches."""
90104
text = path.read_text(encoding="utf-8")
91-
match = re.search(
92-
r"def _render_tool_result\(.*?(?=\ndef _render_system)",
93-
text,
94-
re.DOTALL,
95-
)
96-
if not match:
97-
msg = f"Could not find _render_tool_result in {path}"
98-
raise ValueError(msg)
99-
body = match.group(0)
105+
body = _parse_top_level_function_body(text, "_render_tool_result")
100106
return frozenset(re.findall(r'(?:if|elif) rt == "([^"]+)"', body))
101107

102108

103109
def _parse_md_exporter_tool_use_handlers(path: Path) -> frozenset[str]:
104110
"""Extract tool names handled by ``_render_tool_use`` if/elif branches."""
105111
text = path.read_text(encoding="utf-8")
106-
match = re.search(
107-
r"def _render_tool_use\(.*?(?=\ndef _render_tool_result)",
108-
text,
109-
re.DOTALL,
110-
)
111-
if not match:
112-
msg = f"Could not find _render_tool_use in {path}"
113-
raise ValueError(msg)
114-
body = match.group(0)
112+
body = _parse_top_level_function_body(text, "_render_tool_use")
115113
names = set(re.findall(r'(?:if|elif) name == "([^"]+)"', body))
116114
for tuple_match in re.finditer(r"elif name in \(([^)]+)\)", body):
117115
names.update(re.findall(r'"([^"]+)"', tuple_match.group(1)))

utils/export_engine.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ def failure_message_for_code(code: ErrorCode) -> str:
9090
return "Failed to export session"
9191
if code == ErrorCode.SESSION_NOT_FOUND:
9292
return "Session not found"
93+
if code == ErrorCode.SESSION_AMBIGUOUS_PREFIX:
94+
return "Session prefix matches more than one session"
95+
if code == ErrorCode.PROJECTS_DIR_NOT_FOUND:
96+
return "Claude projects directory not found"
9397
if code == ErrorCode.EXPORT_ALL_FAILED:
9498
return "All sessions failed to export"
9599
return "Export failed"

0 commit comments

Comments
 (0)