scc-cli 1.5.3__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.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,200 @@
1
+ """Suspicious directory detection for workspace resolution.
2
+
3
+ This module provides functions to detect whether a directory is inappropriate
4
+ for use as a workspace root. A "suspicious" directory is one that should
5
+ require explicit user confirmation before launching a session.
6
+
7
+ Suspicious directories include:
8
+ - User's home directory itself (e.g., /Users/dev, /home/user)
9
+ - System directories (/, /tmp, /var, /usr, /etc, /opt, etc.)
10
+ - Common non-project locations (Downloads, Desktop, Documents, Library)
11
+ - Windows system directories (C:\\, C:\\Windows, C:\\Program Files)
12
+ - Drive roots on Windows (D:\\, etc.)
13
+
14
+ The rationale is:
15
+ 1. These locations typically don't represent a single project
16
+ 2. Mounting them into a container exposes too much data
17
+ 3. Users who explicitly provide such paths should confirm their intent
18
+
19
+ This logic is extracted from ui/wizard.py's _is_suspicious_directory for reuse
20
+ across the codebase without UI dependencies.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ # Unix directories that should NOT be used as workspace
29
+ _SUSPICIOUS_DIRS_UNIX: frozenset[str] = frozenset(
30
+ {
31
+ "/",
32
+ "/tmp",
33
+ "/var",
34
+ "/usr",
35
+ "/etc",
36
+ "/opt",
37
+ "/proc",
38
+ "/dev",
39
+ "/sys",
40
+ "/run",
41
+ "/Applications", # macOS
42
+ "/Library", # macOS
43
+ "/System", # macOS
44
+ "/Volumes", # macOS mount points
45
+ "/mnt", # Linux mount points
46
+ "/home", # Parent of all user homes
47
+ "/Users", # macOS parent of all user homes
48
+ }
49
+ )
50
+
51
+ # Windows directories that should NOT be used as workspace
52
+ _SUSPICIOUS_DIRS_WINDOWS: frozenset[str] = frozenset(
53
+ {
54
+ "C:\\",
55
+ "C:\\Windows",
56
+ "C:\\Program Files",
57
+ "C:\\Program Files (x86)",
58
+ "C:\\ProgramData",
59
+ "C:\\Users",
60
+ "D:\\",
61
+ }
62
+ )
63
+
64
+ # Common non-project locations under home directory
65
+ _SUSPICIOUS_HOME_SUBDIRS: tuple[str, ...] = (
66
+ "Downloads",
67
+ "Desktop",
68
+ "Documents",
69
+ "Library",
70
+ )
71
+
72
+
73
+ def _safe_resolve(path: Path) -> Path:
74
+ """Safely resolve a path, falling back to absolute() on errors.
75
+
76
+ Args:
77
+ path: Path to resolve.
78
+
79
+ Returns:
80
+ Resolved path, or absolute path if resolution fails.
81
+ """
82
+ try:
83
+ return path.resolve(strict=False)
84
+ except OSError:
85
+ try:
86
+ return path.absolute()
87
+ except OSError:
88
+ return path
89
+
90
+
91
+ def is_suspicious_directory(path: Path) -> bool:
92
+ """Check if directory is suspicious (should not be used as workspace).
93
+
94
+ Cross-platform detection of directories that are likely not project roots:
95
+ - System directories (/, /tmp, C:\\Windows, etc.)
96
+ - User home directory itself
97
+ - Common non-project locations (Downloads, Desktop)
98
+
99
+ A "suspicious" workspace requires explicit user confirmation or --force
100
+ to proceed with launch.
101
+
102
+ Note: On macOS, paths like /tmp and /var are symlinks to /private/tmp and
103
+ /private/var. We check both the original path string and the resolved path
104
+ to ensure these are detected as suspicious.
105
+
106
+ Args:
107
+ path: Directory to check.
108
+
109
+ Returns:
110
+ True if this is a suspicious directory.
111
+ """
112
+ resolved = _safe_resolve(path)
113
+ home = _safe_resolve(Path.home())
114
+
115
+ # User's home directory itself is suspicious
116
+ if resolved == home:
117
+ return True
118
+
119
+ # Get both the original path string and the resolved path string
120
+ # This handles macOS symlinks like /tmp -> /private/tmp
121
+ str_path_original = str(path)
122
+ str_path_resolved = str(resolved)
123
+
124
+ # Check platform-specific suspicious directories
125
+ if sys.platform == "win32":
126
+ # Windows: case-insensitive comparison
127
+ str_path_lower = str_path_resolved.lower()
128
+ for suspicious in _SUSPICIOUS_DIRS_WINDOWS:
129
+ if str_path_lower == suspicious.lower():
130
+ return True
131
+ # Also check if it's a drive root (e.g., "D:\")
132
+ if len(str_path_resolved) <= 3 and len(str_path_resolved) >= 2:
133
+ if str_path_resolved[1:3] == ":\\":
134
+ return True
135
+ else:
136
+ # Unix-like systems: check both original and resolved paths
137
+ # This catches /tmp (original) even when it resolves to /private/tmp
138
+ if str_path_original in _SUSPICIOUS_DIRS_UNIX:
139
+ return True
140
+ if str_path_resolved in _SUSPICIOUS_DIRS_UNIX:
141
+ return True
142
+
143
+ # Common non-project locations under home
144
+ for subdir in _SUSPICIOUS_HOME_SUBDIRS:
145
+ if resolved == home / subdir:
146
+ return True
147
+
148
+ return False
149
+
150
+
151
+ def get_suspicious_reason(path: Path) -> str | None:
152
+ """Get a human-readable reason why a directory is suspicious.
153
+
154
+ This provides context for UI error messages explaining why auto-launch
155
+ was blocked or why confirmation is required.
156
+
157
+ Note: On macOS, paths like /tmp and /var are symlinks to /private/tmp and
158
+ /private/var. We check both the original path string and the resolved path
159
+ to ensure these are detected and return the user-facing path in the message.
160
+
161
+ Args:
162
+ path: Directory to check.
163
+
164
+ Returns:
165
+ A human-readable reason string, or None if not suspicious.
166
+ """
167
+ resolved = _safe_resolve(path)
168
+ home = _safe_resolve(Path.home())
169
+
170
+ # User's home directory itself
171
+ if resolved == home:
172
+ return "Home directory is too broad - select a specific project"
173
+
174
+ # Get both the original path string and the resolved path string
175
+ str_path_original = str(path)
176
+ str_path_resolved = str(resolved)
177
+
178
+ # Check platform-specific suspicious directories
179
+ if sys.platform == "win32":
180
+ str_path_lower = str_path_resolved.lower()
181
+ for suspicious in _SUSPICIOUS_DIRS_WINDOWS:
182
+ if str_path_lower == suspicious.lower():
183
+ return f"System directory '{suspicious}' cannot be used as workspace"
184
+ if len(str_path_resolved) <= 3 and len(str_path_resolved) >= 2:
185
+ if str_path_resolved[1:3] == ":\\":
186
+ return f"Drive root '{str_path_resolved}' is too broad - select a specific folder"
187
+ else:
188
+ # Unix-like systems: check both original and resolved paths
189
+ # Return the user-facing path (original) in the error message
190
+ if str_path_original in _SUSPICIOUS_DIRS_UNIX:
191
+ return f"System directory '{str_path_original}' cannot be used as workspace"
192
+ if str_path_resolved in _SUSPICIOUS_DIRS_UNIX:
193
+ return f"System directory '{str_path_resolved}' cannot be used as workspace"
194
+
195
+ # Common non-project locations under home
196
+ for subdir in _SUSPICIOUS_HOME_SUBDIRS:
197
+ if resolved == home / subdir:
198
+ return f"'{subdir}' folder is not a typical project location"
199
+
200
+ return None
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 .core.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"