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 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)
@@ -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
@@ -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)