@brandry/claude-jsonl-compressor 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -56
- package/LICENSE +674 -674
- package/NOTICE +8 -8
- package/README.md +666 -611
- package/SKILL.md +230 -345
- package/agents/openai.yaml +7 -7
- package/bin/claude-jsonl-compressor.cjs +4 -4
- package/bin/claude-jsonl-repair-read-pages.cjs +4 -4
- package/bin/run-python.cjs +77 -77
- package/package.json +60 -60
- package/references/claude-jsonl-compression-format.md +635 -578
- package/scripts/claude_session_tools.py +304 -245
- package/scripts/compress_claude_jsonl.py +8608 -8275
- package/scripts/repair_claude_jsonl.py +627 -627
- package/templates/summary_template_en.md +78 -78
|
@@ -1,245 +1,304 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Utilities for locating and backing up Claude Code session JSONL files.
|
|
3
|
-
|
|
4
|
-
This helper is intentionally generic. It can:
|
|
5
|
-
- list session files under a .claude/projects tree
|
|
6
|
-
- match a session by exact path, file name, or session id without reading files
|
|
7
|
-
- optionally scan titles when --scan-titles is explicitly provided
|
|
8
|
-
- create numbered .backup copies before modification
|
|
9
|
-
|
|
10
|
-
It does not compress JSONL itself; it only prepares a single target file safely.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import os
|
|
18
|
-
import pathlib
|
|
19
|
-
import sys
|
|
20
|
-
from typing import Any, Dict, List, Optional, Sequence
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def configure_stdio() -> None:
|
|
24
|
-
for stream in (sys.stdout, sys.stderr):
|
|
25
|
-
reconfigure = getattr(stream, "reconfigure", None)
|
|
26
|
-
if callable(reconfigure):
|
|
27
|
-
try:
|
|
28
|
-
reconfigure(encoding="utf-8", errors="replace")
|
|
29
|
-
except Exception:
|
|
30
|
-
pass
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def eprint(*parts: object) -> None:
|
|
34
|
-
print(*parts, file=sys.stderr)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
configure_stdio()
|
|
38
|
-
|
|
39
|
-
MIN_SUPPORTED_PYTHON = (3, 10)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def warn_if_python_too_old() -> Optional[str]:
|
|
43
|
-
"""Warn on an unsupported interpreter without blocking the run."""
|
|
44
|
-
if sys.version_info >= MIN_SUPPORTED_PYTHON:
|
|
45
|
-
return None
|
|
46
|
-
running = ".".join(str(part) for part in sys.version_info[:3])
|
|
47
|
-
required = ".".join(str(part) for part in MIN_SUPPORTED_PYTHON)
|
|
48
|
-
message = (
|
|
49
|
-
f"WARNING: running on Python {running}; this project documents Python {required} or newer. "
|
|
50
|
-
"Continuing anyway. Unexpected errors may be caused by the interpreter version."
|
|
51
|
-
)
|
|
52
|
-
eprint(message)
|
|
53
|
-
return message
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
warn_if_python_too_old()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def read_jsonl_records(path: pathlib.Path) -> List[Dict[str, Any]]:
|
|
60
|
-
records: List[Dict[str, Any]] = []
|
|
61
|
-
with path.open("r", encoding="utf-8-sig", errors="replace") as f:
|
|
62
|
-
for line in f:
|
|
63
|
-
line = line.strip()
|
|
64
|
-
if not line:
|
|
65
|
-
continue
|
|
66
|
-
obj = json.loads(line)
|
|
67
|
-
if isinstance(obj, dict):
|
|
68
|
-
records.append(obj)
|
|
69
|
-
return records
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def list_session_files(root: pathlib.Path) -> List[pathlib.Path]:
|
|
73
|
-
if not root.exists():
|
|
74
|
-
return []
|
|
75
|
-
root_resolved = root.resolve()
|
|
76
|
-
found: List[pathlib.Path] = []
|
|
77
|
-
for dir_path, dir_names, file_names in os.walk(root, topdown=True, followlinks=False):
|
|
78
|
-
directory = pathlib.Path(dir_path)
|
|
79
|
-
# Resolve the containing directory once and compare each child against
|
|
80
|
-
# `resolved_directory / name`. Comparing child.resolve() against
|
|
81
|
-
# os.path.abspath(child) instead would reject every entry whenever any
|
|
82
|
-
# component of the root is a Windows 8.3 short name, because resolve()
|
|
83
|
-
# expands short names to their long form and abspath() leaves them as
|
|
84
|
-
# written. A symlink or junction that leaves the directory still
|
|
85
|
-
# resolves somewhere other than resolved_directory / name, so the
|
|
86
|
-
# escape guard is unchanged.
|
|
87
|
-
try:
|
|
88
|
-
resolved_directory = directory.resolve()
|
|
89
|
-
except OSError:
|
|
90
|
-
dir_names[:] = []
|
|
91
|
-
continue
|
|
92
|
-
safe_dirs: List[str] = []
|
|
93
|
-
for name in dir_names:
|
|
94
|
-
child = directory / name
|
|
95
|
-
try:
|
|
96
|
-
resolved = child.resolve()
|
|
97
|
-
if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
|
|
98
|
-
continue
|
|
99
|
-
except OSError:
|
|
100
|
-
continue
|
|
101
|
-
safe_dirs.append(name)
|
|
102
|
-
dir_names[:] = safe_dirs
|
|
103
|
-
for name in file_names:
|
|
104
|
-
if not name.lower().endswith(".jsonl"):
|
|
105
|
-
continue
|
|
106
|
-
candidate = directory / name
|
|
107
|
-
try:
|
|
108
|
-
resolved = candidate.resolve()
|
|
109
|
-
if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
|
|
110
|
-
continue
|
|
111
|
-
if not candidate.is_file():
|
|
112
|
-
continue
|
|
113
|
-
except OSError:
|
|
114
|
-
continue
|
|
115
|
-
found.append(candidate)
|
|
116
|
-
return sorted(found)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Utilities for locating and backing up Claude Code session JSONL files.
|
|
3
|
+
|
|
4
|
+
This helper is intentionally generic. It can:
|
|
5
|
+
- list session files under a .claude/projects tree
|
|
6
|
+
- match a session by exact path, file name, or session id without reading files
|
|
7
|
+
- optionally scan titles when --scan-titles is explicitly provided
|
|
8
|
+
- create numbered .backup copies before modification
|
|
9
|
+
|
|
10
|
+
It does not compress JSONL itself; it only prepares a single target file safely.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import pathlib
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def configure_stdio() -> None:
|
|
24
|
+
for stream in (sys.stdout, sys.stderr):
|
|
25
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
26
|
+
if callable(reconfigure):
|
|
27
|
+
try:
|
|
28
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def eprint(*parts: object) -> None:
|
|
34
|
+
print(*parts, file=sys.stderr)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
configure_stdio()
|
|
38
|
+
|
|
39
|
+
MIN_SUPPORTED_PYTHON = (3, 10)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def warn_if_python_too_old() -> Optional[str]:
|
|
43
|
+
"""Warn on an unsupported interpreter without blocking the run."""
|
|
44
|
+
if sys.version_info >= MIN_SUPPORTED_PYTHON:
|
|
45
|
+
return None
|
|
46
|
+
running = ".".join(str(part) for part in sys.version_info[:3])
|
|
47
|
+
required = ".".join(str(part) for part in MIN_SUPPORTED_PYTHON)
|
|
48
|
+
message = (
|
|
49
|
+
f"WARNING: running on Python {running}; this project documents Python {required} or newer. "
|
|
50
|
+
"Continuing anyway. Unexpected errors may be caused by the interpreter version."
|
|
51
|
+
)
|
|
52
|
+
eprint(message)
|
|
53
|
+
return message
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
warn_if_python_too_old()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_jsonl_records(path: pathlib.Path) -> List[Dict[str, Any]]:
|
|
60
|
+
records: List[Dict[str, Any]] = []
|
|
61
|
+
with path.open("r", encoding="utf-8-sig", errors="replace") as f:
|
|
62
|
+
for line in f:
|
|
63
|
+
line = line.strip()
|
|
64
|
+
if not line:
|
|
65
|
+
continue
|
|
66
|
+
obj = json.loads(line)
|
|
67
|
+
if isinstance(obj, dict):
|
|
68
|
+
records.append(obj)
|
|
69
|
+
return records
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_session_files(root: pathlib.Path) -> List[pathlib.Path]:
|
|
73
|
+
if not root.exists():
|
|
74
|
+
return []
|
|
75
|
+
root_resolved = root.resolve()
|
|
76
|
+
found: List[pathlib.Path] = []
|
|
77
|
+
for dir_path, dir_names, file_names in os.walk(root, topdown=True, followlinks=False):
|
|
78
|
+
directory = pathlib.Path(dir_path)
|
|
79
|
+
# Resolve the containing directory once and compare each child against
|
|
80
|
+
# `resolved_directory / name`. Comparing child.resolve() against
|
|
81
|
+
# os.path.abspath(child) instead would reject every entry whenever any
|
|
82
|
+
# component of the root is a Windows 8.3 short name, because resolve()
|
|
83
|
+
# expands short names to their long form and abspath() leaves them as
|
|
84
|
+
# written. A symlink or junction that leaves the directory still
|
|
85
|
+
# resolves somewhere other than resolved_directory / name, so the
|
|
86
|
+
# escape guard is unchanged.
|
|
87
|
+
try:
|
|
88
|
+
resolved_directory = directory.resolve()
|
|
89
|
+
except OSError:
|
|
90
|
+
dir_names[:] = []
|
|
91
|
+
continue
|
|
92
|
+
safe_dirs: List[str] = []
|
|
93
|
+
for name in dir_names:
|
|
94
|
+
child = directory / name
|
|
95
|
+
try:
|
|
96
|
+
resolved = child.resolve()
|
|
97
|
+
if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
|
|
98
|
+
continue
|
|
99
|
+
except OSError:
|
|
100
|
+
continue
|
|
101
|
+
safe_dirs.append(name)
|
|
102
|
+
dir_names[:] = safe_dirs
|
|
103
|
+
for name in file_names:
|
|
104
|
+
if not name.lower().endswith(".jsonl"):
|
|
105
|
+
continue
|
|
106
|
+
candidate = directory / name
|
|
107
|
+
try:
|
|
108
|
+
resolved = candidate.resolve()
|
|
109
|
+
if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
|
|
110
|
+
continue
|
|
111
|
+
if not candidate.is_file():
|
|
112
|
+
continue
|
|
113
|
+
except OSError:
|
|
114
|
+
continue
|
|
115
|
+
found.append(candidate)
|
|
116
|
+
return sorted(found)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def title_value(obj: Dict[str, Any]) -> str:
|
|
120
|
+
if obj.get("type") not in ("custom-title", "ai-title"):
|
|
121
|
+
return ""
|
|
122
|
+
native = "customTitle" if obj.get("type") == "custom-title" else "aiTitle"
|
|
123
|
+
for key in (native, "title"):
|
|
124
|
+
value = obj.get(key)
|
|
125
|
+
if isinstance(value, str) and value.strip():
|
|
126
|
+
return value
|
|
127
|
+
message = obj.get("message")
|
|
128
|
+
if isinstance(message, dict):
|
|
129
|
+
value = message.get("content")
|
|
130
|
+
if isinstance(value, str) and value.strip():
|
|
131
|
+
return value
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _pointer_chain_session(records: Sequence[Dict[str, Any]], pointer: Dict[str, Any]) -> Optional[str]:
|
|
136
|
+
"""Resolve a session-less pointer only through an unambiguous complete chain."""
|
|
137
|
+
by_uuid: Dict[str, List[Dict[str, Any]]] = {}
|
|
138
|
+
for obj in records:
|
|
139
|
+
uid = obj.get("uuid")
|
|
140
|
+
if isinstance(uid, str) and uid:
|
|
141
|
+
by_uuid.setdefault(uid, []).append(obj)
|
|
142
|
+
current = pointer.get("leafUuid")
|
|
143
|
+
seen = set()
|
|
144
|
+
sessions = set()
|
|
145
|
+
while isinstance(current, str) and current and current not in seen:
|
|
146
|
+
matches = by_uuid.get(current, [])
|
|
147
|
+
if len(matches) != 1:
|
|
148
|
+
return None
|
|
149
|
+
seen.add(current)
|
|
150
|
+
node = matches[0]
|
|
151
|
+
owner = node.get("sessionId")
|
|
152
|
+
if isinstance(owner, str) and owner:
|
|
153
|
+
sessions.add(owner)
|
|
154
|
+
current = node.get("parentUuid")
|
|
155
|
+
if current is None:
|
|
156
|
+
return next(iter(sessions)) if len(sessions) == 1 else None
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def select_session_title(records: Sequence[Dict[str, Any]], session_id: Optional[str] = None) -> Dict[str, Any]:
|
|
161
|
+
"""Select control metadata only; never infer a conversation leaf from a title."""
|
|
162
|
+
sessions = {obj["sessionId"] for obj in records
|
|
163
|
+
if isinstance(obj.get("sessionId"), str) and obj["sessionId"]}
|
|
164
|
+
if session_id is None:
|
|
165
|
+
pointer = next((obj for obj in reversed(records) if obj.get("type") == "last-prompt"), None)
|
|
166
|
+
session_id = pointer.get("sessionId") if pointer else None
|
|
167
|
+
if not isinstance(session_id, str) or not session_id:
|
|
168
|
+
session_id = (_pointer_chain_session(records, pointer) if pointer is not None
|
|
169
|
+
else next(iter(sessions)) if len(sessions) == 1 else None)
|
|
170
|
+
selected: Dict[str, Tuple[int, Dict[str, Any]]] = {}
|
|
171
|
+
ambiguous = 0
|
|
172
|
+
for index, obj in enumerate(records):
|
|
173
|
+
kind = obj.get("type")
|
|
174
|
+
if kind not in ("custom-title", "ai-title") or not title_value(obj):
|
|
175
|
+
continue
|
|
176
|
+
if "uuid" in obj or "parentUuid" in obj:
|
|
177
|
+
ambiguous += 1
|
|
178
|
+
continue
|
|
179
|
+
owner = obj.get("sessionId")
|
|
180
|
+
if owner is None or owner == "":
|
|
181
|
+
if not session_id or sessions != {session_id}:
|
|
182
|
+
ambiguous += 1
|
|
183
|
+
continue
|
|
184
|
+
elif not isinstance(owner, str) or owner != session_id:
|
|
185
|
+
continue
|
|
186
|
+
selected[kind] = (index, obj)
|
|
187
|
+
entry = selected.get("custom-title") or selected.get("ai-title")
|
|
188
|
+
return {"status": "selected" if entry else "ambiguous" if ambiguous else "absent",
|
|
189
|
+
"index": entry[0] if entry else None, "record": entry[1] if entry else None,
|
|
190
|
+
"ambiguousCount": ambiguous}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def extract_hint(path: pathlib.Path) -> str:
|
|
194
|
+
try:
|
|
195
|
+
records = read_jsonl_records(path)
|
|
196
|
+
except Exception:
|
|
197
|
+
return ""
|
|
198
|
+
return title_value(select_session_title(records).get("record") or {}).strip()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def numbered_backup_path(path: pathlib.Path) -> pathlib.Path:
|
|
202
|
+
base = path.with_suffix(path.suffix + ".backup")
|
|
203
|
+
if not base.exists():
|
|
204
|
+
return base
|
|
205
|
+
for i in range(1, 1000):
|
|
206
|
+
candidate = path.with_suffix(path.suffix + f".backup{i}")
|
|
207
|
+
if not candidate.exists():
|
|
208
|
+
return candidate
|
|
209
|
+
raise RuntimeError(f"could not find free backup name for {path}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def create_backup(path: pathlib.Path) -> pathlib.Path:
|
|
213
|
+
source_bytes = path.read_bytes()
|
|
214
|
+
for _attempt in range(1000):
|
|
215
|
+
backup = numbered_backup_path(path)
|
|
216
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
try:
|
|
218
|
+
with backup.open("xb") as stream:
|
|
219
|
+
stream.write(source_bytes)
|
|
220
|
+
stream.flush()
|
|
221
|
+
os.fsync(stream.fileno())
|
|
222
|
+
except FileExistsError:
|
|
223
|
+
continue
|
|
224
|
+
if backup.read_bytes() == source_bytes:
|
|
225
|
+
return backup
|
|
226
|
+
# Keep a concurrently replaced path untouched and try the next number.
|
|
227
|
+
raise RuntimeError(f"could not create and verify a numbered backup for {path}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def is_same_or_inside(path: pathlib.Path, root: pathlib.Path) -> bool:
|
|
231
|
+
try:
|
|
232
|
+
path.resolve().relative_to(root.resolve())
|
|
233
|
+
return True
|
|
234
|
+
except (ValueError, OSError):
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _candidate_label(root: pathlib.Path, path: pathlib.Path) -> str:
|
|
239
|
+
try:
|
|
240
|
+
return str(path.resolve().relative_to(root.resolve()))
|
|
241
|
+
except ValueError:
|
|
242
|
+
return path.name
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _path_query_match(root: pathlib.Path, path: pathlib.Path, query: str) -> bool:
|
|
246
|
+
q = query.lower()
|
|
247
|
+
name = path.name.lower()
|
|
248
|
+
stem = path.stem.lower()
|
|
249
|
+
if q == name or q == stem:
|
|
250
|
+
return True
|
|
251
|
+
if any(sep in query for sep in ("\\", "/")):
|
|
252
|
+
query_path = pathlib.Path(query)
|
|
253
|
+
if query_path.is_absolute():
|
|
254
|
+
try:
|
|
255
|
+
return path.resolve() == query_path.resolve()
|
|
256
|
+
except OSError:
|
|
257
|
+
return False
|
|
258
|
+
normalized_query = query.replace("\\", "/").lower()
|
|
259
|
+
relative = _candidate_label(root, path).replace("\\", "/").lower()
|
|
260
|
+
return normalized_query == relative
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def find_unique_session(root: pathlib.Path, query: str, scan_titles: bool = False) -> pathlib.Path:
|
|
265
|
+
files = list_session_files(root)
|
|
266
|
+
candidates = [path for path in files if _path_query_match(root, path, query)]
|
|
267
|
+
if not candidates and scan_titles:
|
|
268
|
+
candidates = [path for path in files if query.lower() in extract_hint(path).lower()]
|
|
269
|
+
if not candidates:
|
|
270
|
+
raise FileNotFoundError(f"no session matches {query!r} under {root}")
|
|
271
|
+
if len(candidates) > 1:
|
|
272
|
+
lines = [f"- {_candidate_label(root, p)}" for p in candidates[:20]]
|
|
273
|
+
raise RuntimeError("query matched multiple sessions:\n" + "\n".join(lines))
|
|
274
|
+
return candidates[0]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
278
|
+
parser = argparse.ArgumentParser(
|
|
279
|
+
prog="claude-session-tools",
|
|
280
|
+
description="Locate exactly one Claude Code session JSONL and optionally create a verified numbered backup.",
|
|
281
|
+
)
|
|
282
|
+
parser.add_argument("--root", type=pathlib.Path, required=True, help="Root .claude/projects directory")
|
|
283
|
+
parser.add_argument("--query", required=True, help="Exact session id, filename, relative/absolute path, or title substring when --scan-titles is set")
|
|
284
|
+
parser.add_argument(
|
|
285
|
+
"--scan-titles",
|
|
286
|
+
action="store_true",
|
|
287
|
+
help="Read candidate JSONL files to match custom-title/ai-title. Omit this for privacy-sensitive exact path/id/filename lookup.",
|
|
288
|
+
)
|
|
289
|
+
parser.add_argument("--backup", action="store_true", help="Create a numbered .backup copy of the matched file")
|
|
290
|
+
args = parser.parse_args(argv)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
target = find_unique_session(args.root, args.query, scan_titles=args.scan_titles)
|
|
294
|
+
print(str(target))
|
|
295
|
+
if args.backup:
|
|
296
|
+
print(str(create_backup(target)))
|
|
297
|
+
return 0
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
eprint(f"ERROR: {exc}")
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
raise SystemExit(main())
|