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
scc_cli/contexts.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Work context tracking for multi-team, multi-project workflows.
|
|
2
|
+
|
|
3
|
+
A WorkContext represents the developer's "working unit": team + repo + worktree.
|
|
4
|
+
This module tracks recent contexts to enable quick switching between projects
|
|
5
|
+
without requiring multiple manual steps (team switch → worktree → session).
|
|
6
|
+
|
|
7
|
+
The contexts are stored in ~/.cache/scc/contexts.json with a versioned schema:
|
|
8
|
+
{"version": 1, "contexts": [...]}
|
|
9
|
+
|
|
10
|
+
Writes are atomic (temp file + rename) for safety.
|
|
11
|
+
|
|
12
|
+
Note: Concurrent writes use "last writer wins" semantics. For most CLI usage
|
|
13
|
+
patterns, this is fine since operations are user-initiated and sequential.
|
|
14
|
+
|
|
15
|
+
Example usage:
|
|
16
|
+
# Record a context when starting work
|
|
17
|
+
ctx = WorkContext(
|
|
18
|
+
team="platform",
|
|
19
|
+
repo_root=Path("/code/api-service"),
|
|
20
|
+
worktree_path=Path("/code/api-service"),
|
|
21
|
+
worktree_name="main",
|
|
22
|
+
)
|
|
23
|
+
record_context(ctx)
|
|
24
|
+
|
|
25
|
+
# Get recent contexts for display
|
|
26
|
+
recent = load_recent_contexts(limit=10)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import tempfile
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Literal
|
|
38
|
+
|
|
39
|
+
from .utils.locks import file_lock, lock_path
|
|
40
|
+
|
|
41
|
+
# Schema version for future migration support
|
|
42
|
+
SCHEMA_VERSION = 1
|
|
43
|
+
|
|
44
|
+
# Maximum number of contexts to keep in history
|
|
45
|
+
MAX_CONTEXTS = 30
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_dt(s: str) -> datetime:
|
|
49
|
+
"""Parse ISO datetime string, with fallback for malformed values."""
|
|
50
|
+
try:
|
|
51
|
+
# Handle Z suffix and standard ISO format
|
|
52
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
return datetime.fromtimestamp(0, tz=timezone.utc)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_path(p: str | Path) -> Path:
|
|
58
|
+
"""Normalize a path for consistent comparison.
|
|
59
|
+
|
|
60
|
+
Uses strict=False to avoid errors on non-existent paths while still
|
|
61
|
+
resolving symlinks. Falls back to absolute() on OSError.
|
|
62
|
+
"""
|
|
63
|
+
path = Path(p).expanduser()
|
|
64
|
+
try:
|
|
65
|
+
return path.resolve(strict=False)
|
|
66
|
+
except OSError:
|
|
67
|
+
# Fall back to absolute without resolving symlinks
|
|
68
|
+
return path.absolute()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class WorkContext:
|
|
73
|
+
"""A developer's working context (team + repo + worktree).
|
|
74
|
+
|
|
75
|
+
This is the primary unit of work switching in SCC. Instead of thinking
|
|
76
|
+
about "sessions" and "workspaces" separately, we track the full context
|
|
77
|
+
that a developer was working in.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
team: The team profile name (e.g., "platform", "data"), or None for standalone mode.
|
|
81
|
+
repo_root: Absolute path to the repository root.
|
|
82
|
+
worktree_path: Absolute path to the worktree (may equal repo_root for main).
|
|
83
|
+
worktree_name: Directory name of the worktree (stable identifier).
|
|
84
|
+
branch: Git branch name at time of last use (metadata, can change).
|
|
85
|
+
last_session_id: Optional session ID from last work in this context.
|
|
86
|
+
last_used: When this context was last used (ISO format string).
|
|
87
|
+
pinned: Whether this context is pinned to the top of the list.
|
|
88
|
+
|
|
89
|
+
Note:
|
|
90
|
+
worktree_name is the directory name (stable), while branch is metadata
|
|
91
|
+
that can change. Display uses branch (if available) with worktree_name
|
|
92
|
+
as fallback. This prevents context records from becoming "lost" when
|
|
93
|
+
a user switches branches within the same worktree.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
team: str | None
|
|
97
|
+
repo_root: Path
|
|
98
|
+
worktree_path: Path
|
|
99
|
+
worktree_name: str
|
|
100
|
+
branch: str | None = None
|
|
101
|
+
last_session_id: str | None = None
|
|
102
|
+
last_used: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
103
|
+
pinned: bool = False
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def repo_name(self) -> str:
|
|
107
|
+
"""Extract repository name from path."""
|
|
108
|
+
return self.repo_root.name
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def team_label(self) -> str:
|
|
112
|
+
"""Return team name or 'standalone' for display."""
|
|
113
|
+
return self.team if self.team else "standalone"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def display_label(self) -> str:
|
|
117
|
+
"""Format for display in lists: 'team · repo · branch/worktree'.
|
|
118
|
+
|
|
119
|
+
Uses branch name if available, otherwise falls back to worktree directory name.
|
|
120
|
+
This provides meaningful labels (branch names) while maintaining stability
|
|
121
|
+
(directory names don't change when branches switch).
|
|
122
|
+
"""
|
|
123
|
+
name = self.branch or self.worktree_name
|
|
124
|
+
return f"{self.team_label} · {self.repo_name} · {name}"
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def unique_key(self) -> tuple[str | None, Path, Path]:
|
|
128
|
+
"""Unique identifier for deduplication: (team, repo_root, worktree_path)."""
|
|
129
|
+
return (self.team, self.repo_root, self.worktree_path)
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict[str, Any]:
|
|
132
|
+
"""Convert to dictionary for JSON serialization."""
|
|
133
|
+
return {
|
|
134
|
+
"team": self.team,
|
|
135
|
+
"repo_root": str(self.repo_root),
|
|
136
|
+
"worktree_path": str(self.worktree_path),
|
|
137
|
+
"worktree_name": self.worktree_name,
|
|
138
|
+
"branch": self.branch,
|
|
139
|
+
"last_session_id": self.last_session_id,
|
|
140
|
+
"last_used": self.last_used,
|
|
141
|
+
"pinned": self.pinned,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_dict(cls, data: dict[str, Any]) -> WorkContext:
|
|
146
|
+
"""Create from dictionary (JSON deserialization).
|
|
147
|
+
|
|
148
|
+
Handles backward compatibility for contexts without branch field.
|
|
149
|
+
"""
|
|
150
|
+
return cls(
|
|
151
|
+
team=data["team"],
|
|
152
|
+
repo_root=normalize_path(data["repo_root"]),
|
|
153
|
+
worktree_path=normalize_path(data["worktree_path"]),
|
|
154
|
+
worktree_name=data["worktree_name"],
|
|
155
|
+
branch=data.get("branch"), # Optional, may not exist in old records
|
|
156
|
+
last_session_id=data.get("last_session_id"),
|
|
157
|
+
last_used=data.get("last_used", datetime.now(timezone.utc).isoformat()),
|
|
158
|
+
pinned=data.get("pinned", False),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_contexts_path() -> Path:
|
|
163
|
+
"""Get path to contexts cache file."""
|
|
164
|
+
cache_dir = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "scc"
|
|
165
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
return cache_dir / "contexts.json"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _load_contexts_raw() -> list[dict[str, Any]]:
|
|
170
|
+
"""Load raw context data from disk."""
|
|
171
|
+
path = _get_contexts_path()
|
|
172
|
+
if not path.exists():
|
|
173
|
+
return []
|
|
174
|
+
try:
|
|
175
|
+
with path.open(encoding="utf-8") as f:
|
|
176
|
+
data = json.load(f)
|
|
177
|
+
# Handle versioned schema
|
|
178
|
+
if isinstance(data, dict) and "contexts" in data:
|
|
179
|
+
contexts = data["contexts"]
|
|
180
|
+
if isinstance(contexts, list):
|
|
181
|
+
return contexts
|
|
182
|
+
return []
|
|
183
|
+
# Legacy: raw list (migrate on next write)
|
|
184
|
+
if isinstance(data, list):
|
|
185
|
+
return data
|
|
186
|
+
return []
|
|
187
|
+
except (json.JSONDecodeError, OSError):
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _save_contexts_raw(contexts: list[dict[str, Any]]) -> None:
|
|
192
|
+
"""Save context data to disk atomically (temp file + rename)."""
|
|
193
|
+
path = _get_contexts_path()
|
|
194
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
|
|
196
|
+
# Versioned schema
|
|
197
|
+
data = {"version": SCHEMA_VERSION, "contexts": contexts}
|
|
198
|
+
|
|
199
|
+
# Write to temp file then rename for atomicity
|
|
200
|
+
fd, temp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
201
|
+
try:
|
|
202
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
203
|
+
json.dump(data, f, indent=2)
|
|
204
|
+
os.replace(temp_path, path)
|
|
205
|
+
except Exception:
|
|
206
|
+
# Clean up temp file on failure
|
|
207
|
+
if os.path.exists(temp_path):
|
|
208
|
+
os.unlink(temp_path)
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_recent_contexts(
|
|
213
|
+
limit: int = 10,
|
|
214
|
+
*,
|
|
215
|
+
team_filter: str | None | Literal["all"] = "all",
|
|
216
|
+
) -> list[WorkContext]:
|
|
217
|
+
"""Load recent contexts, sorted by pinned first then recency.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
limit: Maximum number of contexts to return.
|
|
221
|
+
team_filter: Team filter:
|
|
222
|
+
- "all" (default): No filter, return all contexts
|
|
223
|
+
- None: Return only standalone contexts (team=None)
|
|
224
|
+
- str: Return only contexts matching this team name
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
List of WorkContext objects, pinned first, then by last_used descending.
|
|
228
|
+
"""
|
|
229
|
+
raw_data = _load_contexts_raw()
|
|
230
|
+
contexts = [WorkContext.from_dict(d) for d in raw_data]
|
|
231
|
+
|
|
232
|
+
# Sort: pinned=True first (True > False with reverse=True),
|
|
233
|
+
# then by timestamp descending (larger = more recent)
|
|
234
|
+
contexts.sort(key=lambda c: (c.pinned, _parse_dt(c.last_used)), reverse=True)
|
|
235
|
+
|
|
236
|
+
# Apply team filter if specified
|
|
237
|
+
if team_filter != "all":
|
|
238
|
+
if team_filter is None:
|
|
239
|
+
# Standalone mode: only contexts with no team
|
|
240
|
+
contexts = [ctx for ctx in contexts if ctx.team is None]
|
|
241
|
+
else:
|
|
242
|
+
# Team mode: only contexts matching this team
|
|
243
|
+
contexts = [ctx for ctx in contexts if ctx.team == team_filter]
|
|
244
|
+
|
|
245
|
+
return contexts[:limit]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _merge_contexts(existing: WorkContext, incoming: WorkContext) -> WorkContext:
|
|
249
|
+
"""Merge incoming context update with existing context.
|
|
250
|
+
|
|
251
|
+
Preserves pinned status, updates timestamps, session info, and branch.
|
|
252
|
+
"""
|
|
253
|
+
return WorkContext(
|
|
254
|
+
team=incoming.team,
|
|
255
|
+
repo_root=incoming.repo_root,
|
|
256
|
+
worktree_path=incoming.worktree_path,
|
|
257
|
+
worktree_name=incoming.worktree_name,
|
|
258
|
+
branch=incoming.branch or existing.branch, # Prefer new, fallback to existing
|
|
259
|
+
last_session_id=incoming.last_session_id or existing.last_session_id,
|
|
260
|
+
last_used=datetime.now(timezone.utc).isoformat(),
|
|
261
|
+
pinned=existing.pinned, # Preserve pinned status
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def record_context(context: WorkContext) -> None:
|
|
266
|
+
"""Record a context, updating if it already exists.
|
|
267
|
+
|
|
268
|
+
If a context with the same (team, repo_root, worktree_path) exists,
|
|
269
|
+
it's updated with new last_used and last_session_id.
|
|
270
|
+
|
|
271
|
+
Note: This function does not mutate the input context.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
context: The context to record.
|
|
275
|
+
"""
|
|
276
|
+
lock_file = lock_path("contexts")
|
|
277
|
+
with file_lock(lock_file):
|
|
278
|
+
raw_data = _load_contexts_raw()
|
|
279
|
+
existing = [WorkContext.from_dict(d) for d in raw_data]
|
|
280
|
+
|
|
281
|
+
# Normalize the incoming context paths
|
|
282
|
+
normalized = WorkContext(
|
|
283
|
+
team=context.team,
|
|
284
|
+
repo_root=normalize_path(context.repo_root),
|
|
285
|
+
worktree_path=normalize_path(context.worktree_path),
|
|
286
|
+
worktree_name=context.worktree_name,
|
|
287
|
+
branch=context.branch, # Preserve branch for Quick Resume display
|
|
288
|
+
last_session_id=context.last_session_id,
|
|
289
|
+
last_used=datetime.now(timezone.utc).isoformat(),
|
|
290
|
+
pinned=context.pinned,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Find and update or append
|
|
294
|
+
key = normalized.unique_key
|
|
295
|
+
found = False
|
|
296
|
+
for i, ctx in enumerate(existing):
|
|
297
|
+
if ctx.unique_key == key:
|
|
298
|
+
existing[i] = _merge_contexts(ctx, normalized)
|
|
299
|
+
found = True
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
if not found:
|
|
303
|
+
existing.append(normalized)
|
|
304
|
+
|
|
305
|
+
# Sort by recency and trim to MAX_CONTEXTS
|
|
306
|
+
# Keep pinned contexts even if they're old
|
|
307
|
+
pinned = [c for c in existing if c.pinned]
|
|
308
|
+
unpinned = [c for c in existing if not c.pinned]
|
|
309
|
+
|
|
310
|
+
# Sort both lists by recency for consistent ordering
|
|
311
|
+
pinned.sort(key=lambda c: _parse_dt(c.last_used), reverse=True)
|
|
312
|
+
unpinned.sort(key=lambda c: _parse_dt(c.last_used), reverse=True)
|
|
313
|
+
|
|
314
|
+
# Trim unpinned to fit within MAX_CONTEXTS (minus pinned count)
|
|
315
|
+
max_unpinned = MAX_CONTEXTS - len(pinned)
|
|
316
|
+
if max_unpinned < 0:
|
|
317
|
+
max_unpinned = 0
|
|
318
|
+
unpinned = unpinned[:max_unpinned]
|
|
319
|
+
|
|
320
|
+
final = pinned + unpinned
|
|
321
|
+
_save_contexts_raw([c.to_dict() for c in final])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def toggle_pin(team: str, repo_root: str | Path, worktree_path: str | Path) -> bool | None:
|
|
325
|
+
"""Toggle the pinned status of a context.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
team: Team name.
|
|
329
|
+
repo_root: Repository root path.
|
|
330
|
+
worktree_path: Worktree path.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
New pinned status (True if now pinned, False if unpinned),
|
|
334
|
+
or None if context not found.
|
|
335
|
+
"""
|
|
336
|
+
lock_file = lock_path("contexts")
|
|
337
|
+
with file_lock(lock_file):
|
|
338
|
+
# Load all contexts as WorkContext objects (normalizes paths once)
|
|
339
|
+
contexts = [WorkContext.from_dict(d) for d in _load_contexts_raw()]
|
|
340
|
+
key = (team, normalize_path(repo_root), normalize_path(worktree_path))
|
|
341
|
+
|
|
342
|
+
for i, ctx in enumerate(contexts):
|
|
343
|
+
if ctx.unique_key == key:
|
|
344
|
+
# Create new context with toggled pinned status
|
|
345
|
+
contexts[i] = WorkContext(
|
|
346
|
+
team=ctx.team,
|
|
347
|
+
repo_root=ctx.repo_root,
|
|
348
|
+
worktree_path=ctx.worktree_path,
|
|
349
|
+
worktree_name=ctx.worktree_name,
|
|
350
|
+
branch=ctx.branch, # Preserve branch metadata
|
|
351
|
+
last_session_id=ctx.last_session_id,
|
|
352
|
+
last_used=ctx.last_used,
|
|
353
|
+
pinned=not ctx.pinned,
|
|
354
|
+
)
|
|
355
|
+
_save_contexts_raw([c.to_dict() for c in contexts])
|
|
356
|
+
return contexts[i].pinned
|
|
357
|
+
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def clear_contexts() -> int:
|
|
362
|
+
"""Clear all contexts from cache.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Number of contexts cleared.
|
|
366
|
+
"""
|
|
367
|
+
lock_file = lock_path("contexts")
|
|
368
|
+
with file_lock(lock_file):
|
|
369
|
+
raw_data = _load_contexts_raw()
|
|
370
|
+
count = len(raw_data)
|
|
371
|
+
_save_contexts_raw([])
|
|
372
|
+
return count
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def get_context_for_path(worktree_path: str | Path, team: str | None = None) -> WorkContext | None:
|
|
376
|
+
"""Find a context matching the given worktree path.
|
|
377
|
+
|
|
378
|
+
Uses normalized path comparison for robustness.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
worktree_path: The worktree path to search for.
|
|
382
|
+
team: Optional team filter.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Matching context or None.
|
|
386
|
+
"""
|
|
387
|
+
normalized = normalize_path(worktree_path)
|
|
388
|
+
contexts = load_recent_contexts(limit=MAX_CONTEXTS)
|
|
389
|
+
|
|
390
|
+
for ctx in contexts:
|
|
391
|
+
if ctx.worktree_path == normalized:
|
|
392
|
+
if team is None or ctx.team == team:
|
|
393
|
+
return ctx
|
|
394
|
+
return None
|
scc_cli/core/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Core business logic and shared foundations.
|
|
2
|
+
|
|
3
|
+
This package contains domain-agnostic foundations:
|
|
4
|
+
- errors: Exception hierarchy
|
|
5
|
+
- constants: Application constants
|
|
6
|
+
- exit_codes: CLI exit code definitions
|
|
7
|
+
|
|
8
|
+
These modules have no CLI dependencies and can be used by
|
|
9
|
+
both CLI and non-CLI code (tests, background tasks, etc.).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# Explicit public API exports
|
|
15
|
+
from .constants import CLI_VERSION
|
|
16
|
+
from .errors import (
|
|
17
|
+
ConfigError,
|
|
18
|
+
InternalError,
|
|
19
|
+
PolicyViolationError,
|
|
20
|
+
PrerequisiteError,
|
|
21
|
+
ProfileNotFoundError,
|
|
22
|
+
SCCError,
|
|
23
|
+
ToolError,
|
|
24
|
+
UsageError,
|
|
25
|
+
)
|
|
26
|
+
from .exit_codes import (
|
|
27
|
+
EXIT_CANCELLED,
|
|
28
|
+
EXIT_CODE_MAP,
|
|
29
|
+
EXIT_CONFIG,
|
|
30
|
+
EXIT_ERROR,
|
|
31
|
+
EXIT_GOVERNANCE,
|
|
32
|
+
EXIT_INTERNAL,
|
|
33
|
+
EXIT_NOT_FOUND,
|
|
34
|
+
EXIT_PREREQ,
|
|
35
|
+
EXIT_SUCCESS,
|
|
36
|
+
EXIT_TOOL,
|
|
37
|
+
EXIT_USAGE,
|
|
38
|
+
EXIT_VALIDATION,
|
|
39
|
+
get_exit_code_for_exception,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Version
|
|
44
|
+
"CLI_VERSION",
|
|
45
|
+
# Errors
|
|
46
|
+
"SCCError",
|
|
47
|
+
"UsageError",
|
|
48
|
+
"PrerequisiteError",
|
|
49
|
+
"ToolError",
|
|
50
|
+
"ConfigError",
|
|
51
|
+
"PolicyViolationError",
|
|
52
|
+
"ProfileNotFoundError",
|
|
53
|
+
"InternalError",
|
|
54
|
+
# Exit codes
|
|
55
|
+
"EXIT_SUCCESS",
|
|
56
|
+
"EXIT_NOT_FOUND",
|
|
57
|
+
"EXIT_ERROR",
|
|
58
|
+
"EXIT_USAGE",
|
|
59
|
+
"EXIT_CONFIG",
|
|
60
|
+
"EXIT_TOOL",
|
|
61
|
+
"EXIT_VALIDATION",
|
|
62
|
+
"EXIT_PREREQ",
|
|
63
|
+
"EXIT_INTERNAL",
|
|
64
|
+
"EXIT_GOVERNANCE",
|
|
65
|
+
"EXIT_CANCELLED",
|
|
66
|
+
"EXIT_CODE_MAP",
|
|
67
|
+
"get_exit_code_for_exception",
|
|
68
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend-specific constants for SCC-CLI.
|
|
3
|
+
|
|
4
|
+
Centralized location for all backend-specific values that identify the
|
|
5
|
+
AI coding assistant being sandboxed. Currently supports Claude Code.
|
|
6
|
+
|
|
7
|
+
This module enables future extensibility to support other AI coding CLIs
|
|
8
|
+
(e.g., Codex, Gemini) by providing a single location to update when
|
|
9
|
+
adding new backend support.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from scc_cli.core.constants import AGENT_NAME, SANDBOX_IMAGE
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import PackageNotFoundError
|
|
16
|
+
from importlib.metadata import version as get_package_version
|
|
17
|
+
|
|
18
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
# Agent Configuration
|
|
20
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
# The agent binary name inside the container
|
|
23
|
+
# This is passed to `docker sandbox run` and `docker exec`
|
|
24
|
+
AGENT_NAME = "claude"
|
|
25
|
+
|
|
26
|
+
# The Docker sandbox template image
|
|
27
|
+
SANDBOX_IMAGE = "docker/sandbox-templates:claude-code"
|
|
28
|
+
|
|
29
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
# Credential & Storage Paths
|
|
31
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
# Directory name inside user home for agent config/credentials
|
|
34
|
+
# Maps to ~/.claude/ on host and /home/agent/.claude/ in container
|
|
35
|
+
AGENT_CONFIG_DIR = ".claude"
|
|
36
|
+
|
|
37
|
+
# Docker volume for persistent sandbox data
|
|
38
|
+
SANDBOX_DATA_VOLUME = "docker-claude-sandbox-data"
|
|
39
|
+
|
|
40
|
+
# Mount point inside the container for the data volume
|
|
41
|
+
SANDBOX_DATA_MOUNT = "/mnt/claude-data"
|
|
42
|
+
|
|
43
|
+
# Safety net policy injection
|
|
44
|
+
# This is the filename for the extracted security.safety_net blob (NOT full org config)
|
|
45
|
+
SAFETY_NET_POLICY_FILENAME = "effective_policy.json"
|
|
46
|
+
|
|
47
|
+
# Credential file paths (relative to agent home directory)
|
|
48
|
+
CREDENTIAL_PATHS = (
|
|
49
|
+
f"/home/agent/{AGENT_CONFIG_DIR}/.credentials.json",
|
|
50
|
+
f"/home/agent/{AGENT_CONFIG_DIR}/credentials.json",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# OAuth credential key in credentials file
|
|
54
|
+
OAUTH_CREDENTIAL_KEY = "claudeAiOauth"
|
|
55
|
+
|
|
56
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
# Git Integration
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
# Branch prefix for worktrees created by SCC
|
|
61
|
+
# Uses product namespace (scc/) not agent namespace (claude/)
|
|
62
|
+
WORKTREE_BRANCH_PREFIX = "scc/"
|
|
63
|
+
|
|
64
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
# Default Plugin Marketplace
|
|
66
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
# Default GitHub repo for plugins marketplace
|
|
69
|
+
DEFAULT_MARKETPLACE_REPO = "sundsvall/claude-plugins-marketplace"
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# Version Information
|
|
73
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# Fallback version for editable installs and dev checkouts
|
|
76
|
+
# Keep in sync with pyproject.toml as last resort
|
|
77
|
+
_FALLBACK_VERSION = "1.5.0"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_version() -> str:
|
|
81
|
+
"""Get CLI version from package metadata with meaningful fallback.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Version string from installed package, or fallback with dev suffix
|
|
85
|
+
for editable installs where package metadata is unavailable.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
return get_package_version("scc-cli")
|
|
89
|
+
except PackageNotFoundError:
|
|
90
|
+
# Editable install or dev checkout - still provide meaningful version
|
|
91
|
+
return f"{_FALLBACK_VERSION}-dev (no package metadata)"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
CLI_VERSION = _get_version()
|
|
95
|
+
|
|
96
|
+
# Schema versions this CLI can understand
|
|
97
|
+
# v1: Full-featured format with delegation, security policies, marketplace
|
|
98
|
+
SUPPORTED_SCHEMA_VERSIONS = ("v1",)
|
|
99
|
+
|
|
100
|
+
# Current schema version used for validation
|
|
101
|
+
CURRENT_SCHEMA_VERSION = "1.0.0"
|