-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsession_path.py
More file actions
120 lines (105 loc) · 4.33 KB
/
session_path.py
File metadata and controls
120 lines (105 loc) · 4.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
"""Finds where Claude Code stores its .jsonl session files on disk and
lists projects/sessions from that directory."""
import json
import logging
import os
import platform
from models.project import ProjectDict, SessionListItemDict
_logger = logging.getLogger(__name__)
def safe_join(base: str, *parts: str) -> str:
"""Join path components and verify the result stays under base.
Raises ValueError if the resolved path escapes the base directory."""
joined = os.path.realpath(os.path.join(base, *parts))
base_resolved = os.path.realpath(base)
if not joined.startswith(base_resolved + os.sep) and joined != base_resolved:
raise ValueError(f"Path escapes base directory: {joined}")
return joined
def get_claude_projects_dir() -> str:
"""~/.claude/projects/ -- handles Windows USERPROFILE vs Unix HOME."""
system = platform.system()
if system == "Windows":
home = os.environ.get("USERPROFILE", os.path.expanduser("~"))
else:
home = os.path.expanduser("~")
return os.path.join(home, ".claude", "projects")
def list_projects(base_dir: str | None = None) -> list[ProjectDict]:
"""Scan the projects dir and return info for each one that has .jsonl files."""
base = base_dir or get_claude_projects_dir()
if not os.path.isdir(base):
return []
projects: list[ProjectDict] = []
for name in sorted(os.listdir(base)):
project_dir = os.path.join(base, name)
if not os.path.isdir(project_dir):
continue
jsonl_files = [
f for f in os.listdir(project_dir)
if f.endswith(".jsonl") and not f.startswith(".")
]
if jsonl_files:
latest_mtime = max(
os.path.getmtime(os.path.join(project_dir, jf))
for jf in jsonl_files
)
from datetime import datetime, timezone
last_modified = datetime.fromtimestamp(
latest_mtime, tz=timezone.utc
).isoformat()
# Read cwd from sessions to get the real project path
display_name = name
for jf in jsonl_files:
candidate = _get_display_name(
os.path.join(project_dir, jf), None
)
if candidate is not None:
display_name = candidate
break
projects.append({
"name": name,
"path": project_dir,
"display_name": display_name,
"session_count": len(jsonl_files),
"last_modified": last_modified,
})
return projects
def _get_display_name(jsonl_path: str, fallback: str | None) -> str | None:
"""Peek at the first entry's cwd field to get a human-readable project path
instead of the hashed directory name."""
try:
with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
entry = json.loads(line)
cwd = entry.get("cwd")
if cwd:
# Normalize: replace backslashes, strip trailing slash
cwd = cwd.replace("\\", "/").rstrip("/")
# Extract last folder name and capitalize first letter
folder = cwd.rsplit("/", 1)[-1]
out = folder[:1].upper() + folder[1:] if folder else cwd
return str(out)
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
_logger.warning(
"Failed to extract display name from %s: %s", jsonl_path, exc
)
return fallback
def list_sessions(project_dir: str) -> list[SessionListItemDict]:
"""Return id, path, size, mtime for each .jsonl file in a project dir."""
sessions: list[SessionListItemDict] = []
if not os.path.isdir(project_dir):
return sessions
for fname in sorted(os.listdir(project_dir)):
if not fname.endswith(".jsonl"):
continue
fpath = os.path.join(project_dir, fname)
session_id = fname.replace(".jsonl", "")
stat = os.stat(fpath)
sessions.append({
"id": session_id,
"path": fpath,
"size_bytes": stat.st_size,
"modified": stat.st_mtime,
})
return sessions