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
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
@@ -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"