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.
- 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 +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -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 +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -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/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -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 +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -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 +960 -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/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -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 +383 -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 +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -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 +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- 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"
|