ccsessions-tui 0.1.0__py3-none-any.whl
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.
- ccsessions/__init__.py +1 -0
- ccsessions/__main__.py +22 -0
- ccsessions/core/__init__.py +0 -0
- ccsessions/core/aliases.py +56 -0
- ccsessions/core/cache.py +86 -0
- ccsessions/core/discovery.py +186 -0
- ccsessions/core/live.py +37 -0
- ccsessions/core/manage.py +115 -0
- ccsessions/core/models.py +77 -0
- ccsessions/core/parser.py +191 -0
- ccsessions/core/resume.py +37 -0
- ccsessions/core/settings.py +24 -0
- ccsessions/ui/__init__.py +0 -0
- ccsessions/ui/app.py +1063 -0
- ccsessions_tui-0.1.0.dist-info/METADATA +157 -0
- ccsessions_tui-0.1.0.dist-info/RECORD +19 -0
- ccsessions_tui-0.1.0.dist-info/WHEEL +4 -0
- ccsessions_tui-0.1.0.dist-info/entry_points.txt +2 -0
- ccsessions_tui-0.1.0.dist-info/licenses/LICENSE +21 -0
ccsessions/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ccsessions/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .core.resume import ResumeError, ResumeRequest, execute_resume
|
|
6
|
+
from .ui.app import CCSessionsApp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> int:
|
|
10
|
+
app = CCSessionsApp()
|
|
11
|
+
result = app.run()
|
|
12
|
+
if isinstance(result, ResumeRequest):
|
|
13
|
+
try:
|
|
14
|
+
execute_resume(result) # replaces process; does not return on success
|
|
15
|
+
except ResumeError as e:
|
|
16
|
+
print(f"Resume failed: {e}", file=sys.stderr)
|
|
17
|
+
return 1
|
|
18
|
+
return 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .models import Project
|
|
7
|
+
|
|
8
|
+
DEFAULT_ALIASES_PATH = Path.home() / ".config" / "ccsessions" / "aliases.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectAliases:
|
|
12
|
+
"""Cosmetic display names for projects, keyed by project path.
|
|
13
|
+
|
|
14
|
+
Purely a ccsessions-side overlay — nothing under ~/.claude is touched,
|
|
15
|
+
so the default (path-derived) name is always recoverable by removing
|
|
16
|
+
the alias.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: Path = DEFAULT_ALIASES_PATH) -> None:
|
|
20
|
+
self.path = path
|
|
21
|
+
self._data: dict[str, str] = {}
|
|
22
|
+
self._load()
|
|
23
|
+
|
|
24
|
+
def _load(self) -> None:
|
|
25
|
+
try:
|
|
26
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
27
|
+
except (OSError, json.JSONDecodeError):
|
|
28
|
+
return
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
self._data = {
|
|
31
|
+
k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def get(self, project_path: str) -> str:
|
|
35
|
+
return self._data.get(project_path, "")
|
|
36
|
+
|
|
37
|
+
def set(self, project_path: str, alias: str) -> None:
|
|
38
|
+
self._data[project_path] = alias
|
|
39
|
+
|
|
40
|
+
def remove(self, project_path: str) -> None:
|
|
41
|
+
self._data.pop(project_path, None)
|
|
42
|
+
|
|
43
|
+
def save(self) -> None:
|
|
44
|
+
try:
|
|
45
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = self.path.with_suffix(".tmp")
|
|
47
|
+
tmp.write_text(
|
|
48
|
+
json.dumps(self._data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
49
|
+
)
|
|
50
|
+
tmp.replace(self.path)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def apply(self, projects: list[Project]) -> None:
|
|
55
|
+
for p in projects:
|
|
56
|
+
p.alias = self.get(p.project_path)
|
ccsessions/core/cache.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .models import TokenStats
|
|
8
|
+
|
|
9
|
+
DEFAULT_CACHE_PATH = Path.home() / ".cache" / "ccsessions" / "token-stats.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TokenCache:
|
|
13
|
+
"""Per-JSONL-file token stats cache.
|
|
14
|
+
|
|
15
|
+
An entry stays valid as long as the file's (st_mtime_ns, st_size) are
|
|
16
|
+
unchanged — large finished sessions are read from disk only once.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None:
|
|
20
|
+
self.path = path
|
|
21
|
+
self._entries: dict[str, dict] = {}
|
|
22
|
+
self._dirty = False
|
|
23
|
+
self._load()
|
|
24
|
+
|
|
25
|
+
def _load(self) -> None:
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
28
|
+
except (OSError, json.JSONDecodeError):
|
|
29
|
+
return
|
|
30
|
+
if isinstance(data, dict):
|
|
31
|
+
self._entries = {
|
|
32
|
+
k: v for k, v in data.items() if isinstance(v, dict)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def get(self, jsonl: Path, stat: os.stat_result | None = None) -> TokenStats | None:
|
|
36
|
+
entry = self._entries.get(str(jsonl))
|
|
37
|
+
if not entry:
|
|
38
|
+
return None
|
|
39
|
+
if stat is None:
|
|
40
|
+
try:
|
|
41
|
+
stat = jsonl.stat()
|
|
42
|
+
except OSError:
|
|
43
|
+
return None
|
|
44
|
+
if entry.get("mtime_ns") != stat.st_mtime_ns or entry.get("size") != stat.st_size:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return TokenStats(
|
|
48
|
+
input=int(entry["input"]),
|
|
49
|
+
output=int(entry["output"]),
|
|
50
|
+
cache_read=int(entry["cache_read"]),
|
|
51
|
+
cache_write=int(entry["cache_write"]),
|
|
52
|
+
messages=int(entry["messages"]),
|
|
53
|
+
)
|
|
54
|
+
except (KeyError, TypeError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def put(self, jsonl: Path, stats: TokenStats, stat: os.stat_result) -> None:
|
|
58
|
+
self._entries[str(jsonl)] = {
|
|
59
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
60
|
+
"size": stat.st_size,
|
|
61
|
+
"input": stats.input,
|
|
62
|
+
"output": stats.output,
|
|
63
|
+
"cache_read": stats.cache_read,
|
|
64
|
+
"cache_write": stats.cache_write,
|
|
65
|
+
"messages": stats.messages,
|
|
66
|
+
}
|
|
67
|
+
self._dirty = True
|
|
68
|
+
|
|
69
|
+
def prune(self, existing: set[str]) -> None:
|
|
70
|
+
"""Drop entries for files that no longer exist on disk."""
|
|
71
|
+
stale = [k for k in self._entries if k not in existing]
|
|
72
|
+
for k in stale:
|
|
73
|
+
del self._entries[k]
|
|
74
|
+
self._dirty = True
|
|
75
|
+
|
|
76
|
+
def save(self) -> None:
|
|
77
|
+
if not self._dirty:
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
tmp = self.path.with_suffix(".tmp")
|
|
82
|
+
tmp.write_text(json.dumps(self._entries), encoding="utf-8")
|
|
83
|
+
tmp.replace(self.path)
|
|
84
|
+
self._dirty = False
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .cache import TokenCache
|
|
8
|
+
from .live import get_live_session_ids
|
|
9
|
+
from .manage import ARCHIVE_SUBDIR, PROJECTS_ARCHIVE_DIR
|
|
10
|
+
from .models import Project, Session, TokenStats
|
|
11
|
+
from .parser import extract_cwd, get_token_stats
|
|
12
|
+
|
|
13
|
+
DEFAULT_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_iso(value: object) -> datetime | None:
|
|
17
|
+
if not isinstance(value, str) or not value:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
# normalize to local-naive so we can sort against fileMtime-derived values
|
|
24
|
+
if dt.tzinfo is not None:
|
|
25
|
+
dt = dt.astimezone().replace(tzinfo=None)
|
|
26
|
+
return dt
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_index(index_path: Path) -> tuple[str, dict[str, dict]]:
|
|
30
|
+
if not index_path.is_file():
|
|
31
|
+
return "", {}
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
34
|
+
except (OSError, json.JSONDecodeError):
|
|
35
|
+
return "", {}
|
|
36
|
+
project_path = data.get("originalPath") or ""
|
|
37
|
+
indexed: dict[str, dict] = {}
|
|
38
|
+
for entry in data.get("entries", []) or []:
|
|
39
|
+
sid = entry.get("sessionId")
|
|
40
|
+
if isinstance(sid, str):
|
|
41
|
+
indexed[sid] = entry
|
|
42
|
+
return project_path, indexed
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_session(
|
|
46
|
+
sid: str,
|
|
47
|
+
jsonl: Path,
|
|
48
|
+
entry: dict,
|
|
49
|
+
live_ids: set[str],
|
|
50
|
+
project_path_fallback: str,
|
|
51
|
+
cache: TokenCache | None = None,
|
|
52
|
+
archived: bool = False,
|
|
53
|
+
) -> Session:
|
|
54
|
+
missing = not jsonl.is_file()
|
|
55
|
+
tokens = get_token_stats(jsonl, cache) if not missing else TokenStats()
|
|
56
|
+
mtime: datetime | None = None
|
|
57
|
+
if not missing:
|
|
58
|
+
try:
|
|
59
|
+
mtime = datetime.fromtimestamp(jsonl.stat().st_mtime)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
elif entry.get("fileMtime"):
|
|
63
|
+
try:
|
|
64
|
+
mtime = datetime.fromtimestamp(int(entry["fileMtime"]) / 1000)
|
|
65
|
+
except (ValueError, TypeError):
|
|
66
|
+
pass
|
|
67
|
+
return Session(
|
|
68
|
+
session_id=sid,
|
|
69
|
+
jsonl_path=jsonl,
|
|
70
|
+
project_path=entry.get("projectPath") or project_path_fallback,
|
|
71
|
+
first_prompt=entry.get("firstPrompt") or "",
|
|
72
|
+
summary=entry.get("summary") or "",
|
|
73
|
+
message_count=int(entry.get("messageCount") or 0) or tokens.messages,
|
|
74
|
+
created=_parse_iso(entry.get("created")),
|
|
75
|
+
modified=_parse_iso(entry.get("modified")) or mtime,
|
|
76
|
+
git_branch=entry.get("gitBranch") or "",
|
|
77
|
+
is_sidechain=bool(entry.get("isSidechain", False)),
|
|
78
|
+
is_live=(not missing and not archived) and sid in live_ids,
|
|
79
|
+
is_archived=archived,
|
|
80
|
+
is_missing=missing,
|
|
81
|
+
tokens=tokens,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode_dir_name(name: str) -> str:
|
|
86
|
+
# best-effort fallback when sessions-index.json is missing
|
|
87
|
+
return "/" + name.lstrip("-").replace("-", "/")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_project(
|
|
91
|
+
proj_dir: Path, live_ids: set[str], cache: TokenCache | None = None
|
|
92
|
+
) -> Project:
|
|
93
|
+
project_path, indexed = _load_index(proj_dir / "sessions-index.json")
|
|
94
|
+
|
|
95
|
+
sessions: list[Session] = []
|
|
96
|
+
seen: set[str] = set()
|
|
97
|
+
|
|
98
|
+
for jsonl in proj_dir.glob("*.jsonl"):
|
|
99
|
+
sid = jsonl.stem
|
|
100
|
+
seen.add(sid)
|
|
101
|
+
entry = indexed.get(sid, {})
|
|
102
|
+
sessions.append(_build_session(sid, jsonl, entry, live_ids, project_path, cache))
|
|
103
|
+
|
|
104
|
+
for jsonl in (proj_dir / ARCHIVE_SUBDIR).glob("*.jsonl"):
|
|
105
|
+
sid = jsonl.stem
|
|
106
|
+
if sid in seen:
|
|
107
|
+
continue
|
|
108
|
+
seen.add(sid)
|
|
109
|
+
entry = indexed.get(sid, {})
|
|
110
|
+
sessions.append(
|
|
111
|
+
_build_session(sid, jsonl, entry, live_ids, project_path, cache, archived=True)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for sid, entry in indexed.items():
|
|
115
|
+
if sid in seen:
|
|
116
|
+
continue
|
|
117
|
+
full = entry.get("fullPath")
|
|
118
|
+
path = Path(full) if isinstance(full, str) else proj_dir / f"{sid}.jsonl"
|
|
119
|
+
# include archived entries (no .jsonl on disk) — still useful as history
|
|
120
|
+
sessions.append(_build_session(sid, path, entry, live_ids, project_path, cache))
|
|
121
|
+
|
|
122
|
+
if not project_path:
|
|
123
|
+
# try to recover real cwd from a JSONL event
|
|
124
|
+
for s in sessions:
|
|
125
|
+
cwd = extract_cwd(s.jsonl_path)
|
|
126
|
+
if cwd:
|
|
127
|
+
project_path = cwd
|
|
128
|
+
break
|
|
129
|
+
if not project_path:
|
|
130
|
+
project_path = _decode_dir_name(proj_dir.name)
|
|
131
|
+
# backfill sessions that fell back to the lossy decoded path
|
|
132
|
+
for s in sessions:
|
|
133
|
+
if not s.project_path or s.project_path.startswith("/"):
|
|
134
|
+
if s.project_path == _decode_dir_name(proj_dir.name) or not s.project_path:
|
|
135
|
+
s.project_path = project_path
|
|
136
|
+
|
|
137
|
+
sessions.sort(key=lambda s: s.modified or datetime.min, reverse=True)
|
|
138
|
+
return Project(project_path=project_path, encoded_dir=proj_dir, sessions=sessions)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _latest_modified(p: Project) -> datetime:
|
|
142
|
+
return max((s.modified or datetime.min) for s in p.sessions)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def discover_projects(
|
|
146
|
+
base: Path = DEFAULT_PROJECTS_DIR,
|
|
147
|
+
cache: TokenCache | None = None,
|
|
148
|
+
archive_base: Path | None = None,
|
|
149
|
+
) -> list[Project]:
|
|
150
|
+
"""Scan projects (and archived projects) under ~/.claude.
|
|
151
|
+
|
|
152
|
+
When `archive_base` is None it defaults to PROJECTS_ARCHIVE_DIR, but only
|
|
153
|
+
for the real base directory — callers passing a custom `base` (tests)
|
|
154
|
+
never get the user's archive mixed in unless they ask for it.
|
|
155
|
+
"""
|
|
156
|
+
if not base.is_dir():
|
|
157
|
+
return []
|
|
158
|
+
live_ids = get_live_session_ids()
|
|
159
|
+
projects: list[Project] = []
|
|
160
|
+
for proj_dir in sorted(base.iterdir()):
|
|
161
|
+
if not proj_dir.is_dir():
|
|
162
|
+
continue
|
|
163
|
+
project = _load_project(proj_dir, live_ids, cache)
|
|
164
|
+
if project.sessions:
|
|
165
|
+
projects.append(project)
|
|
166
|
+
# newest project first (by latest session modified)
|
|
167
|
+
projects.sort(key=_latest_modified, reverse=True)
|
|
168
|
+
|
|
169
|
+
if archive_base is None and base == DEFAULT_PROJECTS_DIR:
|
|
170
|
+
archive_base = PROJECTS_ARCHIVE_DIR
|
|
171
|
+
archived: list[Project] = []
|
|
172
|
+
if archive_base is not None and archive_base.is_dir():
|
|
173
|
+
for proj_dir in sorted(archive_base.iterdir()):
|
|
174
|
+
if not proj_dir.is_dir():
|
|
175
|
+
continue
|
|
176
|
+
project = _load_project(proj_dir, live_ids, cache)
|
|
177
|
+
if not project.sessions:
|
|
178
|
+
continue
|
|
179
|
+
project.is_archived = True
|
|
180
|
+
for s in project.sessions:
|
|
181
|
+
s.is_archived = True
|
|
182
|
+
s.is_live = False
|
|
183
|
+
archived.append(project)
|
|
184
|
+
archived.sort(key=_latest_modified, reverse=True)
|
|
185
|
+
# archived projects always come after active ones
|
|
186
|
+
return projects + archived
|
ccsessions/core/live.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_SESSIONS_DIR = Path.home() / ".claude" / "sessions"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _pid_alive(pid: int) -> bool:
|
|
11
|
+
try:
|
|
12
|
+
os.kill(pid, 0)
|
|
13
|
+
except ProcessLookupError:
|
|
14
|
+
return False
|
|
15
|
+
except PermissionError:
|
|
16
|
+
return True
|
|
17
|
+
except OSError:
|
|
18
|
+
return False
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_live_session_ids(base: Path = DEFAULT_SESSIONS_DIR) -> set[str]:
|
|
23
|
+
alive: set[str] = set()
|
|
24
|
+
if not base.is_dir():
|
|
25
|
+
return alive
|
|
26
|
+
for path in base.glob("*.json"):
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
except (OSError, json.JSONDecodeError):
|
|
30
|
+
continue
|
|
31
|
+
pid = data.get("pid")
|
|
32
|
+
sid = data.get("sessionId")
|
|
33
|
+
if not isinstance(pid, int) or not isinstance(sid, str):
|
|
34
|
+
continue
|
|
35
|
+
if _pid_alive(pid):
|
|
36
|
+
alive.add(sid)
|
|
37
|
+
return alive
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .models import Project, Session
|
|
8
|
+
|
|
9
|
+
ARCHIVE_SUBDIR = "archived"
|
|
10
|
+
PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
11
|
+
PROJECTS_ARCHIVE_DIR = Path.home() / ".claude" / "projects-archive"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ManageError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def archive_session(session: Session) -> Path:
|
|
19
|
+
"""Move the .jsonl into the archived/ subdirectory — it disappears from
|
|
20
|
+
`claude --resume` but stays on disk and can be restored."""
|
|
21
|
+
if not session.jsonl_path.is_file():
|
|
22
|
+
raise ManageError("JSONL file not on this machine")
|
|
23
|
+
dest_dir = session.jsonl_path.parent / ARCHIVE_SUBDIR
|
|
24
|
+
try:
|
|
25
|
+
dest_dir.mkdir(exist_ok=True)
|
|
26
|
+
dest = dest_dir / session.jsonl_path.name
|
|
27
|
+
session.jsonl_path.rename(dest)
|
|
28
|
+
except OSError as e:
|
|
29
|
+
raise ManageError(str(e)) from e
|
|
30
|
+
return dest
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def restore_session(session: Session) -> Path:
|
|
34
|
+
"""Move the .jsonl from archived/ back into the project directory."""
|
|
35
|
+
if not session.jsonl_path.is_file():
|
|
36
|
+
raise ManageError("file not found in the archive")
|
|
37
|
+
if session.jsonl_path.parent.name != ARCHIVE_SUBDIR:
|
|
38
|
+
raise ManageError("session is not archived")
|
|
39
|
+
dest = session.jsonl_path.parent.parent / session.jsonl_path.name
|
|
40
|
+
try:
|
|
41
|
+
session.jsonl_path.rename(dest)
|
|
42
|
+
except OSError as e:
|
|
43
|
+
raise ManageError(str(e)) from e
|
|
44
|
+
return dest
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _remove_index_entry(proj_dir: Path, session_id: str) -> None:
|
|
48
|
+
index_path = proj_dir / "sessions-index.json"
|
|
49
|
+
if not index_path.is_file():
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
53
|
+
except (OSError, json.JSONDecodeError):
|
|
54
|
+
return
|
|
55
|
+
entries = data.get("entries")
|
|
56
|
+
if not isinstance(entries, list):
|
|
57
|
+
return
|
|
58
|
+
kept = [e for e in entries if not (isinstance(e, dict) and e.get("sessionId") == session_id)]
|
|
59
|
+
if len(kept) != len(entries):
|
|
60
|
+
data["entries"] = kept
|
|
61
|
+
try:
|
|
62
|
+
index_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def delete_session(session: Session) -> None:
|
|
68
|
+
"""Delete the transcript file (if present) and its sessions-index.json entry."""
|
|
69
|
+
try:
|
|
70
|
+
if session.jsonl_path.is_file():
|
|
71
|
+
session.jsonl_path.unlink()
|
|
72
|
+
except OSError as e:
|
|
73
|
+
raise ManageError(str(e)) from e
|
|
74
|
+
proj_dir = session.jsonl_path.parent
|
|
75
|
+
if proj_dir.name == ARCHIVE_SUBDIR:
|
|
76
|
+
proj_dir = proj_dir.parent
|
|
77
|
+
_remove_index_entry(proj_dir, session.session_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def archive_project(project: Project) -> Path:
|
|
81
|
+
"""Move the whole project directory into ~/.claude/projects-archive/."""
|
|
82
|
+
try:
|
|
83
|
+
PROJECTS_ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
except OSError as e:
|
|
85
|
+
raise ManageError(str(e)) from e
|
|
86
|
+
dest = PROJECTS_ARCHIVE_DIR / project.encoded_dir.name
|
|
87
|
+
if dest.exists():
|
|
88
|
+
raise ManageError(f"archive already contains directory {dest.name}")
|
|
89
|
+
try:
|
|
90
|
+
shutil.move(str(project.encoded_dir), str(dest))
|
|
91
|
+
except OSError as e:
|
|
92
|
+
raise ManageError(str(e)) from e
|
|
93
|
+
return dest
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def restore_project(project: Project) -> Path:
|
|
97
|
+
"""Move an archived project directory back into ~/.claude/projects/."""
|
|
98
|
+
if not project.encoded_dir.is_dir():
|
|
99
|
+
raise ManageError("project directory not found")
|
|
100
|
+
dest = PROJECTS_DIR / project.encoded_dir.name
|
|
101
|
+
if dest.exists():
|
|
102
|
+
raise ManageError(f"projects directory already contains {dest.name}")
|
|
103
|
+
try:
|
|
104
|
+
shutil.move(str(project.encoded_dir), str(dest))
|
|
105
|
+
except OSError as e:
|
|
106
|
+
raise ManageError(str(e)) from e
|
|
107
|
+
return dest
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def delete_project(project: Project) -> None:
|
|
111
|
+
"""Permanently delete the whole project directory from ~/.claude/projects/."""
|
|
112
|
+
try:
|
|
113
|
+
shutil.rmtree(project.encoded_dir)
|
|
114
|
+
except OSError as e:
|
|
115
|
+
raise ManageError(str(e)) from e
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class TokenStats:
|
|
10
|
+
input: int = 0
|
|
11
|
+
output: int = 0
|
|
12
|
+
cache_read: int = 0
|
|
13
|
+
cache_write: int = 0
|
|
14
|
+
messages: int = 0 # user/assistant message count computed from the JSONL
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def total(self) -> int:
|
|
18
|
+
return self.input + self.output + self.cache_read + self.cache_write
|
|
19
|
+
|
|
20
|
+
def __iadd__(self, other: "TokenStats") -> "TokenStats":
|
|
21
|
+
self.input += other.input
|
|
22
|
+
self.output += other.output
|
|
23
|
+
self.cache_read += other.cache_read
|
|
24
|
+
self.cache_write += other.cache_write
|
|
25
|
+
self.messages += other.messages
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Session:
|
|
31
|
+
session_id: str
|
|
32
|
+
jsonl_path: Path
|
|
33
|
+
project_path: str
|
|
34
|
+
first_prompt: str = ""
|
|
35
|
+
summary: str = ""
|
|
36
|
+
message_count: int = 0
|
|
37
|
+
created: datetime | None = None
|
|
38
|
+
modified: datetime | None = None
|
|
39
|
+
git_branch: str = ""
|
|
40
|
+
is_sidechain: bool = False
|
|
41
|
+
is_live: bool = False
|
|
42
|
+
is_archived: bool = False # .jsonl moved into the archived/ subdirectory
|
|
43
|
+
is_missing: bool = False # index entry exists but the file does not (e.g. another machine)
|
|
44
|
+
tokens: TokenStats = field(default_factory=TokenStats)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def display_title(self) -> str:
|
|
48
|
+
return self.summary or self.first_prompt or self.session_id
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Project:
|
|
53
|
+
project_path: str
|
|
54
|
+
encoded_dir: Path
|
|
55
|
+
sessions: list[Session] = field(default_factory=list)
|
|
56
|
+
is_archived: bool = False # directory lives in ~/.claude/projects-archive/
|
|
57
|
+
alias: str = "" # user-defined display name (see core.aliases)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def default_name(self) -> str:
|
|
61
|
+
name = Path(self.project_path).name
|
|
62
|
+
return name or self.project_path
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def display_name(self) -> str:
|
|
66
|
+
return self.alias or self.default_name
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def total_tokens(self) -> TokenStats:
|
|
70
|
+
agg = TokenStats()
|
|
71
|
+
for s in self.sessions:
|
|
72
|
+
agg += s.tokens
|
|
73
|
+
return agg
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def has_live(self) -> bool:
|
|
77
|
+
return any(s.is_live for s in self.sessions)
|