scc-cli 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (112) 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 +259 -0
  8. scc_cli/cli_admin.py +683 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1400 -0
  16. scc_cli/cli_org.py +1433 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +858 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +603 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1082 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1405 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/compute.py +377 -0
  46. scc_cli/marketplace/constants.py +87 -0
  47. scc_cli/marketplace/managed.py +135 -0
  48. scc_cli/marketplace/materialize.py +723 -0
  49. scc_cli/marketplace/normalize.py +548 -0
  50. scc_cli/marketplace/render.py +238 -0
  51. scc_cli/marketplace/resolve.py +459 -0
  52. scc_cli/marketplace/schema.py +502 -0
  53. scc_cli/marketplace/sync.py +257 -0
  54. scc_cli/marketplace/team_cache.py +195 -0
  55. scc_cli/marketplace/team_fetch.py +688 -0
  56. scc_cli/marketplace/trust.py +244 -0
  57. scc_cli/models/__init__.py +41 -0
  58. scc_cli/models/exceptions.py +273 -0
  59. scc_cli/models/plugin_audit.py +434 -0
  60. scc_cli/org_templates.py +269 -0
  61. scc_cli/output_mode.py +167 -0
  62. scc_cli/panels.py +113 -0
  63. scc_cli/platform.py +350 -0
  64. scc_cli/profiles.py +1034 -0
  65. scc_cli/remote.py +443 -0
  66. scc_cli/schemas/__init__.py +1 -0
  67. scc_cli/schemas/org-v1.schema.json +456 -0
  68. scc_cli/schemas/team-config.v1.schema.json +163 -0
  69. scc_cli/sessions.py +425 -0
  70. scc_cli/setup.py +582 -0
  71. scc_cli/source_resolver.py +470 -0
  72. scc_cli/stats.py +378 -0
  73. scc_cli/stores/__init__.py +13 -0
  74. scc_cli/stores/exception_store.py +251 -0
  75. scc_cli/subprocess_utils.py +88 -0
  76. scc_cli/teams.py +339 -0
  77. scc_cli/templates/__init__.py +2 -0
  78. scc_cli/templates/org/__init__.py +0 -0
  79. scc_cli/templates/org/minimal.json +19 -0
  80. scc_cli/templates/org/reference.json +74 -0
  81. scc_cli/templates/org/strict.json +38 -0
  82. scc_cli/templates/org/teams.json +42 -0
  83. scc_cli/templates/statusline.sh +75 -0
  84. scc_cli/theme.py +348 -0
  85. scc_cli/ui/__init__.py +124 -0
  86. scc_cli/ui/branding.py +68 -0
  87. scc_cli/ui/chrome.py +395 -0
  88. scc_cli/ui/dashboard/__init__.py +62 -0
  89. scc_cli/ui/dashboard/_dashboard.py +669 -0
  90. scc_cli/ui/dashboard/loaders.py +369 -0
  91. scc_cli/ui/dashboard/models.py +184 -0
  92. scc_cli/ui/dashboard/orchestrator.py +337 -0
  93. scc_cli/ui/formatters.py +443 -0
  94. scc_cli/ui/gate.py +350 -0
  95. scc_cli/ui/help.py +157 -0
  96. scc_cli/ui/keys.py +521 -0
  97. scc_cli/ui/list_screen.py +431 -0
  98. scc_cli/ui/picker.py +700 -0
  99. scc_cli/ui/prompts.py +200 -0
  100. scc_cli/ui/wizard.py +490 -0
  101. scc_cli/update.py +680 -0
  102. scc_cli/utils/__init__.py +39 -0
  103. scc_cli/utils/fixit.py +264 -0
  104. scc_cli/utils/fuzzy.py +124 -0
  105. scc_cli/utils/locks.py +101 -0
  106. scc_cli/utils/ttl.py +376 -0
  107. scc_cli/validate.py +455 -0
  108. scc_cli-1.4.0.dist-info/METADATA +369 -0
  109. scc_cli-1.4.0.dist-info/RECORD +112 -0
  110. scc_cli-1.4.0.dist-info/WHEEL +4 -0
  111. scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
  112. scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,603 @@
1
+ """
2
+ Provide high-level Docker sandbox launch functions and settings injection.
3
+
4
+ Orchestrate the Docker sandbox lifecycle, combining primitives from
5
+ core.py and credential management from credentials.py.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from ..config import get_cache_dir
16
+ from ..constants import SAFETY_NET_POLICY_FILENAME, SANDBOX_DATA_MOUNT, SANDBOX_DATA_VOLUME
17
+ from ..errors import SandboxLaunchError
18
+ from .core import (
19
+ build_command,
20
+ validate_container_filename,
21
+ )
22
+ from .credentials import (
23
+ _create_symlinks_in_container,
24
+ _preinit_credential_volume,
25
+ _start_migration_loop,
26
+ _sync_credentials_from_existing_containers,
27
+ )
28
+
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+ # Safety Net Policy Injection
31
+ # ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ # Default policy for when no org config exists (fail-safe to block mode)
34
+ DEFAULT_SAFETY_NET_POLICY: dict[str, Any] = {"action": "block"}
35
+
36
+ # Valid action values (prevents typo → weird behavior)
37
+ VALID_SAFETY_NET_ACTIONS: frozenset[str] = frozenset({"block", "warn", "allow"})
38
+
39
+ # Container path for policy (constant derived from mount point)
40
+ CONTAINER_POLICY_PATH = f"{SANDBOX_DATA_MOUNT}/{SAFETY_NET_POLICY_FILENAME}"
41
+
42
+
43
+ def extract_safety_net_policy(org_config: dict[str, Any] | None) -> dict[str, Any] | None:
44
+ """Extract safety_net policy from org config for container injection.
45
+
46
+ Args:
47
+ org_config: The resolved organization configuration, or None.
48
+
49
+ Returns:
50
+ The safety_net policy dict if present, None otherwise.
51
+ """
52
+ if org_config is None:
53
+ return None
54
+ security = org_config.get("security")
55
+ if not isinstance(security, dict):
56
+ return None
57
+ safety_net = security.get("safety_net")
58
+ if not isinstance(safety_net, dict):
59
+ return None
60
+ return safety_net
61
+
62
+
63
+ def validate_safety_net_policy(policy: dict[str, Any]) -> dict[str, Any]:
64
+ """Validate and sanitize safety net policy, fail-closed on invalid values.
65
+
66
+ Args:
67
+ policy: Raw policy dict from org config.
68
+
69
+ Returns:
70
+ Validated policy dict. Missing or invalid 'action' values default to 'block'.
71
+ The result always contains an 'action' key.
72
+ """
73
+ result = dict(policy) # shallow copy
74
+ action = result.get("action")
75
+ # Always set action: either keep valid value or default to "block" (fail-closed)
76
+ if action is None or action not in VALID_SAFETY_NET_ACTIONS:
77
+ result["action"] = "block" # fail-closed on missing or invalid
78
+ return result
79
+
80
+
81
+ def get_effective_safety_net_policy(org_config: dict[str, Any] | None) -> dict[str, Any]:
82
+ """Get the safety net policy, falling back to default if not configured.
83
+
84
+ Always returns a policy dict - never None. This ensures the mount is always
85
+ present, avoiding sandbox reuse issues when policy is added later.
86
+
87
+ Args:
88
+ org_config: The resolved organization configuration, or None.
89
+
90
+ Returns:
91
+ The validated safety_net policy dict from org config, or DEFAULT_SAFETY_NET_POLICY.
92
+ """
93
+ custom_policy = extract_safety_net_policy(org_config)
94
+ if custom_policy is not None:
95
+ return validate_safety_net_policy(custom_policy)
96
+ return DEFAULT_SAFETY_NET_POLICY
97
+
98
+
99
+ def _write_policy_to_dir(policy: dict[str, Any], target_dir: Path) -> Path | None:
100
+ """Write policy to a specific directory with atomic pattern.
101
+
102
+ Uses temp file + rename pattern for atomicity. Even if the process crashes
103
+ mid-write, readers will see either the old file or the complete new file.
104
+
105
+ Args:
106
+ policy: Policy dict to write.
107
+ target_dir: Directory to write to.
108
+
109
+ Returns:
110
+ The absolute path to the policy file on success, None on failure.
111
+ """
112
+ try:
113
+ target_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
114
+ except OSError:
115
+ return None
116
+
117
+ policy_path = target_dir / SAFETY_NET_POLICY_FILENAME
118
+ content = json.dumps(policy, indent=2)
119
+
120
+ try:
121
+ # Atomic write: temp file → fsync → rename → fsync dir
122
+ fd, temp_path_str = tempfile.mkstemp(
123
+ dir=target_dir,
124
+ prefix=".policy_",
125
+ suffix=".tmp",
126
+ )
127
+ temp_path = Path(temp_path_str)
128
+ try:
129
+ os.write(fd, content.encode("utf-8"))
130
+ os.fsync(fd)
131
+ finally:
132
+ os.close(fd)
133
+
134
+ os.chmod(temp_path, 0o600) # User read/write only
135
+ temp_path.replace(policy_path) # Atomic replace (cross-platform)
136
+
137
+ # fsync directory to ensure replace is durable
138
+ # O_DIRECTORY may not exist on all platforms (e.g., Windows)
139
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
140
+ try:
141
+ dir_fd = os.open(target_dir, flags)
142
+ try:
143
+ os.fsync(dir_fd)
144
+ finally:
145
+ os.close(dir_fd)
146
+ except OSError:
147
+ pass # Directory fsync is best-effort
148
+
149
+ return policy_path.resolve() # Return absolute path
150
+ except OSError:
151
+ # Clean up temp file if it exists
152
+ try:
153
+ if "temp_path" in locals():
154
+ temp_path.unlink(missing_ok=True)
155
+ except OSError:
156
+ pass
157
+ return None
158
+
159
+
160
+ def _get_fallback_policy_dir() -> Path:
161
+ """Get fallback directory for policy files.
162
+
163
+ Uses home-based path instead of tempfile.gettempdir() because:
164
+ - On macOS, /var/folders/... is often NOT Docker-shareable by default
165
+ - Home directory (/Users/... on macOS, /home/... on Linux) is always shared
166
+
167
+ Returns:
168
+ Path under user's home that's reliably Docker-mountable.
169
+ """
170
+ return Path.home() / ".cache" / "scc-policy-fallback"
171
+
172
+
173
+ def write_safety_net_policy_to_host(policy: dict[str, Any]) -> Path | None:
174
+ """Write safety net policy to host cache with atomic write pattern.
175
+
176
+ Args:
177
+ policy: The safety_net policy dict to write.
178
+
179
+ Returns:
180
+ The absolute host path to the policy file (resolved for Docker Desktop),
181
+ or None on failure.
182
+
183
+ Note:
184
+ Uses atomic write (temp file + replace) to prevent partial reads
185
+ if container starts while file is being written.
186
+ Returns resolved absolute path for Docker Desktop compatibility.
187
+
188
+ If cache dir write fails, falls back to ~/.cache/scc-policy-fallback/
189
+ which is reliably Docker-shareable (under /Users on macOS, /home on Linux).
190
+ """
191
+ # Primary: try cache directory (user's standard cache location)
192
+ cache_dir = get_cache_dir().resolve()
193
+ result = _write_policy_to_dir(policy, cache_dir)
194
+
195
+ if result is not None:
196
+ # Optional hygiene: delete old fallback files on success
197
+ _cleanup_fallback_policy_files()
198
+ return result
199
+
200
+ # Fallback: use home-based path (always Docker-shareable)
201
+ # Avoid tempfile.gettempdir() - on macOS it's /var/folders which may not be shared
202
+ fallback_dir = _get_fallback_policy_dir()
203
+ return _write_policy_to_dir(policy, fallback_dir)
204
+
205
+
206
+ def _cleanup_fallback_policy_files() -> None:
207
+ """Remove old fallback policy files (optional hygiene).
208
+
209
+ Called after successful cache dir write to clean up any stale fallback files.
210
+ Failures are silently ignored - this is purely optional hygiene.
211
+ """
212
+ fallback_dir = _get_fallback_policy_dir()
213
+ fallback_file = fallback_dir / SAFETY_NET_POLICY_FILENAME
214
+ try:
215
+ fallback_file.unlink(missing_ok=True)
216
+ # Also try to remove the directory if empty
217
+ if fallback_dir.exists() and not any(fallback_dir.iterdir()):
218
+ fallback_dir.rmdir()
219
+ except OSError:
220
+ pass # Silently ignore - this is optional hygiene
221
+
222
+
223
+ def run(
224
+ cmd: list[str],
225
+ ensure_credentials: bool = True,
226
+ org_config: dict[str, Any] | None = None,
227
+ ) -> int:
228
+ """
229
+ Execute the Docker command with optional org configuration.
230
+
231
+ This is a thin wrapper that calls run_sandbox() with extracted parameters.
232
+ When org_config is provided, the security.safety_net policy is extracted
233
+ and mounted read-only into the container for the scc-safety-net plugin.
234
+
235
+ Args:
236
+ cmd: Command to execute (must be docker sandbox run format)
237
+ ensure_credentials: If True, use detached→symlink→exec pattern
238
+ org_config: Organization config dict. If provided, safety-net policy
239
+ is extracted and mounted. If None, default fail-safe policy is used.
240
+
241
+ Raises:
242
+ SandboxLaunchError: If Docker command fails to start
243
+ """
244
+ # Extract workspace from command if present
245
+ workspace = None
246
+ continue_session = False
247
+ resume = False
248
+
249
+ # Parse the command to extract workspace and flags
250
+ for i, arg in enumerate(cmd):
251
+ if arg == "-w" and i + 1 < len(cmd):
252
+ workspace = Path(cmd[i + 1])
253
+ elif arg == "-c":
254
+ continue_session = True
255
+ elif arg == "--resume":
256
+ resume = True
257
+
258
+ # Use the new synchronous run_sandbox function
259
+ return run_sandbox(
260
+ workspace=workspace,
261
+ continue_session=continue_session,
262
+ resume=resume,
263
+ ensure_credentials=ensure_credentials,
264
+ org_config=org_config,
265
+ )
266
+
267
+
268
+ def run_sandbox(
269
+ workspace: Path | None = None,
270
+ continue_session: bool = False,
271
+ resume: bool = False,
272
+ ensure_credentials: bool = True,
273
+ org_config: dict[str, Any] | None = None,
274
+ ) -> int:
275
+ """
276
+ Run Claude in a Docker sandbox with credential persistence.
277
+
278
+ Uses SYNCHRONOUS detached→symlink→exec pattern to eliminate race condition:
279
+ 1. Start container in DETACHED mode (no Claude running yet)
280
+ 2. Create symlinks BEFORE Claude starts (race eliminated!)
281
+ 3. Exec Claude interactively using docker exec
282
+
283
+ This replaces the previous fork-and-inject pattern which had a fundamental
284
+ race condition: parent became Docker at T+0, child created symlinks at T+2s,
285
+ but Claude read config at T+0 before symlinks existed.
286
+
287
+ Args:
288
+ workspace: Path to mount as workspace (-w flag)
289
+ continue_session: Pass -c flag to Claude
290
+ resume: Pass --resume flag to Claude
291
+ ensure_credentials: If True, create credential symlinks
292
+ org_config: Organization config dict. If provided, security.safety_net
293
+ policy is extracted and mounted read-only into container for the
294
+ scc-safety-net plugin. If None, a default fail-safe policy is used.
295
+
296
+ Returns:
297
+ Exit code from Docker process
298
+
299
+ Raises:
300
+ SandboxLaunchError: If Docker command fails to start
301
+ """
302
+ try:
303
+ # ALWAYS write policy file and get host path (even without org config)
304
+ # This ensures the mount is present from first launch, avoiding
305
+ # sandbox reuse issues when safety-net is enabled later.
306
+ # If no org config, uses default {"action": "block"} (fail-safe).
307
+ effective_policy = get_effective_safety_net_policy(org_config)
308
+ policy_host_path = write_safety_net_policy_to_host(effective_policy)
309
+ # Note: policy_host_path may be None if write failed - build_command
310
+ # will handle this gracefully (no mount, plugin uses internal defaults)
311
+
312
+ if os.name != "nt" and ensure_credentials:
313
+ # STEP 1: Sync credentials from existing containers to volume
314
+ # This copies credentials from project A's container when starting project B
315
+ _sync_credentials_from_existing_containers()
316
+
317
+ # STEP 2: Pre-initialize volume files (prevents EOF race condition)
318
+ _preinit_credential_volume()
319
+
320
+ # STEP 3: Start container in DETACHED mode (no Claude running yet)
321
+ detached_cmd = build_command(
322
+ workspace=workspace,
323
+ detached=True,
324
+ policy_host_path=policy_host_path,
325
+ )
326
+ result = subprocess.run(
327
+ detached_cmd,
328
+ capture_output=True,
329
+ text=True,
330
+ timeout=60,
331
+ )
332
+
333
+ if result.returncode != 0:
334
+ raise SandboxLaunchError(
335
+ user_message="Failed to create Docker sandbox",
336
+ command=" ".join(detached_cmd),
337
+ stderr=result.stderr,
338
+ )
339
+
340
+ container_id = result.stdout.strip()
341
+ if not container_id:
342
+ raise SandboxLaunchError(
343
+ user_message="Docker sandbox returned empty container ID",
344
+ command=" ".join(detached_cmd),
345
+ )
346
+
347
+ # STEP 4: Create symlinks BEFORE Claude starts
348
+ # This is the KEY fix - symlinks exist BEFORE Claude reads config
349
+ _create_symlinks_in_container(container_id)
350
+
351
+ # STEP 5: Start background migration loop for first-time login
352
+ # This runs in background to capture OAuth tokens during login
353
+ _start_migration_loop(container_id)
354
+
355
+ # STEP 6: Exec Claude interactively (replaces current process)
356
+ # Claude binary is at /home/agent/.local/bin/claude
357
+ exec_cmd = ["docker", "exec", "-it", container_id, "claude"]
358
+
359
+ # Add Claude-specific flags
360
+ if continue_session:
361
+ exec_cmd.append("-c")
362
+ elif resume:
363
+ exec_cmd.append("--resume")
364
+
365
+ # Replace current process with docker exec
366
+ os.execvp("docker", exec_cmd)
367
+
368
+ # If execvp returns, something went wrong
369
+ raise SandboxLaunchError(
370
+ user_message="Failed to exec into Docker sandbox",
371
+ command=" ".join(exec_cmd),
372
+ )
373
+
374
+ else:
375
+ # Non-credential mode or Windows: use legacy flow
376
+ # Policy injection still applies - mount is always present
377
+ cmd = build_command(
378
+ workspace=workspace,
379
+ continue_session=continue_session,
380
+ resume=resume,
381
+ detached=False,
382
+ policy_host_path=policy_host_path,
383
+ )
384
+
385
+ if os.name != "nt":
386
+ os.execvp(cmd[0], cmd)
387
+ raise SandboxLaunchError(
388
+ user_message="Failed to start Docker sandbox",
389
+ command=" ".join(cmd),
390
+ )
391
+ else:
392
+ result = subprocess.run(cmd, text=True)
393
+ return result.returncode
394
+
395
+ except subprocess.TimeoutExpired:
396
+ raise SandboxLaunchError(
397
+ user_message="Docker sandbox creation timed out",
398
+ suggested_action="Check if Docker Desktop is running",
399
+ )
400
+ except FileNotFoundError:
401
+ raise SandboxLaunchError(
402
+ user_message="Command not found: docker",
403
+ suggested_action="Ensure Docker is installed and in your PATH",
404
+ )
405
+ except OSError as e:
406
+ raise SandboxLaunchError(
407
+ user_message=f"Failed to start Docker sandbox: {e}",
408
+ )
409
+
410
+
411
+ def inject_file_to_sandbox_volume(filename: str, content: str) -> bool:
412
+ """
413
+ Inject a file into the Docker sandbox persistent volume.
414
+
415
+ Uses a temporary alpine container to write to the sandbox data volume.
416
+ Files are written to /data/ which maps to /mnt/claude-data/ in the sandbox.
417
+
418
+ Args:
419
+ filename: Name of file to create (e.g., "settings.json", "scc-statusline.sh")
420
+ Must be a simple filename, no path separators allowed.
421
+ content: Content to write
422
+
423
+ Returns:
424
+ True if successful
425
+
426
+ Raises:
427
+ ValueError: If filename contains unsafe characters
428
+ """
429
+ # Validate filename to prevent path traversal
430
+ filename = validate_container_filename(filename)
431
+
432
+ try:
433
+ # Escape content for shell (replace single quotes)
434
+ escaped_content = content.replace("'", "'\"'\"'")
435
+
436
+ # Use alpine to write to the persistent volume
437
+ result = subprocess.run(
438
+ [
439
+ "docker",
440
+ "run",
441
+ "--rm",
442
+ "-v",
443
+ f"{SANDBOX_DATA_VOLUME}:/data",
444
+ "alpine",
445
+ "sh",
446
+ "-c",
447
+ f"printf '%s' '{escaped_content}' > /data/{filename} && chmod +x /data/{filename}",
448
+ ],
449
+ capture_output=True,
450
+ text=True,
451
+ timeout=30,
452
+ )
453
+ return result.returncode == 0
454
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
455
+ return False
456
+
457
+
458
+ def get_sandbox_settings() -> dict[str, Any] | None:
459
+ """
460
+ Return current settings from the Docker sandbox volume.
461
+
462
+ Returns:
463
+ Settings dict or None if not found
464
+ """
465
+ try:
466
+ result = subprocess.run(
467
+ [
468
+ "docker",
469
+ "run",
470
+ "--rm",
471
+ "-v",
472
+ f"{SANDBOX_DATA_VOLUME}:/data",
473
+ "alpine",
474
+ "cat",
475
+ "/data/settings.json",
476
+ ],
477
+ capture_output=True,
478
+ text=True,
479
+ timeout=30,
480
+ )
481
+ if result.returncode == 0 and result.stdout.strip():
482
+ return cast(dict[Any, Any], json.loads(result.stdout))
483
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError, json.JSONDecodeError):
484
+ pass
485
+ return None
486
+
487
+
488
+ def inject_settings(settings: dict[str, Any]) -> bool:
489
+ """
490
+ Inject pre-built settings into the Docker sandbox volume.
491
+
492
+ This is the "dumb" settings injection function. docker.py does NOT know
493
+ about Claude Code settings format - it just merges and injects JSON.
494
+
495
+ Settings are merged with any existing settings in the sandbox volume
496
+ (e.g., status line config). New settings take precedence for conflicts.
497
+
498
+ Args:
499
+ settings: Pre-built settings dict (from claude_adapter.build_claude_settings)
500
+
501
+ Returns:
502
+ True if settings were injected successfully, False otherwise
503
+ """
504
+ # Get existing settings from Docker volume (preserve status line, etc.)
505
+ existing_settings = get_sandbox_settings() or {}
506
+
507
+ # Merge settings with existing settings
508
+ # New settings take precedence for overlapping keys
509
+ merged_settings = {**existing_settings, **settings}
510
+
511
+ # Inject merged settings into Docker volume
512
+ return inject_file_to_sandbox_volume(
513
+ "settings.json",
514
+ json.dumps(merged_settings, indent=2),
515
+ )
516
+
517
+
518
+ def inject_team_settings(team_name: str, org_config: dict[str, Any] | None = None) -> bool:
519
+ """
520
+ Inject team-specific settings into the Docker sandbox volume.
521
+
522
+ Supports two modes:
523
+ 1. With org_config: Uses new remote org config architecture
524
+ - Resolves profile/marketplace from org_config
525
+ - Builds settings via claude_adapter
526
+ 2. Without org_config (deprecated): Uses legacy teams module
527
+
528
+ Args:
529
+ team_name: Name of the team profile
530
+ org_config: Optional remote organization config. If provided, uses
531
+ the new architecture with profiles.py and claude_adapter.py
532
+
533
+ Returns:
534
+ True if settings were injected successfully, False otherwise
535
+ """
536
+ if org_config is not None:
537
+ # New architecture: use profiles.py and claude_adapter.py
538
+ from .. import claude_adapter, profiles
539
+
540
+ # Resolve profile from org config
541
+ profile = profiles.resolve_profile(org_config, team_name)
542
+
543
+ # Check if profile has a plugin
544
+ if not profile.get("plugin"):
545
+ return True # No plugin to inject
546
+
547
+ # Resolve marketplace
548
+ marketplace = profiles.resolve_marketplace(org_config, profile)
549
+
550
+ # Get org_id for namespacing
551
+ org_id = org_config.get("organization", {}).get("id")
552
+
553
+ # Build settings using claude_adapter
554
+ settings = claude_adapter.build_claude_settings(profile, marketplace, org_id)
555
+
556
+ # Inject settings
557
+ return inject_settings(settings)
558
+ else:
559
+ # Legacy mode: use old teams module
560
+ from .. import teams
561
+
562
+ team_settings = teams.get_team_sandbox_settings(team_name)
563
+
564
+ if not team_settings:
565
+ return True
566
+
567
+ return inject_settings(team_settings)
568
+
569
+
570
+ def get_or_create_container(
571
+ workspace: Path | None,
572
+ branch: str | None = None,
573
+ profile: str | None = None,
574
+ force_new: bool = False,
575
+ continue_session: bool = False,
576
+ env_vars: dict[str, str] | None = None,
577
+ ) -> tuple[list[str], bool]:
578
+ """
579
+ Build a Docker sandbox run command.
580
+
581
+ Note: Docker sandboxes are ephemeral by design - they don't support container
582
+ re-use patterns like traditional `docker run`. Each invocation creates a new
583
+ sandbox instance. The branch, profile, force_new, and env_vars parameters are
584
+ kept for API compatibility but are not used.
585
+
586
+ Args:
587
+ workspace: Path to workspace (-w flag for sandbox)
588
+ branch: Git branch name (unused - sandboxes don't support naming)
589
+ profile: Team profile (unused - sandboxes don't support labels)
590
+ force_new: Force new container (unused - sandboxes are always new)
591
+ continue_session: Pass -c flag to Claude
592
+ env_vars: Environment variables (unused - sandboxes handle auth)
593
+
594
+ Returns:
595
+ Tuple of (command_to_run, is_resume)
596
+ - is_resume is always False for sandboxes (no resume support)
597
+ """
598
+ # Docker sandbox doesn't support container re-use - always create new
599
+ cmd = build_command(
600
+ workspace=workspace,
601
+ continue_session=continue_session,
602
+ )
603
+ return cmd, False
@@ -0,0 +1,99 @@
1
+ """Provide system diagnostics and health checks for SCC-CLI.
2
+
3
+ Offer comprehensive health checks for all prerequisites needed to run
4
+ Claude Code in Docker sandboxes.
5
+
6
+ Philosophy: "Fast feedback, clear guidance"
7
+ - Check all prerequisites quickly
8
+ - Provide clear pass/fail indicators
9
+ - Offer actionable fix suggestions
10
+
11
+ Package Structure:
12
+ - types.py: Data structures (CheckResult, DoctorResult, JsonValidationResult)
13
+ - checks.py: Individual health check functions
14
+ - render.py: Orchestration and Rich terminal rendering
15
+ """
16
+
17
+ # Re-export the config module for backward compatibility with tests
18
+ from scc_cli import config
19
+
20
+ # Import all check functions from checks.py
21
+ from scc_cli.doctor.checks import (
22
+ _escape_rich,
23
+ check_cache_readable,
24
+ check_cache_ttl_status,
25
+ check_config_directory,
26
+ check_credential_injection,
27
+ check_docker,
28
+ check_docker_running,
29
+ check_docker_sandbox,
30
+ check_exception_stores,
31
+ check_git,
32
+ check_marketplace_auth_available,
33
+ check_migration_status,
34
+ check_org_config_reachable,
35
+ check_proxy_environment,
36
+ check_user_config_valid,
37
+ check_workspace_path,
38
+ check_wsl2,
39
+ format_code_frame,
40
+ get_json_error_hints,
41
+ load_cached_org_config,
42
+ run_all_checks,
43
+ validate_json_file,
44
+ )
45
+
46
+ # Import orchestration and rendering functions from render.py
47
+ from scc_cli.doctor.render import (
48
+ build_doctor_json_data,
49
+ is_first_run,
50
+ quick_check,
51
+ render_doctor_compact,
52
+ render_doctor_results,
53
+ render_quick_status,
54
+ run_doctor,
55
+ )
56
+
57
+ # Import types from types.py
58
+ from scc_cli.doctor.types import CheckResult, DoctorResult, JsonValidationResult
59
+
60
+ __all__ = [
61
+ # Config module (for backward compatibility with tests)
62
+ "config",
63
+ # Dataclasses
64
+ "CheckResult",
65
+ "DoctorResult",
66
+ "JsonValidationResult",
67
+ # JSON validation helpers
68
+ "validate_json_file",
69
+ "format_code_frame",
70
+ "_escape_rich",
71
+ "get_json_error_hints",
72
+ # Check functions
73
+ "check_git",
74
+ "check_docker",
75
+ "check_docker_sandbox",
76
+ "check_docker_running",
77
+ "check_wsl2",
78
+ "check_workspace_path",
79
+ "check_user_config_valid",
80
+ "check_config_directory",
81
+ "load_cached_org_config",
82
+ "check_org_config_reachable",
83
+ "check_marketplace_auth_available",
84
+ "check_credential_injection",
85
+ "check_cache_readable",
86
+ "check_cache_ttl_status",
87
+ "check_migration_status",
88
+ "check_exception_stores",
89
+ "check_proxy_environment",
90
+ "run_all_checks",
91
+ # Orchestration and rendering
92
+ "run_doctor",
93
+ "build_doctor_json_data",
94
+ "render_doctor_results",
95
+ "render_doctor_compact",
96
+ "render_quick_status",
97
+ "quick_check",
98
+ "is_first_run",
99
+ ]