scc-cli 1.4.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.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
scc_cli/sessions.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manage Claude Code sessions.
|
|
3
|
+
|
|
4
|
+
Track recent sessions, workspaces, containers, and enable resuming.
|
|
5
|
+
|
|
6
|
+
Container Linking:
|
|
7
|
+
- Sessions are linked to their Docker container names
|
|
8
|
+
- Container names are deterministic: scc-<workspace>-<hash>
|
|
9
|
+
- This enables seamless resume of Claude Code conversations
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, cast
|
|
17
|
+
|
|
18
|
+
from . import config
|
|
19
|
+
from .constants import AGENT_CONFIG_DIR
|
|
20
|
+
from .utils.locks import file_lock, lock_path
|
|
21
|
+
|
|
22
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
23
|
+
# Data Classes
|
|
24
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SessionRecord:
|
|
29
|
+
"""A recorded Claude Code session with container linking."""
|
|
30
|
+
|
|
31
|
+
workspace: str
|
|
32
|
+
team: str | None = None
|
|
33
|
+
name: str | None = None
|
|
34
|
+
container_name: str | None = None
|
|
35
|
+
branch: str | None = None
|
|
36
|
+
last_used: str | None = None
|
|
37
|
+
created_at: str | None = None
|
|
38
|
+
schema_version: int = 1 # For future migration support
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
"""Convert the record to a dictionary for JSON serialization."""
|
|
42
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: dict[str, Any]) -> "SessionRecord":
|
|
46
|
+
"""Create a SessionRecord from a dictionary."""
|
|
47
|
+
return cls(
|
|
48
|
+
workspace=data.get("workspace", ""),
|
|
49
|
+
team=data.get("team"),
|
|
50
|
+
name=data.get("name"),
|
|
51
|
+
container_name=data.get("container_name"),
|
|
52
|
+
branch=data.get("branch"),
|
|
53
|
+
last_used=data.get("last_used"),
|
|
54
|
+
created_at=data.get("created_at"),
|
|
55
|
+
schema_version=data.get("schema_version", 1),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
60
|
+
# Session Operations
|
|
61
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_most_recent() -> dict[str, Any] | None:
|
|
65
|
+
"""
|
|
66
|
+
Return the most recently used session.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Session dict with workspace, team, container_name, etc. or None if no sessions.
|
|
70
|
+
"""
|
|
71
|
+
sessions = _load_sessions()
|
|
72
|
+
|
|
73
|
+
if not sessions:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
# Sort by last_used descending and return first
|
|
77
|
+
sessions.sort(key=lambda s: s.get("last_used", ""), reverse=True)
|
|
78
|
+
return sessions[0]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_recent(limit: int = 10) -> list[dict[str, Any]]:
|
|
82
|
+
"""
|
|
83
|
+
Return recent sessions with container and relative time info.
|
|
84
|
+
|
|
85
|
+
Returns list of dicts with: name, workspace, team, last_used, container_name, branch
|
|
86
|
+
"""
|
|
87
|
+
sessions = _load_sessions()
|
|
88
|
+
|
|
89
|
+
# Sort by last_used descending
|
|
90
|
+
sessions.sort(key=lambda s: s.get("last_used", ""), reverse=True)
|
|
91
|
+
|
|
92
|
+
# Limit results
|
|
93
|
+
sessions = sessions[:limit]
|
|
94
|
+
|
|
95
|
+
# Format for display
|
|
96
|
+
result = []
|
|
97
|
+
for s in sessions:
|
|
98
|
+
last_used = s.get("last_used", "")
|
|
99
|
+
if last_used:
|
|
100
|
+
try:
|
|
101
|
+
dt = datetime.fromisoformat(last_used)
|
|
102
|
+
last_used = format_relative_time(dt)
|
|
103
|
+
except ValueError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
result.append(
|
|
107
|
+
{
|
|
108
|
+
"name": s.get("name") or _generate_session_name(s),
|
|
109
|
+
"workspace": s.get("workspace", ""),
|
|
110
|
+
"team": s.get("team"),
|
|
111
|
+
"last_used": last_used,
|
|
112
|
+
"container_name": s.get("container_name"),
|
|
113
|
+
"branch": s.get("branch"),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _generate_session_name(session: dict[str, Any]) -> str:
|
|
121
|
+
"""Generate a display name for a session without an explicit name."""
|
|
122
|
+
workspace = session.get("workspace", "")
|
|
123
|
+
if workspace:
|
|
124
|
+
return Path(workspace).name
|
|
125
|
+
return "Unnamed"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def record_session(
|
|
129
|
+
workspace: str,
|
|
130
|
+
team: str | None = None,
|
|
131
|
+
session_name: str | None = None,
|
|
132
|
+
container_name: str | None = None,
|
|
133
|
+
branch: str | None = None,
|
|
134
|
+
) -> SessionRecord:
|
|
135
|
+
"""
|
|
136
|
+
Record a new session or update an existing one.
|
|
137
|
+
|
|
138
|
+
Key sessions by workspace + branch combination.
|
|
139
|
+
"""
|
|
140
|
+
lock_file = lock_path("sessions")
|
|
141
|
+
with file_lock(lock_file):
|
|
142
|
+
sessions = _load_sessions()
|
|
143
|
+
now = datetime.now().isoformat()
|
|
144
|
+
|
|
145
|
+
# Find existing session for this workspace+branch
|
|
146
|
+
existing_idx = None
|
|
147
|
+
for idx, s in enumerate(sessions):
|
|
148
|
+
if s.get("workspace") == workspace and s.get("branch") == branch:
|
|
149
|
+
existing_idx = idx
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
record = SessionRecord(
|
|
153
|
+
workspace=workspace,
|
|
154
|
+
team=team,
|
|
155
|
+
name=session_name,
|
|
156
|
+
container_name=container_name,
|
|
157
|
+
branch=branch,
|
|
158
|
+
last_used=now,
|
|
159
|
+
created_at=(
|
|
160
|
+
sessions[existing_idx].get("created_at", now) if existing_idx is not None else now
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if existing_idx is not None:
|
|
165
|
+
# Update existing
|
|
166
|
+
sessions[existing_idx] = record.to_dict()
|
|
167
|
+
else:
|
|
168
|
+
# Add new
|
|
169
|
+
sessions.insert(0, record.to_dict())
|
|
170
|
+
|
|
171
|
+
_save_sessions(sessions)
|
|
172
|
+
return record
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def update_session_container(
|
|
176
|
+
workspace: str,
|
|
177
|
+
container_name: str,
|
|
178
|
+
branch: str | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Update the container name for an existing session.
|
|
182
|
+
|
|
183
|
+
Call when a container is created for a session.
|
|
184
|
+
"""
|
|
185
|
+
lock_file = lock_path("sessions")
|
|
186
|
+
with file_lock(lock_file):
|
|
187
|
+
sessions = _load_sessions()
|
|
188
|
+
|
|
189
|
+
for s in sessions:
|
|
190
|
+
if s.get("workspace") == workspace:
|
|
191
|
+
if branch is None or s.get("branch") == branch:
|
|
192
|
+
s["container_name"] = container_name
|
|
193
|
+
s["last_used"] = datetime.now().isoformat()
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
_save_sessions(sessions)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def find_session_by_container(container_name: str) -> dict[str, Any] | None:
|
|
200
|
+
"""
|
|
201
|
+
Find a session by its container name.
|
|
202
|
+
|
|
203
|
+
Use for resume operations.
|
|
204
|
+
"""
|
|
205
|
+
sessions = _load_sessions()
|
|
206
|
+
for s in sessions:
|
|
207
|
+
if s.get("container_name") == container_name:
|
|
208
|
+
return s
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def find_session_by_workspace(
|
|
213
|
+
workspace: str,
|
|
214
|
+
branch: str | None = None,
|
|
215
|
+
) -> dict[str, Any] | None:
|
|
216
|
+
"""
|
|
217
|
+
Find a session by workspace and optionally branch.
|
|
218
|
+
|
|
219
|
+
Return the most recent matching session.
|
|
220
|
+
"""
|
|
221
|
+
sessions = _load_sessions()
|
|
222
|
+
|
|
223
|
+
# Sort by last_used descending
|
|
224
|
+
sessions.sort(key=lambda s: s.get("last_used", ""), reverse=True)
|
|
225
|
+
|
|
226
|
+
for s in sessions:
|
|
227
|
+
if s.get("workspace") == workspace:
|
|
228
|
+
if branch is None or s.get("branch") == branch:
|
|
229
|
+
return s
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_container_for_workspace(
|
|
234
|
+
workspace: str,
|
|
235
|
+
branch: str | None = None,
|
|
236
|
+
) -> str | None:
|
|
237
|
+
"""
|
|
238
|
+
Return the container name for a workspace (and optionally branch).
|
|
239
|
+
|
|
240
|
+
Return None if no container has been recorded.
|
|
241
|
+
"""
|
|
242
|
+
session = find_session_by_workspace(workspace, branch)
|
|
243
|
+
if session:
|
|
244
|
+
return session.get("container_name")
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
249
|
+
# History Management
|
|
250
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def clear_history() -> int:
|
|
254
|
+
"""
|
|
255
|
+
Clear all session history.
|
|
256
|
+
|
|
257
|
+
Return the number of sessions cleared.
|
|
258
|
+
"""
|
|
259
|
+
lock_file = lock_path("sessions")
|
|
260
|
+
with file_lock(lock_file):
|
|
261
|
+
sessions = _load_sessions()
|
|
262
|
+
count = len(sessions)
|
|
263
|
+
_save_sessions([])
|
|
264
|
+
return count
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def remove_session(workspace: str, branch: str | None = None) -> bool:
|
|
268
|
+
"""
|
|
269
|
+
Remove a specific session from history.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
workspace: Workspace path to remove
|
|
273
|
+
branch: Optional branch (if None, removes all sessions for workspace)
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True if session was found and removed
|
|
277
|
+
"""
|
|
278
|
+
lock_file = lock_path("sessions")
|
|
279
|
+
with file_lock(lock_file):
|
|
280
|
+
sessions = _load_sessions()
|
|
281
|
+
original_count = len(sessions)
|
|
282
|
+
|
|
283
|
+
if branch:
|
|
284
|
+
sessions = [
|
|
285
|
+
s
|
|
286
|
+
for s in sessions
|
|
287
|
+
if not (s.get("workspace") == workspace and s.get("branch") == branch)
|
|
288
|
+
]
|
|
289
|
+
else:
|
|
290
|
+
sessions = [s for s in sessions if s.get("workspace") != workspace]
|
|
291
|
+
|
|
292
|
+
_save_sessions(sessions)
|
|
293
|
+
return len(sessions) < original_count
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def prune_orphaned_sessions() -> int:
|
|
297
|
+
"""
|
|
298
|
+
Remove sessions whose workspaces no longer exist.
|
|
299
|
+
|
|
300
|
+
Return the number of sessions pruned.
|
|
301
|
+
"""
|
|
302
|
+
lock_file = lock_path("sessions")
|
|
303
|
+
with file_lock(lock_file):
|
|
304
|
+
sessions = _load_sessions()
|
|
305
|
+
original_count = len(sessions)
|
|
306
|
+
|
|
307
|
+
valid_sessions = [s for s in sessions if Path(s.get("workspace", "")).expanduser().exists()]
|
|
308
|
+
|
|
309
|
+
_save_sessions(valid_sessions)
|
|
310
|
+
return original_count - len(valid_sessions)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
314
|
+
# Claude Code Integration
|
|
315
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_claude_sessions_dir() -> Path:
|
|
319
|
+
"""Return the Claude Code sessions directory."""
|
|
320
|
+
# Claude Code stores sessions in its config directory
|
|
321
|
+
return Path.home() / AGENT_CONFIG_DIR
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_claude_recent_sessions() -> list[dict[Any, Any]]:
|
|
325
|
+
"""
|
|
326
|
+
Return recent sessions from Claude Code's own storage.
|
|
327
|
+
|
|
328
|
+
Read from ~/.claude/ if available.
|
|
329
|
+
Note: Claude Code's session format may change; this is best-effort.
|
|
330
|
+
"""
|
|
331
|
+
claude_dir = get_claude_sessions_dir()
|
|
332
|
+
sessions_file = claude_dir / "sessions.json"
|
|
333
|
+
|
|
334
|
+
if sessions_file.exists():
|
|
335
|
+
try:
|
|
336
|
+
with open(sessions_file) as f:
|
|
337
|
+
data = json.load(f)
|
|
338
|
+
return cast(list[dict[Any, Any]], data.get("sessions", []))
|
|
339
|
+
except (OSError, json.JSONDecodeError):
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
346
|
+
# Internal Helpers
|
|
347
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _migrate_legacy_sessions(sessions: list[dict[Any, Any]]) -> list[dict[Any, Any]]:
|
|
351
|
+
"""Migrate legacy session records to current format.
|
|
352
|
+
|
|
353
|
+
Migrations performed:
|
|
354
|
+
- team == "base" → team = None (standalone mode)
|
|
355
|
+
|
|
356
|
+
This allows sessions created with the old hardcoded "base" fallback
|
|
357
|
+
to be safely loaded without causing "Team Not Found" errors.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
sessions: List of raw session dicts from JSON.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Migrated session list (same list, mutated in place).
|
|
364
|
+
"""
|
|
365
|
+
for session in sessions:
|
|
366
|
+
# Migration: "base" was never a real team, treat as standalone
|
|
367
|
+
if session.get("team") == "base":
|
|
368
|
+
session["team"] = None
|
|
369
|
+
|
|
370
|
+
return sessions
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _load_sessions() -> list[dict[Any, Any]]:
|
|
374
|
+
"""Load and return sessions from the config file.
|
|
375
|
+
|
|
376
|
+
Performs legacy migrations on load to handle sessions saved
|
|
377
|
+
with older schema versions.
|
|
378
|
+
"""
|
|
379
|
+
sessions_file = config.SESSIONS_FILE
|
|
380
|
+
|
|
381
|
+
if sessions_file.exists():
|
|
382
|
+
try:
|
|
383
|
+
with open(sessions_file) as f:
|
|
384
|
+
data = json.load(f)
|
|
385
|
+
sessions = cast(list[dict[Any, Any]], data.get("sessions", []))
|
|
386
|
+
# Apply migrations for legacy sessions
|
|
387
|
+
return _migrate_legacy_sessions(sessions)
|
|
388
|
+
except (OSError, json.JSONDecodeError):
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
return []
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _save_sessions(sessions: list[dict[str, Any]]) -> None:
|
|
395
|
+
"""Save the sessions list to the config file."""
|
|
396
|
+
sessions_file = config.SESSIONS_FILE
|
|
397
|
+
|
|
398
|
+
# Ensure parent directory exists
|
|
399
|
+
sessions_file.parent.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
|
|
401
|
+
with open(sessions_file, "w") as f:
|
|
402
|
+
json.dump({"sessions": sessions}, f, indent=2)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def format_relative_time(dt: datetime) -> str:
|
|
406
|
+
"""Format a datetime as a relative time string (e.g., '2h ago')."""
|
|
407
|
+
now = datetime.now()
|
|
408
|
+
diff = now - dt
|
|
409
|
+
|
|
410
|
+
seconds = diff.total_seconds()
|
|
411
|
+
|
|
412
|
+
if seconds < 60:
|
|
413
|
+
return "just now"
|
|
414
|
+
elif seconds < 3600:
|
|
415
|
+
minutes = int(seconds / 60)
|
|
416
|
+
return f"{minutes}m ago"
|
|
417
|
+
elif seconds < 86400:
|
|
418
|
+
hours = int(seconds / 3600)
|
|
419
|
+
return f"{hours}h ago"
|
|
420
|
+
elif seconds < 604800:
|
|
421
|
+
days = int(seconds / 86400)
|
|
422
|
+
return f"{days}d ago"
|
|
423
|
+
else:
|
|
424
|
+
weeks = int(seconds / 604800)
|
|
425
|
+
return f"{weeks}w ago"
|