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,865 @@
1
+ """
2
+ CLI Worktree and Session Commands.
3
+
4
+ Commands for managing git worktrees, sessions, and containers.
5
+ """
6
+
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+ from rich.prompt import Confirm
13
+ from rich.status import Status
14
+
15
+ from . import config, contexts, deps, docker, git, sessions
16
+ from .cli_common import console, handle_errors, render_responsive_table
17
+ from .cli_helpers import ConfirmItems, confirm_action
18
+ from .constants import WORKTREE_BRANCH_PREFIX
19
+ from .errors import NotAGitRepoError, WorkspaceNotFoundError
20
+ from .json_command import json_command
21
+ from .kinds import Kind
22
+ from .output_mode import is_json_mode
23
+ from .panels import create_info_panel, create_success_panel, create_warning_panel
24
+ from .theme import Indicators, Spinners
25
+ from .ui.gate import InteractivityContext
26
+ from .ui.picker import TeamSwitchRequested, pick_containers, pick_session, pick_worktree
27
+
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ # Worktree App
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ worktree_app = typer.Typer(
33
+ name="worktree",
34
+ help="Manage git worktrees for parallel development.",
35
+ no_args_is_help=True,
36
+ )
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────────────────────
40
+ # Pure Functions
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+
43
+
44
+ def build_worktree_list_data(
45
+ worktrees: list[dict[str, Any]],
46
+ workspace: str,
47
+ ) -> dict[str, Any]:
48
+ """Build worktree list data for JSON output.
49
+
50
+ Args:
51
+ worktrees: List of worktree dictionaries from git.list_worktrees()
52
+ workspace: Path to the workspace
53
+
54
+ Returns:
55
+ Dictionary with worktrees, count, and workspace
56
+ """
57
+ return {
58
+ "worktrees": worktrees,
59
+ "count": len(worktrees),
60
+ "workspace": workspace,
61
+ }
62
+
63
+
64
+ # ─────────────────────────────────────────────────────────────────────────────
65
+ # Worktree Commands
66
+ # ─────────────────────────────────────────────────────────────────────────────
67
+
68
+
69
+ @worktree_app.command("create")
70
+ @handle_errors
71
+ def worktree_create_cmd(
72
+ workspace: str = typer.Argument(..., help="Path to the main repository"),
73
+ name: str = typer.Argument(..., help="Name for the worktree/feature"),
74
+ base_branch: str | None = typer.Option(
75
+ None, "-b", "--base", help="Base branch (default: current)"
76
+ ),
77
+ start_claude: bool = typer.Option(
78
+ True, "--start/--no-start", help="Start Claude after creating"
79
+ ),
80
+ install_deps: bool = typer.Option(
81
+ False, "--install-deps", help="Install dependencies after creating worktree"
82
+ ),
83
+ ) -> None:
84
+ """Create a new worktree for parallel development."""
85
+ workspace_path = Path(workspace).expanduser().resolve()
86
+
87
+ if not workspace_path.exists():
88
+ raise WorkspaceNotFoundError(path=str(workspace_path))
89
+
90
+ if not git.is_git_repo(workspace_path):
91
+ raise NotAGitRepoError(path=str(workspace_path))
92
+
93
+ worktree_path = git.create_worktree(workspace_path, name, base_branch)
94
+
95
+ console.print(
96
+ create_success_panel(
97
+ "Worktree Created",
98
+ {
99
+ "Path": str(worktree_path),
100
+ "Branch": f"{WORKTREE_BRANCH_PREFIX}{name}",
101
+ "Base": base_branch or "current branch",
102
+ },
103
+ )
104
+ )
105
+
106
+ # Install dependencies if requested
107
+ if install_deps:
108
+ with Status(
109
+ "[cyan]Installing dependencies...[/cyan]", console=console, spinner=Spinners.SETUP
110
+ ):
111
+ success = deps.auto_install_dependencies(worktree_path)
112
+ if success:
113
+ console.print(f"[green]{Indicators.get('PASS')} Dependencies installed[/green]")
114
+ else:
115
+ console.print("[yellow]⚠ Could not detect package manager or install failed[/yellow]")
116
+
117
+ if start_claude:
118
+ console.print()
119
+ if Confirm.ask("[cyan]Start Claude Code in this worktree?[/cyan]", default=True):
120
+ docker.check_docker_available()
121
+ docker_cmd, _ = docker.get_or_create_container(
122
+ workspace=worktree_path,
123
+ branch=f"{WORKTREE_BRANCH_PREFIX}{name}",
124
+ )
125
+ # Load org config for safety-net policy injection
126
+ org_config = config.load_cached_org_config()
127
+ docker.run(docker_cmd, org_config=org_config)
128
+
129
+
130
+ @worktree_app.command("list")
131
+ @json_command(Kind.WORKTREE_LIST)
132
+ @handle_errors
133
+ def worktree_list_cmd(
134
+ workspace: str = typer.Argument(".", help="Path to the repository"),
135
+ interactive: bool = typer.Option(
136
+ False, "-i", "--interactive", help="Interactive mode: select a worktree to work with"
137
+ ),
138
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
139
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
140
+ ) -> dict[str, Any]:
141
+ """List all worktrees for a repository.
142
+
143
+ With -i/--interactive, select a worktree and print its path
144
+ (useful for piping: cd $(scc worktree list -i))
145
+ """
146
+ workspace_path = Path(workspace).expanduser().resolve()
147
+
148
+ if not workspace_path.exists():
149
+ raise WorkspaceNotFoundError(path=str(workspace_path))
150
+
151
+ worktree_list = git.list_worktrees(workspace_path)
152
+
153
+ # Convert WorktreeInfo dataclasses to dicts for JSON serialization
154
+ worktree_dicts = [asdict(wt) for wt in worktree_list]
155
+ data = build_worktree_list_data(worktree_dicts, str(workspace_path))
156
+
157
+ if is_json_mode():
158
+ return data
159
+
160
+ if not worktree_list:
161
+ console.print(
162
+ create_warning_panel(
163
+ "No Worktrees",
164
+ "No worktrees found for this repository.",
165
+ "Create one with: scc worktree create <repo> <name>",
166
+ )
167
+ )
168
+ return
169
+
170
+ # Interactive mode: use worktree picker
171
+ if interactive:
172
+ try:
173
+ selected = pick_worktree(
174
+ worktree_list,
175
+ title="Select Worktree",
176
+ subtitle=f"{len(worktree_list)} worktrees in {workspace_path.name}",
177
+ )
178
+ if selected:
179
+ # Print just the path for scripting: cd $(scc worktree list -i)
180
+ print(selected.path)
181
+ except TeamSwitchRequested:
182
+ console.print("[dim]Use 'scc team switch' to change teams[/dim]")
183
+ return
184
+
185
+ # Use the beautiful worktree rendering from git.py
186
+ git.render_worktrees(worktree_list, console)
187
+
188
+ return data
189
+
190
+
191
+ @worktree_app.command("remove")
192
+ @handle_errors
193
+ def worktree_remove_cmd(
194
+ workspace: str = typer.Argument(..., help="Path to the main repository"),
195
+ name: str = typer.Argument(..., help="Name of the worktree to remove"),
196
+ force: bool = typer.Option(
197
+ False, "-f", "--force", help="Force removal even with uncommitted changes"
198
+ ),
199
+ yes: bool = typer.Option(False, "-y", "--yes", help="Skip all confirmation prompts"),
200
+ dry_run: bool = typer.Option(
201
+ False, "--dry-run", help="Show what would be removed without removing"
202
+ ),
203
+ ) -> None:
204
+ """Remove a worktree.
205
+
206
+ By default, prompts for confirmation if there are uncommitted changes and
207
+ asks whether to delete the associated branch.
208
+
209
+ Use --yes to skip prompts (auto-confirms all actions).
210
+ Use --dry-run to preview what would be removed.
211
+ Use --force to remove even with uncommitted changes (still prompts unless --yes).
212
+ """
213
+ workspace_path = Path(workspace).expanduser().resolve()
214
+
215
+ if not workspace_path.exists():
216
+ raise WorkspaceNotFoundError(path=str(workspace_path))
217
+
218
+ # cleanup_worktree handles all output including success panels
219
+ git.cleanup_worktree(workspace_path, name, force, console, skip_confirm=yes, dry_run=dry_run)
220
+
221
+
222
+ # ─────────────────────────────────────────────────────────────────────────────
223
+ # Session Commands
224
+ # ─────────────────────────────────────────────────────────────────────────────
225
+
226
+
227
+ @handle_errors
228
+ def sessions_cmd(
229
+ limit: int = typer.Option(10, "-n", "--limit", help="Number of sessions to show"),
230
+ select: bool = typer.Option(
231
+ False, "--select", "-s", help="Interactive picker to select a session"
232
+ ),
233
+ ) -> None:
234
+ """List recent Claude Code sessions."""
235
+ recent = sessions.list_recent(limit)
236
+
237
+ # Interactive picker mode
238
+ if select and recent:
239
+ try:
240
+ selected = pick_session(
241
+ recent,
242
+ title="Select Session",
243
+ subtitle=f"{len(recent)} recent sessions",
244
+ )
245
+ if selected:
246
+ console.print(f"[green]Selected session:[/green] {selected.get('name', '-')}")
247
+ console.print(f"[dim]Workspace: {selected.get('workspace', '-')}[/dim]")
248
+ except TeamSwitchRequested:
249
+ console.print("[dim]Use 'scc team switch' to change teams[/dim]")
250
+ return
251
+
252
+ if not recent:
253
+ console.print(
254
+ create_warning_panel(
255
+ "No Sessions",
256
+ "No recent sessions found.",
257
+ "Start a session with: scc start <workspace>",
258
+ )
259
+ )
260
+ return
261
+
262
+ # Build rows for responsive table
263
+ rows = []
264
+ for s in recent:
265
+ # Shorten workspace path if needed
266
+ ws = s.get("workspace", "-")
267
+ if len(ws) > 40:
268
+ ws = "..." + ws[-37:]
269
+ rows.append([s.get("name", "-"), ws, s.get("last_used", "-"), s.get("team", "-")])
270
+
271
+ render_responsive_table(
272
+ title="Recent Sessions",
273
+ columns=[
274
+ ("Session", "cyan"),
275
+ ("Workspace", "white"),
276
+ ],
277
+ rows=rows,
278
+ wide_columns=[
279
+ ("Last Used", "yellow"),
280
+ ("Team", "green"),
281
+ ],
282
+ )
283
+
284
+
285
+ # ─────────────────────────────────────────────────────────────────────────────
286
+ # Container Commands
287
+ # ─────────────────────────────────────────────────────────────────────────────
288
+
289
+
290
+ def _list_interactive(containers: list[docker.ContainerInfo]) -> None:
291
+ """Run interactive container list with action keys.
292
+
293
+ Allows user to navigate containers and press action keys:
294
+ - s: Stop the selected container
295
+ - r: Resume the selected container
296
+ - Enter: Show container details
297
+
298
+ Args:
299
+ containers: List of ContainerInfo objects.
300
+ """
301
+ from .ui.formatters import format_container
302
+ from .ui.list_screen import ListMode, ListScreen
303
+
304
+ # Convert to list items
305
+ items = [format_container(c) for c in containers]
306
+
307
+ # Define action handlers
308
+ def stop_container_action(item: Any) -> None:
309
+ """Stop the selected container."""
310
+ container = item.value
311
+ with Status(f"[cyan]Stopping {container.name}...[/cyan]", console=console):
312
+ success = docker.stop_container(container.id)
313
+ if success:
314
+ console.print(f"[green]{Indicators.get('PASS')} Stopped: {container.name}[/green]")
315
+ else:
316
+ console.print(f"[red]{Indicators.get('FAIL')} Failed to stop: {container.name}[/red]")
317
+
318
+ def resume_container_action(item: Any) -> None:
319
+ """Resume the selected container."""
320
+ container = item.value
321
+ with Status(f"[cyan]Resuming {container.name}...[/cyan]", console=console):
322
+ success = docker.resume_container(container.id)
323
+ if success:
324
+ console.print(f"[green]{Indicators.get('PASS')} Resumed: {container.name}[/green]")
325
+ else:
326
+ console.print(f"[red]{Indicators.get('FAIL')} Failed to resume: {container.name}[/red]")
327
+
328
+ # Create screen with action handlers
329
+ screen = ListScreen(
330
+ items,
331
+ title="Containers",
332
+ mode=ListMode.ACTIONABLE,
333
+ custom_actions={
334
+ "s": stop_container_action,
335
+ "r": resume_container_action,
336
+ },
337
+ )
338
+
339
+ # Run the screen (actions execute via callbacks, returns None)
340
+ screen.run()
341
+
342
+ console.print("[dim]Actions: s=stop, r=resume, q=quit[/dim]")
343
+
344
+
345
+ @handle_errors
346
+ def list_cmd(
347
+ interactive: bool = typer.Option(
348
+ False, "-i", "--interactive", help="Interactive mode: select container and take action"
349
+ ),
350
+ ) -> None:
351
+ """List all SCC-managed Docker containers.
352
+
353
+ With -i/--interactive, enter actionable mode where you can select a container
354
+ and press action keys:
355
+ - s: Stop the container
356
+ - r: Resume the container
357
+ - Enter: Select and show details
358
+ """
359
+ with Status("[cyan]Fetching containers...[/cyan]", console=console, spinner=Spinners.DOCKER):
360
+ containers = docker.list_scc_containers()
361
+
362
+ if not containers:
363
+ console.print(
364
+ create_warning_panel(
365
+ "No Containers",
366
+ "No SCC-managed containers found.",
367
+ "Start a session with: scc start <workspace>",
368
+ )
369
+ )
370
+ return
371
+
372
+ # Interactive mode: use ACTIONABLE list screen
373
+ if interactive:
374
+ _list_interactive(containers)
375
+ return
376
+
377
+ # Build rows for table display
378
+ rows = []
379
+ for c in containers:
380
+ # Color status based on state
381
+ status = c.status
382
+ if "Up" in status:
383
+ status = f"[green]{status}[/green]"
384
+ elif "Exited" in status:
385
+ status = f"[yellow]{status}[/yellow]"
386
+
387
+ ws = c.workspace or "-"
388
+ if ws != "-" and len(ws) > 35:
389
+ ws = "..." + ws[-32:]
390
+
391
+ rows.append([c.name, status, ws, c.profile or "-", c.branch or "-"])
392
+
393
+ render_responsive_table(
394
+ title="SCC Containers",
395
+ columns=[
396
+ ("Container", "cyan"),
397
+ ("Status", "white"),
398
+ ],
399
+ rows=rows,
400
+ wide_columns=[
401
+ ("Workspace", "dim"),
402
+ ("Profile", "yellow"),
403
+ ("Branch", "green"),
404
+ ],
405
+ )
406
+
407
+ console.print("[dim]Resume with: docker start -ai <container_name>[/dim]")
408
+ console.print("[dim]Or use: scc list -i for interactive mode[/dim]")
409
+
410
+
411
+ @handle_errors
412
+ def stop_cmd(
413
+ container: str = typer.Argument(
414
+ None,
415
+ help="Container name or ID to stop (omit for interactive picker)",
416
+ ),
417
+ all_containers: bool = typer.Option(
418
+ False, "--all", "-a", help="Stop all running Claude Code sandboxes"
419
+ ),
420
+ interactive: bool = typer.Option(
421
+ False, "-i", "--interactive", help="Use multi-select picker to choose containers"
422
+ ),
423
+ yes: bool = typer.Option(
424
+ False, "-y", "--yes", help="Skip confirmation prompt when stopping multiple containers"
425
+ ),
426
+ ) -> None:
427
+ """Stop running Docker sandbox(es).
428
+
429
+ Examples:
430
+ scc stop # Interactive picker if multiple running
431
+ scc stop -i # Force interactive multi-select picker
432
+ scc stop claude-sandbox-2025... # Stop specific container
433
+ scc stop --all # Stop all (explicit)
434
+ scc stop --yes # Stop all without confirmation
435
+ """
436
+ with Status("[cyan]Fetching sandboxes...[/cyan]", console=console, spinner=Spinners.DOCKER):
437
+ # List Docker Desktop sandbox containers (image: docker/sandbox-templates:claude-code)
438
+ running = docker.list_running_sandboxes()
439
+
440
+ if not running:
441
+ console.print(
442
+ create_info_panel(
443
+ "No Running Sandboxes",
444
+ "No Claude Code sandboxes are currently running.",
445
+ "Start one with: scc -w /path/to/project",
446
+ )
447
+ )
448
+ return
449
+
450
+ # If specific container requested
451
+ if container and not all_containers:
452
+ # Find matching container
453
+ match = None
454
+ for c in running:
455
+ if c.name == container or c.id.startswith(container):
456
+ match = c
457
+ break
458
+
459
+ if not match:
460
+ console.print(
461
+ create_warning_panel(
462
+ "Container Not Found",
463
+ f"No running container matches: {container}",
464
+ "Run 'scc list' to see available containers",
465
+ )
466
+ )
467
+ raise typer.Exit(1)
468
+
469
+ # Stop the specific container
470
+ with Status(f"[cyan]Stopping {match.name}...[/cyan]", console=console):
471
+ success = docker.stop_container(match.id)
472
+
473
+ if success:
474
+ console.print(create_success_panel("Container Stopped", {"Name": match.name}))
475
+ else:
476
+ console.print(
477
+ create_warning_panel(
478
+ "Stop Failed",
479
+ f"Could not stop container: {match.name}",
480
+ )
481
+ )
482
+ raise typer.Exit(1)
483
+ return
484
+
485
+ # Determine which containers to stop
486
+ to_stop = running
487
+
488
+ # Interactive picker mode: when -i flag OR multiple containers without --all/--yes
489
+ ctx = InteractivityContext.create(json_mode=False, no_interactive=False)
490
+ use_picker = interactive or (len(running) > 1 and not all_containers and not yes)
491
+
492
+ if use_picker and ctx.allows_prompt():
493
+ # Use multi-select picker
494
+ try:
495
+ selected = pick_containers(
496
+ running,
497
+ title="Stop Containers",
498
+ subtitle=f"{len(running)} running",
499
+ )
500
+ if not selected:
501
+ console.print("[dim]No containers selected.[/dim]")
502
+ return
503
+ to_stop = selected
504
+ except TeamSwitchRequested:
505
+ console.print("[dim]Use 'scc team switch' to change teams[/dim]")
506
+ return
507
+ elif len(running) > 1 and not yes:
508
+ # Fallback to confirmation prompt (non-TTY or --all without --yes)
509
+ try:
510
+ confirm_action(
511
+ yes=yes,
512
+ prompt=f"Stop {len(running)} running container(s)?",
513
+ items=ConfirmItems(
514
+ title=f"Found {len(running)} running container(s):",
515
+ items=[c.name for c in running],
516
+ ),
517
+ )
518
+ except typer.Abort:
519
+ console.print("[dim]Aborted.[/dim]")
520
+ return
521
+
522
+ console.print(f"[cyan]Stopping {len(to_stop)} container(s)...[/cyan]")
523
+
524
+ stopped = []
525
+ failed = []
526
+ for c in to_stop:
527
+ with Status(f"[cyan]Stopping {c.name}...[/cyan]", console=console):
528
+ if docker.stop_container(c.id):
529
+ stopped.append(c.name)
530
+ else:
531
+ failed.append(c.name)
532
+
533
+ if stopped:
534
+ console.print(
535
+ create_success_panel(
536
+ "Containers Stopped",
537
+ {"Stopped": str(len(stopped)), "Names": ", ".join(stopped)},
538
+ )
539
+ )
540
+
541
+ if failed:
542
+ console.print(
543
+ create_warning_panel(
544
+ "Some Failed",
545
+ f"Could not stop: {', '.join(failed)}",
546
+ )
547
+ )
548
+
549
+
550
+ # ─────────────────────────────────────────────────────────────────────────────
551
+ # Prune Command
552
+ # ─────────────────────────────────────────────────────────────────────────────
553
+
554
+
555
+ def _is_container_stopped(status: str) -> bool:
556
+ """Check if a container status indicates it's stopped (not running).
557
+
558
+ Docker status strings:
559
+ - "Up 2 hours" / "Up 30 seconds" / "Up 2 hours (healthy)" = running
560
+ - "Exited (0) 2 hours ago" / "Exited (137) 5 seconds ago" = stopped
561
+ - "Created" = created but never started (stopped)
562
+ - "Dead" = dead container (stopped)
563
+ """
564
+ status_lower = status.lower()
565
+ # Running containers have status starting with "up"
566
+ if status_lower.startswith("up"):
567
+ return False
568
+ # Everything else is stopped: Exited, Created, Dead, etc.
569
+ return True
570
+
571
+
572
+ @handle_errors
573
+ def prune_cmd(
574
+ yes: bool = typer.Option(
575
+ False, "--yes", "-y", help="Skip confirmation prompt (for scripts/CI)"
576
+ ),
577
+ dry_run: bool = typer.Option(
578
+ False, "--dry-run", help="Only show what would be removed, don't prompt"
579
+ ),
580
+ ) -> None:
581
+ """Remove stopped SCC containers.
582
+
583
+ Shows stopped containers and prompts for confirmation before removing.
584
+ Use --yes/-y to skip confirmation (for scripts/CI).
585
+ Use --dry-run to only preview without prompting.
586
+
587
+ Only removes STOPPED containers. Running containers are never affected.
588
+
589
+ Examples:
590
+ scc prune # Show containers, prompt to remove
591
+ scc prune --yes # Remove without prompting (CI/scripts)
592
+ scc prune --dry-run # Only show what would be removed
593
+ """
594
+ with Status("[cyan]Fetching containers...[/cyan]", console=console, spinner=Spinners.DOCKER):
595
+ # Use _list_all_sandbox_containers to find ALL sandbox containers (by image)
596
+ # This matches how stop_cmd uses list_running_sandboxes (also by image)
597
+ # Containers created by Docker Desktop directly don't have SCC labels
598
+ all_containers = docker._list_all_sandbox_containers()
599
+
600
+ # Filter to only stopped containers
601
+ stopped = [c for c in all_containers if _is_container_stopped(c.status)]
602
+
603
+ if not stopped:
604
+ console.print(
605
+ create_info_panel(
606
+ "Nothing to Prune",
607
+ "No stopped SCC containers found.",
608
+ "Run 'scc stop' first to stop running containers, then prune.",
609
+ )
610
+ )
611
+ return
612
+
613
+ # Handle dry-run mode separately - show what would be removed
614
+ if dry_run:
615
+ console.print(f"[bold]Would remove {len(stopped)} stopped container(s):[/bold]")
616
+ for c in stopped:
617
+ console.print(f" [dim]•[/dim] {c.name}")
618
+ console.print("[dim]Dry run complete. No containers removed.[/dim]")
619
+ return
620
+
621
+ # Use centralized confirmation helper for actual removal
622
+ # This handles: --yes, JSON mode, non-interactive mode
623
+ try:
624
+ confirm_action(
625
+ yes=yes,
626
+ dry_run=False,
627
+ prompt=f"Remove {len(stopped)} stopped container(s)?",
628
+ items=ConfirmItems(
629
+ title=f"Found {len(stopped)} stopped container(s):",
630
+ items=[c.name for c in stopped],
631
+ ),
632
+ )
633
+ except typer.Abort:
634
+ console.print("[dim]Aborted.[/dim]")
635
+ return
636
+
637
+ # Actually remove containers
638
+ console.print(f"[cyan]Removing {len(stopped)} stopped container(s)...[/cyan]")
639
+
640
+ removed = []
641
+ failed = []
642
+ for c in stopped:
643
+ with Status(f"[cyan]Removing {c.name}...[/cyan]", console=console):
644
+ if docker.remove_container(c.name):
645
+ removed.append(c.name)
646
+ else:
647
+ failed.append(c.name)
648
+
649
+ if removed:
650
+ console.print(
651
+ create_success_panel(
652
+ "Containers Removed",
653
+ {"Removed": str(len(removed)), "Names": ", ".join(removed)},
654
+ )
655
+ )
656
+
657
+ if failed:
658
+ console.print(
659
+ create_warning_panel(
660
+ "Some Failed",
661
+ f"Could not remove: {', '.join(failed)}",
662
+ )
663
+ )
664
+ raise typer.Exit(1)
665
+
666
+
667
+ # ─────────────────────────────────────────────────────────────────────────────
668
+ # Symmetric Alias Apps (Phase 8)
669
+ # ─────────────────────────────────────────────────────────────────────────────
670
+
671
+ session_app = typer.Typer(
672
+ name="session",
673
+ help="Session management commands.",
674
+ no_args_is_help=True,
675
+ )
676
+
677
+ container_app = typer.Typer(
678
+ name="container",
679
+ help="Container management commands.",
680
+ no_args_is_help=True,
681
+ )
682
+
683
+
684
+ @session_app.command("list")
685
+ @handle_errors
686
+ def session_list_cmd(
687
+ limit: int = typer.Option(10, "-n", "--limit", help="Number of sessions to show"),
688
+ select: bool = typer.Option(
689
+ False, "--select", "-s", help="Interactive picker to select a session"
690
+ ),
691
+ ) -> None:
692
+ """List recent Claude Code sessions.
693
+
694
+ Alias for 'scc sessions'. Provides symmetric command structure.
695
+
696
+ Examples:
697
+ scc session list
698
+ scc session list -n 20
699
+ scc session list --select
700
+ """
701
+ # Delegate to existing sessions logic
702
+ recent = sessions.list_recent(limit)
703
+
704
+ # Interactive picker mode
705
+ if select and recent:
706
+ try:
707
+ selected = pick_session(
708
+ recent,
709
+ title="Select Session",
710
+ subtitle=f"{len(recent)} recent sessions",
711
+ )
712
+ if selected:
713
+ console.print(f"[green]Selected session:[/green] {selected.get('name', '-')}")
714
+ console.print(f"[dim]Workspace: {selected.get('workspace', '-')}[/dim]")
715
+ except TeamSwitchRequested:
716
+ console.print("[dim]Use 'scc team switch' to change teams[/dim]")
717
+ return
718
+
719
+ if not recent:
720
+ console.print(
721
+ create_warning_panel(
722
+ "No Sessions",
723
+ "No recent sessions found.",
724
+ "Start a session with: scc start <workspace>",
725
+ )
726
+ )
727
+ return
728
+
729
+ # Build rows for responsive table
730
+ rows = []
731
+ for s in recent:
732
+ # Shorten workspace path if needed
733
+ ws = s.get("workspace", "-")
734
+ if len(ws) > 40:
735
+ ws = "..." + ws[-37:]
736
+ rows.append([s.get("name", "-"), ws, s.get("last_used", "-"), s.get("team", "-")])
737
+
738
+ render_responsive_table(
739
+ title="Recent Sessions",
740
+ columns=[
741
+ ("Session", "cyan"),
742
+ ("Workspace", "white"),
743
+ ],
744
+ rows=rows,
745
+ wide_columns=[
746
+ ("Last Used", "yellow"),
747
+ ("Team", "green"),
748
+ ],
749
+ )
750
+
751
+
752
+ @container_app.command("list")
753
+ @handle_errors
754
+ def container_list_cmd() -> None:
755
+ """List all SCC-managed Docker containers.
756
+
757
+ Alias for 'scc list'. Provides symmetric command structure.
758
+
759
+ Examples:
760
+ scc container list
761
+ """
762
+ # Delegate to existing list logic
763
+ with Status("[cyan]Fetching containers...[/cyan]", console=console, spinner=Spinners.DOCKER):
764
+ containers = docker.list_scc_containers()
765
+
766
+ if not containers:
767
+ console.print(
768
+ create_warning_panel(
769
+ "No Containers",
770
+ "No SCC-managed containers found.",
771
+ "Start a session with: scc start <workspace>",
772
+ )
773
+ )
774
+ return
775
+
776
+ # Build rows
777
+ rows = []
778
+ for c in containers:
779
+ # Color status based on state
780
+ status = c.status
781
+ if status == "running":
782
+ status = f"[green]{status}[/green]"
783
+ elif status == "exited":
784
+ status = f"[yellow]{status}[/yellow]"
785
+
786
+ rows.append([c.name, status, c.workspace or "-", c.profile or "-", c.branch or "-"])
787
+
788
+ render_responsive_table(
789
+ title="SCC Containers",
790
+ columns=[
791
+ ("Name", "cyan"),
792
+ ("Status", "white"),
793
+ ],
794
+ rows=rows,
795
+ wide_columns=[
796
+ ("Workspace", "dim"),
797
+ ("Profile", "yellow"),
798
+ ("Branch", "green"),
799
+ ],
800
+ )
801
+
802
+ console.print("[dim]Resume with: docker start -ai <container_name>[/dim]")
803
+
804
+
805
+ # ─────────────────────────────────────────────────────────────────────────────
806
+ # Context App (Work Context Management)
807
+ # ─────────────────────────────────────────────────────────────────────────────
808
+
809
+ context_app = typer.Typer(
810
+ name="context",
811
+ help="Work context management commands.",
812
+ no_args_is_help=True,
813
+ )
814
+
815
+
816
+ @context_app.command("clear")
817
+ @handle_errors
818
+ def context_clear_cmd(
819
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
820
+ ) -> None:
821
+ """Clear all recent work contexts from cache.
822
+
823
+ Use this command when the Recent Contexts list shows stale or
824
+ incorrect entries that you want to reset.
825
+
826
+ Examples:
827
+ scc context clear # With confirmation prompt
828
+ scc context clear --yes # Skip confirmation
829
+ """
830
+ cache_path = contexts._get_contexts_path()
831
+
832
+ # Show current count
833
+ current_count = len(contexts.load_recent_contexts())
834
+ if current_count == 0:
835
+ console.print(
836
+ create_info_panel(
837
+ "No Contexts",
838
+ "No work contexts to clear.",
839
+ "Contexts are created when you run: scc start <workspace>",
840
+ )
841
+ )
842
+ return
843
+
844
+ # Confirm unless --yes (improved what/why/next confirmation)
845
+ if not yes:
846
+ console.print(
847
+ f"[yellow]This will remove {current_count} context(s) from {cache_path}[/yellow]"
848
+ )
849
+ if not Confirm.ask("Continue?"):
850
+ console.print("[dim]Cancelled.[/dim]")
851
+ return
852
+
853
+ # Clear and report
854
+ cleared = contexts.clear_contexts()
855
+
856
+ console.print(
857
+ create_success_panel(
858
+ "Contexts Cleared",
859
+ {
860
+ "Removed": f"{cleared} work context(s)",
861
+ "Cache file": str(cache_path),
862
+ },
863
+ )
864
+ )
865
+ console.print("[dim]Run 'scc start' to repopulate.[/dim]")