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
scc_cli/cli_launch.py ADDED
@@ -0,0 +1,1400 @@
1
+ """
2
+ CLI Launch Commands.
3
+
4
+ Commands for starting Claude Code in Docker sandboxes.
5
+
6
+ This module handles the `scc start` command, orchestrating:
7
+ - Session selection (--resume, --select, interactive)
8
+ - Workspace validation and preparation
9
+ - Team profile configuration
10
+ - Docker sandbox launch
11
+
12
+ The main `start()` function delegates to focused helper functions
13
+ for maintainability and testability.
14
+ """
15
+
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any, cast
19
+
20
+ import typer
21
+ from rich.panel import Panel
22
+ from rich.prompt import Confirm, Prompt
23
+ from rich.status import Status
24
+ from rich.table import Table
25
+
26
+ from . import config, deps, docker, git, sessions, setup, teams
27
+ from . import platform as platform_module
28
+ from .cli_common import (
29
+ MAX_DISPLAY_PATH_LENGTH,
30
+ PATH_TRUNCATE_LENGTH,
31
+ console,
32
+ handle_errors,
33
+ )
34
+ from .constants import WORKTREE_BRANCH_PREFIX
35
+ from .contexts import WorkContext, load_recent_contexts, normalize_path, record_context
36
+ from .errors import NotAGitRepoError, WorkspaceNotFoundError
37
+ from .exit_codes import EXIT_CANCELLED, EXIT_CONFIG, EXIT_ERROR, EXIT_USAGE
38
+ from .json_output import build_envelope
39
+ from .kinds import Kind
40
+ from .marketplace.sync import SyncError, SyncResult, sync_marketplace_settings
41
+ from .output_mode import json_output_mode, print_human, print_json, set_pretty_mode
42
+ from .panels import create_info_panel, create_success_panel, create_warning_panel
43
+ from .theme import Colors, Indicators, Spinners, get_brand_header
44
+ from .ui.gate import is_interactive_allowed
45
+ from .ui.picker import (
46
+ QuickResumeResult,
47
+ TeamSwitchRequested,
48
+ pick_context_quick_resume,
49
+ )
50
+ from .ui.prompts import (
51
+ prompt_custom_workspace,
52
+ prompt_repo_url,
53
+ select_session,
54
+ select_team,
55
+ )
56
+ from .ui.wizard import (
57
+ BACK,
58
+ WorkspaceSource,
59
+ pick_recent_workspace,
60
+ pick_team_repo,
61
+ pick_workspace_source,
62
+ )
63
+
64
+ # ─────────────────────────────────────────────────────────────────────────────
65
+ # Helper Functions (extracted for maintainability)
66
+ # ─────────────────────────────────────────────────────────────────────────────
67
+
68
+
69
+ def _resolve_session_selection(
70
+ workspace: str | None,
71
+ team: str | None,
72
+ resume: bool,
73
+ select: bool,
74
+ cfg: dict[str, Any],
75
+ *,
76
+ json_mode: bool = False,
77
+ standalone_override: bool = False,
78
+ no_interactive: bool = False,
79
+ ) -> tuple[str | None, str | None, str | None, str | None, bool]:
80
+ """
81
+ Handle session selection logic for --select, --resume, and interactive modes.
82
+
83
+ Args:
84
+ workspace: Workspace path from command line.
85
+ team: Team name from command line.
86
+ resume: Whether --resume flag is set.
87
+ select: Whether --select flag is set.
88
+ cfg: Loaded configuration.
89
+ json_mode: Whether --json output is requested (blocks interactive).
90
+ standalone_override: Whether --standalone flag is set (overrides config).
91
+
92
+ Returns:
93
+ Tuple of (workspace, team, session_name, worktree_name, cancelled)
94
+ If user cancels or no session found, workspace will be None.
95
+ cancelled is True only for explicit user cancellation.
96
+
97
+ Raises:
98
+ typer.Exit: If interactive mode required but not allowed (non-TTY, CI, --json).
99
+ """
100
+ session_name = None
101
+ worktree_name = None
102
+ cancelled = False
103
+
104
+ # Interactive mode if no workspace provided and no session flags
105
+ if workspace is None and not resume and not select:
106
+ # Check TTY gating before entering interactive mode
107
+ if not is_interactive_allowed(
108
+ json_mode=json_mode,
109
+ no_interactive_flag=no_interactive,
110
+ ):
111
+ console.print(
112
+ "[red]Error:[/red] Interactive mode requires a terminal (TTY).\n"
113
+ "[dim]Provide a workspace path: scc start /path/to/project[/dim]",
114
+ highlight=False,
115
+ )
116
+ raise typer.Exit(EXIT_USAGE)
117
+ workspace, team, session_name, worktree_name = interactive_start(
118
+ cfg, standalone_override=standalone_override
119
+ )
120
+ if workspace is None:
121
+ return None, team, None, None, True
122
+ return workspace, team, session_name, worktree_name, False
123
+
124
+ # Handle --select: interactive session picker
125
+ if select and workspace is None:
126
+ # Check TTY gating before showing session picker
127
+ if not is_interactive_allowed(
128
+ json_mode=json_mode,
129
+ no_interactive_flag=no_interactive,
130
+ ):
131
+ console.print(
132
+ "[red]Error:[/red] --select requires a terminal (TTY).\n"
133
+ "[dim]Use --resume to auto-select most recent session.[/dim]",
134
+ highlight=False,
135
+ )
136
+ raise typer.Exit(EXIT_USAGE)
137
+ recent_sessions = sessions.list_recent(limit=10)
138
+ if not recent_sessions:
139
+ if not json_mode:
140
+ console.print("[yellow]No recent sessions found.[/yellow]")
141
+ return None, team, None, None, False
142
+ selected = select_session(console, recent_sessions)
143
+ if selected is None:
144
+ return None, team, None, None, True
145
+ workspace = selected.get("workspace")
146
+ if not team:
147
+ team = selected.get("team")
148
+ # --standalone overrides any team from session (standalone means no team)
149
+ if standalone_override:
150
+ team = None
151
+ if not json_mode:
152
+ console.print(f"[dim]Selected: {workspace}[/dim]")
153
+
154
+ # Handle --resume: auto-select most recent session
155
+ elif resume and workspace is None:
156
+ recent_session = sessions.get_most_recent()
157
+ if recent_session:
158
+ workspace = recent_session.get("workspace")
159
+ if not team:
160
+ team = recent_session.get("team")
161
+ # --standalone overrides any team from session (standalone means no team)
162
+ if standalone_override:
163
+ team = None
164
+ if not json_mode:
165
+ console.print(f"[dim]Resuming: {workspace}[/dim]")
166
+ else:
167
+ if not json_mode:
168
+ console.print("[yellow]No recent sessions found.[/yellow]")
169
+ return None, team, None, None, False
170
+
171
+ return workspace, team, session_name, worktree_name, cancelled
172
+
173
+
174
+ def _validate_and_resolve_workspace(
175
+ workspace: str | None, *, no_interactive: bool = False
176
+ ) -> Path | None:
177
+ """
178
+ Validate workspace path and handle platform-specific warnings.
179
+
180
+ Raises:
181
+ WorkspaceNotFoundError: If workspace path doesn't exist.
182
+ typer.Exit: If user declines to continue after WSL2 warning.
183
+ """
184
+ if workspace is None:
185
+ return None
186
+
187
+ workspace_path = Path(workspace).expanduser().resolve()
188
+
189
+ if not workspace_path.exists():
190
+ raise WorkspaceNotFoundError(path=str(workspace_path))
191
+
192
+ # WSL2 performance warning
193
+ if platform_module.is_wsl2():
194
+ is_optimal, warning = platform_module.check_path_performance(workspace_path)
195
+ if not is_optimal and warning:
196
+ print_human(
197
+ "[yellow]Warning:[/yellow] Workspace is on the Windows filesystem."
198
+ " Performance may be slow.",
199
+ file=sys.stderr,
200
+ highlight=False,
201
+ )
202
+ if is_interactive_allowed(no_interactive_flag=no_interactive):
203
+ console.print()
204
+ console.print(
205
+ create_warning_panel(
206
+ "Performance Warning",
207
+ "Your workspace is on the Windows filesystem.",
208
+ "For better performance, move to ~/projects inside WSL.",
209
+ )
210
+ )
211
+ console.print()
212
+ if not Confirm.ask("[cyan]Continue anyway?[/cyan]", default=True):
213
+ console.print("[dim]Cancelled.[/dim]")
214
+ raise typer.Exit(EXIT_CANCELLED)
215
+
216
+ return workspace_path
217
+
218
+
219
+ def _prepare_workspace(
220
+ workspace_path: Path | None,
221
+ worktree_name: str | None,
222
+ install_deps: bool,
223
+ ) -> Path | None:
224
+ """
225
+ Prepare workspace: create worktree, install deps, check git safety.
226
+
227
+ Returns:
228
+ The (possibly updated) workspace path after worktree creation.
229
+ """
230
+ if workspace_path is None:
231
+ return None
232
+
233
+ # Handle worktree creation
234
+ if worktree_name:
235
+ workspace_path = git.create_worktree(workspace_path, worktree_name)
236
+ console.print(
237
+ create_success_panel(
238
+ "Worktree Created",
239
+ {
240
+ "Path": str(workspace_path),
241
+ "Branch": f"{WORKTREE_BRANCH_PREFIX}{worktree_name}",
242
+ },
243
+ )
244
+ )
245
+
246
+ # Install dependencies if requested
247
+ if install_deps:
248
+ with Status(
249
+ "[cyan]Installing dependencies...[/cyan]", console=console, spinner=Spinners.SETUP
250
+ ):
251
+ success = deps.auto_install_dependencies(workspace_path)
252
+ if success:
253
+ console.print(f"[green]{Indicators.get('PASS')} Dependencies installed[/green]")
254
+ else:
255
+ console.print("[yellow]⚠ Could not detect package manager or install failed[/yellow]")
256
+
257
+ # Check git safety (handles protected branch warnings)
258
+ if workspace_path.exists():
259
+ git.check_branch_safety(workspace_path, console)
260
+
261
+ return workspace_path
262
+
263
+
264
+ def _resolve_workspace_team(
265
+ workspace_path: Path | None,
266
+ team: str | None,
267
+ cfg: dict[str, Any],
268
+ *,
269
+ json_mode: bool = False,
270
+ standalone: bool = False,
271
+ no_interactive: bool = False,
272
+ ) -> str | None:
273
+ """Resolve team selection using workspace pinning when available.
274
+
275
+ Prefers explicit team, then workspace-pinned team, then global selected profile.
276
+ Prompts if pinned team differs from the global profile in interactive mode.
277
+ """
278
+ if standalone or workspace_path is None:
279
+ return team
280
+
281
+ if team:
282
+ return team
283
+
284
+ pinned_team = config.get_workspace_team_from_config(cfg, workspace_path)
285
+ selected_profile = cfg.get("selected_profile")
286
+
287
+ if pinned_team and selected_profile and pinned_team != selected_profile:
288
+ if is_interactive_allowed(json_mode=json_mode, no_interactive_flag=no_interactive):
289
+ message = (
290
+ f"Workspace '{workspace_path}' was last used with team '{pinned_team}'."
291
+ " Use that team for this session?"
292
+ )
293
+ if Confirm.ask(message, default=True):
294
+ return pinned_team
295
+ return selected_profile
296
+
297
+ if not json_mode:
298
+ print_human(
299
+ "[yellow]Notice:[/yellow] "
300
+ f"Workspace '{workspace_path}' was last used with team '{pinned_team}'. "
301
+ "Using it. Pass --team to override.",
302
+ file=sys.stderr,
303
+ highlight=False,
304
+ )
305
+ return pinned_team
306
+
307
+ if pinned_team:
308
+ return pinned_team
309
+
310
+ return selected_profile
311
+
312
+
313
+ def _warn_if_non_worktree(workspace_path: Path | None, *, json_mode: bool = False) -> None:
314
+ """Warn when running from a main repo without a worktree."""
315
+ if json_mode or workspace_path is None:
316
+ return
317
+
318
+ if not git.is_git_repo(workspace_path):
319
+ return
320
+
321
+ if git.is_worktree(workspace_path):
322
+ return
323
+
324
+ print_human(
325
+ "[yellow]Tip:[/yellow] You're working in the main repo. "
326
+ "For isolation, try: scc worktree create . <feature> or "
327
+ "scc start --worktree <feature>",
328
+ file=sys.stderr,
329
+ highlight=False,
330
+ )
331
+
332
+
333
+ def _configure_team_settings(team: str | None, cfg: dict[str, Any]) -> None:
334
+ """
335
+ Validate team profile and inject settings into Docker sandbox.
336
+
337
+ IMPORTANT: This function must remain cache-only (no network calls).
338
+ It's called in offline mode where only cached org config is available.
339
+ If you need to add network operations, gate them with an offline check
340
+ or move them to _sync_marketplace_settings() which is already offline-aware.
341
+
342
+ Raises:
343
+ typer.Exit: If team profile is not found.
344
+ """
345
+ if not team:
346
+ return
347
+
348
+ with Status(
349
+ f"[cyan]Configuring {team} plugin...[/cyan]", console=console, spinner=Spinners.SETUP
350
+ ):
351
+ # load_cached_org_config() reads from local cache only - safe for offline mode
352
+ org_config = config.load_cached_org_config()
353
+
354
+ validation = teams.validate_team_profile(team, cfg, org_config=org_config)
355
+ if not validation["valid"]:
356
+ console.print(
357
+ create_warning_panel(
358
+ "Team Not Found",
359
+ f"No team profile named '{team}'.",
360
+ "Run 'scc team list' to see available profiles",
361
+ )
362
+ )
363
+ raise typer.Exit(1)
364
+
365
+ docker.inject_team_settings(team, org_config=org_config)
366
+
367
+
368
+ def _sync_marketplace_settings(
369
+ workspace_path: Path | None,
370
+ team: str | None,
371
+ org_config_url: str | None = None,
372
+ ) -> SyncResult | None:
373
+ """
374
+ Sync marketplace settings for the workspace.
375
+
376
+ Orchestrates the full marketplace pipeline:
377
+ 1. Compute effective plugins for team
378
+ 2. Materialize required marketplaces
379
+ 3. Render and merge settings
380
+ 4. Write settings.local.json
381
+
382
+ Args:
383
+ workspace_path: Path to the workspace directory.
384
+ team: Selected team profile name.
385
+ org_config_url: URL of the org config (for tracking).
386
+
387
+ Returns:
388
+ SyncResult with details, or None if no sync needed.
389
+
390
+ Raises:
391
+ typer.Exit: If marketplace sync fails critically.
392
+ """
393
+ if workspace_path is None or team is None:
394
+ return None
395
+
396
+ org_config = config.load_cached_org_config()
397
+ if org_config is None:
398
+ return None
399
+
400
+ with Status(
401
+ "[cyan]Syncing marketplace settings...[/cyan]", console=console, spinner=Spinners.NETWORK
402
+ ):
403
+ try:
404
+ result = sync_marketplace_settings(
405
+ project_dir=workspace_path,
406
+ org_config_data=org_config,
407
+ team_id=team,
408
+ org_config_url=org_config_url,
409
+ )
410
+
411
+ # Display any warnings
412
+ if result.warnings:
413
+ console.print()
414
+ for warning in result.warnings:
415
+ console.print(f"[yellow]{warning}[/yellow]")
416
+ console.print()
417
+
418
+ # Log success
419
+ if result.plugins_enabled:
420
+ console.print(
421
+ f"[green]{Indicators.get('PASS')} Enabled {len(result.plugins_enabled)} team plugin(s)[/green]"
422
+ )
423
+ if result.marketplaces_materialized:
424
+ console.print(
425
+ f"[green]{Indicators.get('PASS')} Materialized {len(result.marketplaces_materialized)} marketplace(s)[/green]"
426
+ )
427
+
428
+ return result
429
+
430
+ except SyncError as e:
431
+ console.print(
432
+ create_warning_panel(
433
+ "Marketplace Sync Failed",
434
+ str(e),
435
+ "Team plugins may not be available. Use --dry-run to diagnose.",
436
+ )
437
+ )
438
+ # Non-fatal: continue without marketplace sync
439
+ return None
440
+
441
+
442
+ def _resolve_mount_and_branch(workspace_path: Path | None) -> tuple[Path | None, str | None]:
443
+ """
444
+ Resolve mount path for worktrees and get current branch.
445
+
446
+ For worktrees, expands mount scope to include main repo.
447
+ Returns (mount_path, current_branch).
448
+ """
449
+ if workspace_path is None:
450
+ return None, None
451
+
452
+ # Get current branch
453
+ current_branch = None
454
+ try:
455
+ current_branch = git.get_current_branch(workspace_path)
456
+ except (NotAGitRepoError, OSError):
457
+ pass
458
+
459
+ # Handle worktree mounting
460
+ mount_path, is_expanded = git.get_workspace_mount_path(workspace_path)
461
+ if is_expanded:
462
+ console.print()
463
+ console.print(
464
+ create_info_panel(
465
+ "Worktree Detected",
466
+ f"Mounting parent directory for worktree support:\n{mount_path}",
467
+ "Both worktree and main repo will be accessible",
468
+ )
469
+ )
470
+ console.print()
471
+
472
+ return mount_path, current_branch
473
+
474
+
475
+ def _launch_sandbox(
476
+ workspace_path: Path | None,
477
+ mount_path: Path | None,
478
+ team: str | None,
479
+ session_name: str | None,
480
+ current_branch: str | None,
481
+ should_continue_session: bool,
482
+ fresh: bool,
483
+ ) -> None:
484
+ """
485
+ Execute the Docker sandbox with all configurations applied.
486
+
487
+ Handles container creation, session recording, and process handoff.
488
+ Safety-net policy from org config is extracted and mounted read-only.
489
+ """
490
+ # Load org config for safety-net policy injection
491
+ # This is already cached by _configure_team_settings(), so it's a fast read
492
+ org_config = config.load_cached_org_config()
493
+
494
+ # Prepare sandbox volume for credential persistence
495
+ docker.prepare_sandbox_volume_for_credentials()
496
+
497
+ # Get or create container
498
+ docker_cmd, is_resume = docker.get_or_create_container(
499
+ workspace=mount_path,
500
+ branch=current_branch,
501
+ profile=team,
502
+ force_new=fresh,
503
+ continue_session=should_continue_session,
504
+ env_vars=None,
505
+ )
506
+
507
+ # Extract container name for session tracking
508
+ container_name = _extract_container_name(docker_cmd, is_resume)
509
+
510
+ # Record session and context
511
+ if workspace_path:
512
+ sessions.record_session(
513
+ workspace=str(workspace_path),
514
+ team=team,
515
+ session_name=session_name,
516
+ container_name=container_name,
517
+ branch=current_branch,
518
+ )
519
+ # Record context for quick resume feature
520
+ # Determine repo root (may be same as workspace for non-worktrees)
521
+ repo_root = git.get_worktree_main_repo(workspace_path) or workspace_path
522
+ worktree_name = workspace_path.name
523
+ context = WorkContext(
524
+ team=team, # Keep None for standalone mode (don't use "base")
525
+ repo_root=repo_root,
526
+ worktree_path=workspace_path,
527
+ worktree_name=worktree_name,
528
+ branch=current_branch, # For Quick Resume branch highlighting
529
+ last_session_id=session_name,
530
+ )
531
+ # Context recording is best-effort - failure should never block sandbox launch
532
+ # (Quick Resume is a convenience feature, not critical path)
533
+ try:
534
+ record_context(context)
535
+ except (OSError, ValueError) as e:
536
+ import logging
537
+
538
+ print_human(
539
+ "[yellow]Warning:[/yellow] Could not save Quick Resume context.",
540
+ highlight=False,
541
+ )
542
+ print_human(f"[dim]{e}[/dim]", highlight=False)
543
+ logging.debug(f"Failed to record context for Quick Resume: {e}")
544
+
545
+ if team:
546
+ try:
547
+ config.set_workspace_team(str(workspace_path), team)
548
+ except (OSError, ValueError) as e:
549
+ import logging
550
+
551
+ print_human(
552
+ "[yellow]Warning:[/yellow] Could not save workspace team preference.",
553
+ highlight=False,
554
+ )
555
+ print_human(f"[dim]{e}[/dim]", highlight=False)
556
+ logging.debug(f"Failed to store workspace team mapping: {e}")
557
+
558
+ # Show launch info and execute
559
+ _show_launch_panel(
560
+ workspace=workspace_path,
561
+ team=team,
562
+ session_name=session_name,
563
+ branch=current_branch,
564
+ is_resume=is_resume,
565
+ )
566
+
567
+ # Pass org_config for safety-net policy injection (mounted read-only)
568
+ docker.run(docker_cmd, org_config=org_config)
569
+
570
+
571
+ def _extract_container_name(docker_cmd: list[str], is_resume: bool) -> str | None:
572
+ """Extract container name from docker command for session tracking."""
573
+ for idx, arg in enumerate(docker_cmd):
574
+ if arg == "--name" and idx + 1 < len(docker_cmd):
575
+ return docker_cmd[idx + 1]
576
+ if arg.startswith("--name="):
577
+ return arg.split("=", 1)[1]
578
+
579
+ if is_resume and docker_cmd:
580
+ # For resume, container name is the last arg
581
+ if docker_cmd[-1].startswith("scc-"):
582
+ return docker_cmd[-1]
583
+ return None
584
+
585
+
586
+ # ─────────────────────────────────────────────────────────────────────────────
587
+ # Dry Run Data Builder (Pure Function)
588
+ # ─────────────────────────────────────────────────────────────────────────────
589
+
590
+
591
+ def build_dry_run_data(
592
+ workspace_path: Path,
593
+ team: str | None,
594
+ org_config: dict[str, Any] | None,
595
+ project_config: dict[str, Any] | None,
596
+ ) -> dict[str, Any]:
597
+ """
598
+ Build dry run data showing resolved configuration.
599
+
600
+ This pure function assembles configuration information for preview
601
+ without performing any side effects like Docker launch.
602
+
603
+ Args:
604
+ workspace_path: Path to the workspace directory.
605
+ team: Selected team profile name (or None).
606
+ org_config: Organization configuration dict (or None).
607
+ project_config: Project-level .scc.yaml config (or None).
608
+
609
+ Returns:
610
+ Dictionary with resolved configuration data.
611
+ """
612
+ plugins: list[dict[str, Any]] = []
613
+ blocked_items: list[str] = []
614
+
615
+ if org_config and team:
616
+ from . import profiles
617
+
618
+ workspace_for_project = None if project_config is not None else workspace_path
619
+ effective = profiles.compute_effective_config(
620
+ org_config,
621
+ team,
622
+ project_config=project_config,
623
+ workspace_path=workspace_for_project,
624
+ )
625
+
626
+ for plugin in sorted(effective.plugins):
627
+ plugins.append({"name": plugin, "source": "resolved"})
628
+
629
+ for blocked in effective.blocked_items:
630
+ if blocked.blocked_by:
631
+ blocked_items.append(f"{blocked.item} (blocked by '{blocked.blocked_by}')")
632
+ else:
633
+ blocked_items.append(blocked.item)
634
+
635
+ return {
636
+ "workspace": str(workspace_path),
637
+ "team": team,
638
+ "plugins": plugins,
639
+ "blocked_items": blocked_items,
640
+ "ready_to_start": len(blocked_items) == 0,
641
+ }
642
+
643
+
644
+ # ─────────────────────────────────────────────────────────────────────────────
645
+ # Launch App
646
+ # ─────────────────────────────────────────────────────────────────────────────
647
+
648
+ launch_app = typer.Typer(
649
+ name="launch",
650
+ help="Start Claude Code in sandboxes.",
651
+ no_args_is_help=False,
652
+ )
653
+
654
+
655
+ # ─────────────────────────────────────────────────────────────────────────────
656
+ # Start Command
657
+ # ─────────────────────────────────────────────────────────────────────────────
658
+
659
+
660
+ @handle_errors
661
+ def start(
662
+ workspace: str | None = typer.Argument(None, help="Path to workspace (optional)"),
663
+ team: str | None = typer.Option(None, "-t", "--team", help="Team profile to use"),
664
+ session_name: str | None = typer.Option(None, "--session", help="Session name"),
665
+ resume: bool = typer.Option(False, "-r", "--resume", help="Resume most recent session"),
666
+ select: bool = typer.Option(False, "-s", "--select", help="Select from recent sessions"),
667
+ continue_session: bool = typer.Option(
668
+ False, "-c", "--continue", hidden=True, help="Alias for --resume (deprecated)"
669
+ ),
670
+ worktree_name: str | None = typer.Option(
671
+ None, "-w", "--worktree", help="Create worktree with this name"
672
+ ),
673
+ fresh: bool = typer.Option(
674
+ False, "--fresh", help="Force new container (don't resume existing)"
675
+ ),
676
+ install_deps: bool = typer.Option(
677
+ False, "--install-deps", help="Install dependencies before starting"
678
+ ),
679
+ offline: bool = typer.Option(False, "--offline", help="Use cached config only (error if none)"),
680
+ standalone: bool = typer.Option(False, "--standalone", help="Run without organization config"),
681
+ dry_run: bool = typer.Option(
682
+ False, "--dry-run", help="Preview resolved configuration without launching"
683
+ ),
684
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
685
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
686
+ non_interactive: bool = typer.Option(
687
+ False,
688
+ "--non-interactive",
689
+ "--no-interactive",
690
+ help="Fail fast if interactive input would be required",
691
+ ),
692
+ ) -> None:
693
+ """
694
+ Start Claude Code in a Docker sandbox.
695
+
696
+ If no arguments provided, launches interactive mode.
697
+ """
698
+ # ── Fast Fail: Validate mode flags before any processing ──────────────────
699
+ from scc_cli.ui.gate import validate_mode_flags
700
+
701
+ validate_mode_flags(
702
+ json_mode=(json_output or pretty),
703
+ select=select,
704
+ )
705
+
706
+ # ── Step 0: Handle --standalone mode (skip org config entirely) ───────────
707
+ if standalone:
708
+ # In standalone mode, never ask for team and never load org config
709
+ team = None
710
+ if not json_output and not pretty:
711
+ console.print("[dim]Running in standalone mode (no organization config)[/dim]")
712
+
713
+ # ── Step 0.5: Handle --offline mode (cache-only, fail fast) ───────────────
714
+ if offline and not standalone:
715
+ # Check if cached org config exists
716
+ cached = config.load_cached_org_config()
717
+ if cached is None:
718
+ console.print(
719
+ "[red]Error:[/red] --offline requires cached organization config.\n"
720
+ "[dim]Run 'scc setup' first to cache your org config.[/dim]",
721
+ highlight=False,
722
+ )
723
+ raise typer.Exit(EXIT_CONFIG)
724
+ if not json_output and not pretty:
725
+ console.print("[dim]Using cached organization config (offline mode)[/dim]")
726
+
727
+ # ── Step 1: First-run detection ──────────────────────────────────────────
728
+ # Skip setup wizard in standalone mode (no org config needed)
729
+ # Skip in offline mode (can't fetch remote - already validated cache exists)
730
+ if not standalone and not offline and setup.is_setup_needed():
731
+ if not setup.maybe_run_setup(console):
732
+ raise typer.Exit(1)
733
+
734
+ cfg = config.load_config()
735
+
736
+ # Treat --continue as alias for --resume (backward compatibility)
737
+ if continue_session:
738
+ resume = True
739
+
740
+ # ── Step 2: Session selection (interactive, --select, --resume) ──────────
741
+ workspace, team, session_name, worktree_name, cancelled = _resolve_session_selection(
742
+ workspace=workspace,
743
+ team=team,
744
+ resume=resume,
745
+ select=select,
746
+ cfg=cfg,
747
+ json_mode=(json_output or pretty),
748
+ standalone_override=standalone,
749
+ no_interactive=non_interactive,
750
+ )
751
+ if workspace is None:
752
+ if cancelled:
753
+ if not json_output and not pretty:
754
+ console.print("[dim]Cancelled.[/dim]")
755
+ raise typer.Exit(EXIT_CANCELLED)
756
+ if select or resume:
757
+ raise typer.Exit(EXIT_ERROR)
758
+ raise typer.Exit(EXIT_CANCELLED)
759
+
760
+ # ── Step 3: Docker availability check ────────────────────────────────────
761
+ with Status("[cyan]Checking Docker...[/cyan]", console=console, spinner=Spinners.DOCKER):
762
+ docker.check_docker_available()
763
+
764
+ # ── Step 4: Workspace validation and platform checks ─────────────────────
765
+ workspace_path = _validate_and_resolve_workspace(workspace, no_interactive=non_interactive)
766
+ if workspace_path is None:
767
+ if not json_output and not pretty:
768
+ console.print("[dim]Cancelled.[/dim]")
769
+ raise typer.Exit(EXIT_CANCELLED)
770
+ if not workspace_path.exists():
771
+ raise WorkspaceNotFoundError(path=str(workspace_path))
772
+
773
+ # ── Step 5: Workspace preparation (worktree, deps, git safety) ───────────
774
+ workspace_path = _prepare_workspace(workspace_path, worktree_name, install_deps)
775
+
776
+ # ── Step 5.5: Resolve team from workspace pinning ────────────────────────
777
+ team = _resolve_workspace_team(
778
+ workspace_path,
779
+ team,
780
+ cfg,
781
+ json_mode=(json_output or pretty),
782
+ standalone=standalone,
783
+ no_interactive=non_interactive,
784
+ )
785
+
786
+ # ── Step 6: Team configuration ───────────────────────────────────────────
787
+ # Skip team config in standalone mode (no org config to apply)
788
+ # In offline mode, team config still applies from cached org config
789
+ if not dry_run and not standalone:
790
+ _configure_team_settings(team, cfg)
791
+
792
+ # ── Step 6.5: Sync marketplace settings ────────────────────────────────
793
+ # Skip sync in offline mode (can't fetch remote data)
794
+ if not offline:
795
+ _sync_marketplace_settings(workspace_path, team)
796
+
797
+ # ── Step 6.6: Handle --dry-run (preview without launching) ────────────────
798
+ if dry_run:
799
+ org_config = config.load_cached_org_config()
800
+ project_config = None # TODO: Load from .scc.yaml if present
801
+
802
+ dry_run_data = build_dry_run_data(
803
+ workspace_path=workspace_path, # type: ignore[arg-type]
804
+ team=team,
805
+ org_config=org_config,
806
+ project_config=project_config,
807
+ )
808
+
809
+ # Handle --pretty implies --json
810
+ if pretty:
811
+ json_output = True
812
+
813
+ if json_output:
814
+ with json_output_mode():
815
+ if pretty:
816
+ set_pretty_mode(True)
817
+ try:
818
+ envelope = build_envelope(Kind.START_DRY_RUN, data=dry_run_data)
819
+ print_json(envelope)
820
+ finally:
821
+ if pretty:
822
+ set_pretty_mode(False)
823
+ else:
824
+ _show_dry_run_panel(dry_run_data)
825
+
826
+ raise typer.Exit(0)
827
+
828
+ _warn_if_non_worktree(workspace_path, json_mode=(json_output or pretty))
829
+
830
+ # ── Step 7: Resolve mount path and branch for worktrees ──────────────────
831
+ mount_path, current_branch = _resolve_mount_and_branch(workspace_path)
832
+
833
+ # ── Step 8: Launch sandbox ───────────────────────────────────────────────
834
+ should_continue_session = resume or continue_session
835
+ _launch_sandbox(
836
+ workspace_path=workspace_path,
837
+ mount_path=mount_path,
838
+ team=team,
839
+ session_name=session_name,
840
+ current_branch=current_branch,
841
+ should_continue_session=should_continue_session,
842
+ fresh=fresh,
843
+ )
844
+
845
+
846
+ def _show_launch_panel(
847
+ workspace: Path | None,
848
+ team: str | None,
849
+ session_name: str | None,
850
+ branch: str | None,
851
+ is_resume: bool,
852
+ ) -> None:
853
+ """Display launch info panel with session details.
854
+
855
+ Args:
856
+ workspace: Path to the workspace directory, or None.
857
+ team: Team profile name, or None for base profile.
858
+ session_name: Optional session name for identification.
859
+ branch: Current git branch, or None if not in a git repo.
860
+ is_resume: True if resuming an existing container.
861
+ """
862
+ grid = Table.grid(padding=(0, 2))
863
+ grid.add_column(style="dim", no_wrap=True)
864
+ grid.add_column(style="white")
865
+
866
+ if workspace:
867
+ # Shorten path for display
868
+ display_path = str(workspace)
869
+ if len(display_path) > MAX_DISPLAY_PATH_LENGTH:
870
+ display_path = "..." + display_path[-PATH_TRUNCATE_LENGTH:]
871
+ grid.add_row("Workspace:", display_path)
872
+
873
+ grid.add_row("Team:", team or "standalone")
874
+
875
+ if branch:
876
+ grid.add_row("Branch:", branch)
877
+
878
+ if session_name:
879
+ grid.add_row("Session:", session_name)
880
+
881
+ mode = "[green]Resume existing[/green]" if is_resume else "[cyan]New container[/cyan]"
882
+ grid.add_row("Mode:", mode)
883
+
884
+ panel = Panel(
885
+ grid,
886
+ title="[bold green]Launching Claude Code[/bold green]",
887
+ border_style="green",
888
+ padding=(0, 1),
889
+ )
890
+
891
+ console.print()
892
+ console.print(panel)
893
+ console.print()
894
+ console.print("[dim]Starting Docker sandbox...[/dim]")
895
+ console.print()
896
+
897
+
898
+ def _show_dry_run_panel(data: dict[str, Any]) -> None:
899
+ """Display dry run configuration preview.
900
+
901
+ Args:
902
+ data: Dictionary containing workspace, team, plugins, and ready_to_start status.
903
+ """
904
+ grid = Table.grid(padding=(0, 2))
905
+ grid.add_column(style="dim", no_wrap=True)
906
+ grid.add_column(style="white")
907
+
908
+ # Workspace
909
+ workspace = data.get("workspace", "")
910
+ if len(workspace) > MAX_DISPLAY_PATH_LENGTH:
911
+ workspace = "..." + workspace[-PATH_TRUNCATE_LENGTH:]
912
+ grid.add_row("Workspace:", workspace)
913
+
914
+ # Team
915
+ grid.add_row("Team:", data.get("team") or "standalone")
916
+
917
+ # Plugins
918
+ plugins = data.get("plugins", [])
919
+ if plugins:
920
+ plugin_list = ", ".join(p.get("name", "unknown") for p in plugins)
921
+ grid.add_row("Plugins:", plugin_list)
922
+ else:
923
+ grid.add_row("Plugins:", "[dim]none[/dim]")
924
+
925
+ # Ready status
926
+ ready = data.get("ready_to_start", True)
927
+ status = (
928
+ f"[green]{Indicators.get('PASS')} Ready to start[/green]"
929
+ if ready
930
+ else f"[red]{Indicators.get('FAIL')} Blocked[/red]"
931
+ )
932
+ grid.add_row("Status:", status)
933
+
934
+ # Blocked items
935
+ blocked = data.get("blocked_items", [])
936
+ if blocked:
937
+ for item in blocked:
938
+ grid.add_row("[red]Blocked:[/red]", item)
939
+
940
+ panel = Panel(
941
+ grid,
942
+ title="[bold cyan]Dry Run Preview[/bold cyan]",
943
+ border_style="cyan",
944
+ padding=(0, 1),
945
+ )
946
+
947
+ console.print()
948
+ console.print(panel)
949
+ console.print()
950
+ if ready:
951
+ console.print("[dim]Remove --dry-run to launch[/dim]")
952
+ console.print()
953
+
954
+
955
+ def interactive_start(
956
+ cfg: dict[str, Any],
957
+ *,
958
+ skip_quick_resume: bool = False,
959
+ allow_back: bool = False,
960
+ standalone_override: bool = False,
961
+ ) -> tuple[str | None, str | None, str | None, str | None]:
962
+ """Guide user through interactive session setup.
963
+
964
+ Prompt for team selection, workspace source, optional worktree creation,
965
+ and session naming.
966
+
967
+ The flow prioritizes quick resume by showing recent contexts first:
968
+ 0. Global Quick Resume - if contexts exist and skip_quick_resume=False
969
+ 1. Team selection - if no context selected (skipped in standalone mode)
970
+ 2. Workspace source selection
971
+ 2.5. Workspace-scoped Quick Resume - if contexts exist for selected workspace
972
+ 3. Worktree creation (optional)
973
+ 4. Session naming (optional)
974
+
975
+ Navigation Semantics:
976
+ - 'q' anywhere: Quit wizard entirely (returns None)
977
+ - Esc at Step 0: BACK to dashboard (if allow_back) or skip to Step 1
978
+ - Esc at Step 2: Go back to Step 1 (if team exists) or BACK to dashboard
979
+ - Esc at Step 2.5: Go back to Step 2 workspace picker
980
+ - 't' anywhere: Restart at Step 1 (team selection)
981
+
982
+ Args:
983
+ cfg: Application configuration dictionary containing workspace_base
984
+ and other settings.
985
+ skip_quick_resume: If True, bypass the Quick Resume picker and go
986
+ directly to project source selection. Used when starting from
987
+ dashboard empty states (no_containers, no_sessions) where resume
988
+ doesn't make sense.
989
+ allow_back: If True, Esc at top level returns BACK sentinel instead
990
+ of None. Used when called from Dashboard to enable return to
991
+ dashboard on Esc.
992
+ standalone_override: If True, force standalone mode regardless of
993
+ config. Used when --standalone CLI flag is passed.
994
+
995
+ Returns:
996
+ Tuple of (workspace, team, session_name, worktree_name).
997
+ - Success: (path, team, session, worktree) with path always set
998
+ - Cancel: (None, None, None, None) if user pressed q
999
+ - Back: (BACK, None, None, None) if allow_back and user pressed Esc
1000
+ """
1001
+ console.print(get_brand_header(), style=Colors.BRAND)
1002
+
1003
+ # Determine mode: standalone vs organization
1004
+ # CLI --standalone flag overrides config setting
1005
+ standalone_mode = standalone_override or config.is_standalone_mode()
1006
+
1007
+ active_team_label = cfg.get("selected_profile")
1008
+ if standalone_mode:
1009
+ active_team_label = "standalone"
1010
+ elif not active_team_label:
1011
+ active_team_label = "none"
1012
+ active_team_context = f"Team: {active_team_label}"
1013
+
1014
+ # Get available teams (from org config if available)
1015
+ org_config = config.load_cached_org_config()
1016
+ available_teams = teams.list_teams(cfg, org_config)
1017
+
1018
+ # Track if user dismissed global Quick Resume (to skip workspace-scoped QR)
1019
+ user_dismissed_quick_resume = False
1020
+
1021
+ # Step 0: Global Quick Resume
1022
+ # Skip when: entering from dashboard empty state (skip_quick_resume=True)
1023
+ # User can press 't' to switch teams (raises TeamSwitchRequested → skip to Step 1)
1024
+ if not skip_quick_resume:
1025
+ recent_contexts = load_recent_contexts(limit=10)
1026
+ if recent_contexts:
1027
+ try:
1028
+ result, selected_context = pick_context_quick_resume(
1029
+ recent_contexts,
1030
+ title="Quick Resume",
1031
+ standalone=standalone_mode,
1032
+ context_label=active_team_context,
1033
+ )
1034
+
1035
+ match result:
1036
+ case QuickResumeResult.SELECTED:
1037
+ # User pressed Enter - resume selected context
1038
+ if selected_context is not None:
1039
+ return (
1040
+ str(selected_context.worktree_path),
1041
+ selected_context.team,
1042
+ selected_context.last_session_id,
1043
+ None, # worktree_name - not creating new worktree
1044
+ )
1045
+
1046
+ case QuickResumeResult.BACK:
1047
+ # User pressed Esc - go back if we can (Dashboard context)
1048
+ if allow_back:
1049
+ return (BACK, None, None, None) # type: ignore[return-value]
1050
+ # CLI context: no previous screen, treat as cancel
1051
+ return (None, None, None, None)
1052
+
1053
+ case QuickResumeResult.NEW_SESSION:
1054
+ # User pressed 'n' - continue with normal wizard flow
1055
+ user_dismissed_quick_resume = True
1056
+ console.print()
1057
+
1058
+ case QuickResumeResult.CANCELLED:
1059
+ # User pressed q - cancel entire wizard
1060
+ return (None, None, None, None)
1061
+
1062
+ except TeamSwitchRequested:
1063
+ # User pressed 't' - skip to team selection (Step 1)
1064
+ # Reset Quick Resume dismissal so new team's contexts are shown
1065
+ user_dismissed_quick_resume = False
1066
+ console.print()
1067
+ else:
1068
+ # First-time hint: no recent contexts yet
1069
+ console.print(
1070
+ "[dim]💡 Tip: Your recent contexts will appear here for quick resume[/dim]"
1071
+ )
1072
+ console.print()
1073
+
1074
+ # ─────────────────────────────────────────────────────────────────────────
1075
+ # MEGA-LOOP: Wraps Steps 1-2.5 to handle 't' key (TeamSwitchRequested)
1076
+ # When user presses 't' anywhere, we restart from Step 1 (team selection)
1077
+ # ─────────────────────────────────────────────────────────────────────────
1078
+ while True:
1079
+ # Step 1: Select team (mode-aware handling)
1080
+ team: str | None = None
1081
+
1082
+ if standalone_mode:
1083
+ # P0.1: Standalone mode - skip team picker entirely
1084
+ # Solo devs don't need team selection friction
1085
+ # Only print banner if detected from config (CLI --standalone already printed in start())
1086
+ if not standalone_override:
1087
+ console.print("[dim]Running in standalone mode (no organization config)[/dim]")
1088
+ console.print()
1089
+ elif not available_teams:
1090
+ # P0.2: Org mode with no teams configured - exit with clear error
1091
+ # Get org URL for context in error message
1092
+ user_cfg = config.load_user_config()
1093
+ org_source = user_cfg.get("organization_source", {})
1094
+ org_url = org_source.get("url", "unknown")
1095
+
1096
+ console.print()
1097
+ console.print(
1098
+ create_warning_panel(
1099
+ "No Teams Configured",
1100
+ f"Organization config from: {org_url}\n"
1101
+ "No team profiles are defined in this organization.",
1102
+ "Contact your admin to add profiles, or use: scc start --standalone",
1103
+ )
1104
+ )
1105
+ console.print()
1106
+ raise typer.Exit(EXIT_CONFIG)
1107
+ else:
1108
+ # Normal flow: org mode with teams available
1109
+ team = select_team(console, cfg)
1110
+
1111
+ # Step 2: Select workspace source (with back navigation support)
1112
+ workspace: str | None = None
1113
+ team_context_label = active_team_context
1114
+ if team:
1115
+ team_context_label = f"Team: {team}"
1116
+
1117
+ # Check if team has repositories configured (must be inside mega-loop since team can change)
1118
+ team_config = cfg.get("profiles", {}).get(team, {}) if team else {}
1119
+ team_repos: list[dict[str, Any]] = team_config.get("repositories", [])
1120
+ has_team_repos = bool(team_repos)
1121
+
1122
+ try:
1123
+ # Outer loop: allows Step 2.5 to go BACK to Step 2 (workspace picker)
1124
+ while True:
1125
+ # Step 2: Workspace selection loop
1126
+ while workspace is None:
1127
+ # Top-level picker: supports three-state contract
1128
+ source = pick_workspace_source(
1129
+ has_team_repos=has_team_repos,
1130
+ team=team,
1131
+ standalone=standalone_mode,
1132
+ allow_back=allow_back or (team is not None),
1133
+ context_label=team_context_label,
1134
+ )
1135
+
1136
+ # Handle three-state return contract
1137
+ if source is BACK:
1138
+ if team is not None:
1139
+ # Esc in org mode: go back to Step 1 (team selection)
1140
+ raise TeamSwitchRequested() # Will be caught by mega-loop
1141
+ elif allow_back:
1142
+ # Esc in standalone mode with allow_back: return to dashboard
1143
+ return (BACK, None, None, None) # type: ignore[return-value]
1144
+ else:
1145
+ # Esc in standalone CLI mode: cancel wizard
1146
+ return (None, None, None, None)
1147
+
1148
+ if source is None:
1149
+ # q pressed: quit entirely
1150
+ return (None, None, None, None)
1151
+
1152
+ if source == WorkspaceSource.CURRENT_DIR:
1153
+ # Detect workspace root from CWD (handles subdirs + worktrees)
1154
+ detected_root, _start_cwd = git.detect_workspace_root(Path.cwd())
1155
+ if detected_root:
1156
+ workspace = str(detected_root)
1157
+ else:
1158
+ # Fall back to CWD if no workspace root detected
1159
+ workspace = str(Path.cwd())
1160
+
1161
+ elif source == WorkspaceSource.RECENT:
1162
+ recent = sessions.list_recent(10)
1163
+ picker_result = pick_recent_workspace(
1164
+ recent,
1165
+ standalone=standalone_mode,
1166
+ context_label=team_context_label,
1167
+ )
1168
+ if picker_result is None:
1169
+ return (None, None, None, None) # User pressed q - quit wizard
1170
+ if picker_result is BACK:
1171
+ continue # User pressed Esc - go back to source picker
1172
+ workspace = cast(str, picker_result)
1173
+
1174
+ elif source == WorkspaceSource.TEAM_REPOS:
1175
+ workspace_base = cfg.get("workspace_base", "~/projects")
1176
+ picker_result = pick_team_repo(
1177
+ team_repos,
1178
+ workspace_base,
1179
+ standalone=standalone_mode,
1180
+ context_label=team_context_label,
1181
+ )
1182
+ if picker_result is None:
1183
+ return (None, None, None, None) # User pressed q - quit wizard
1184
+ if picker_result is BACK:
1185
+ continue # User pressed Esc - go back to source picker
1186
+ workspace = cast(str, picker_result)
1187
+
1188
+ elif source == WorkspaceSource.CUSTOM:
1189
+ workspace = prompt_custom_workspace(console)
1190
+ # Empty input means go back
1191
+ if workspace is None:
1192
+ continue
1193
+
1194
+ elif source == WorkspaceSource.CLONE:
1195
+ repo_url = prompt_repo_url(console)
1196
+ if repo_url:
1197
+ workspace = git.clone_repo(
1198
+ repo_url, cfg.get("workspace_base", "~/projects")
1199
+ )
1200
+ # Empty URL means go back
1201
+ if workspace is None:
1202
+ continue
1203
+
1204
+ # ─────────────────────────────────────────────────────────────────
1205
+ # Step 2.5: Workspace-scoped Quick Resume
1206
+ # After selecting a workspace, check if existing contexts exist
1207
+ # and offer to resume one instead of starting fresh
1208
+ # ─────────────────────────────────────────────────────────────────
1209
+ normalized_workspace = normalize_path(workspace)
1210
+
1211
+ # Smart filter: Match contexts related to this workspace AND team
1212
+ workspace_contexts = []
1213
+ for ctx in load_recent_contexts(limit=30):
1214
+ # Filter by team in org mode (prevents cross-team resume confusion)
1215
+ if team is not None and ctx.team != team:
1216
+ continue
1217
+
1218
+ # Case 1: Exact worktree match (fastest check)
1219
+ if ctx.worktree_path == normalized_workspace:
1220
+ workspace_contexts.append(ctx)
1221
+ continue
1222
+
1223
+ # Case 2: User picked repo root - show all worktree contexts for this repo
1224
+ if ctx.repo_root == normalized_workspace:
1225
+ workspace_contexts.append(ctx)
1226
+ continue
1227
+
1228
+ # Case 3: User picked a subdir - match if inside a known worktree/repo
1229
+ try:
1230
+ if normalized_workspace.is_relative_to(ctx.worktree_path):
1231
+ workspace_contexts.append(ctx)
1232
+ continue
1233
+ if normalized_workspace.is_relative_to(ctx.repo_root):
1234
+ workspace_contexts.append(ctx)
1235
+ except ValueError:
1236
+ # is_relative_to raises ValueError if paths are on different drives
1237
+ pass
1238
+
1239
+ # Skip workspace-scoped Quick Resume if user already dismissed global Quick Resume
1240
+ if workspace_contexts and not user_dismissed_quick_resume:
1241
+ console.print()
1242
+
1243
+ # Use flag pattern for control flow (avoid continue inside match block)
1244
+ go_back_to_workspace = False
1245
+
1246
+ result, selected_context = pick_context_quick_resume(
1247
+ workspace_contexts,
1248
+ title=f"Resume session in {Path(workspace).name}?",
1249
+ subtitle="Existing sessions found for this workspace",
1250
+ standalone=standalone_mode,
1251
+ context_label=f"Team: {team or active_team_label}",
1252
+ )
1253
+ # Note: TeamSwitchRequested bubbles up to mega-loop handler
1254
+
1255
+ match result:
1256
+ case QuickResumeResult.SELECTED:
1257
+ # User wants to resume - return context info immediately
1258
+ if selected_context is not None:
1259
+ return (
1260
+ str(selected_context.worktree_path),
1261
+ selected_context.team,
1262
+ selected_context.last_session_id,
1263
+ None, # worktree_name - not creating new worktree
1264
+ )
1265
+
1266
+ case QuickResumeResult.NEW_SESSION:
1267
+ # User pressed 'n' - continue with fresh session
1268
+ pass # Fall through to break below
1269
+
1270
+ case QuickResumeResult.BACK:
1271
+ # User pressed Esc - go back to workspace picker (Step 2)
1272
+ go_back_to_workspace = True
1273
+
1274
+ case QuickResumeResult.CANCELLED:
1275
+ # User pressed q - cancel entire wizard
1276
+ return (None, None, None, None)
1277
+
1278
+ # Handle flag-based control flow outside match block
1279
+ if go_back_to_workspace:
1280
+ workspace = None
1281
+ continue # Continue outer loop to re-enter Step 2
1282
+
1283
+ # No contexts or user dismissed global Quick Resume - proceed to Step 3
1284
+ break # Exit outer loop (Step 2 + 2.5)
1285
+
1286
+ except TeamSwitchRequested:
1287
+ # User pressed 't' somewhere - restart at Step 1 (team selection)
1288
+ # Reset Quick Resume dismissal so new team's contexts are shown
1289
+ user_dismissed_quick_resume = False
1290
+ console.print()
1291
+ continue # Continue mega-loop
1292
+
1293
+ # Successfully got a workspace - exit mega-loop
1294
+ break
1295
+
1296
+ # Step 3: Worktree option
1297
+ worktree_name = None
1298
+ console.print()
1299
+ if Confirm.ask(
1300
+ "[cyan]Create a worktree for isolated feature development?[/cyan]",
1301
+ default=False,
1302
+ ):
1303
+ worktree_name = Prompt.ask("[cyan]Feature/worktree name[/cyan]")
1304
+
1305
+ # Step 4: Session name
1306
+ session_name = (
1307
+ Prompt.ask(
1308
+ "\n[cyan]Session name[/cyan] [dim](optional, for easy resume)[/dim]",
1309
+ default="",
1310
+ )
1311
+ or None
1312
+ )
1313
+
1314
+ return workspace, team, session_name, worktree_name
1315
+
1316
+
1317
+ def run_start_wizard_flow(
1318
+ *, skip_quick_resume: bool = False, allow_back: bool = False
1319
+ ) -> bool | None:
1320
+ """Run the interactive start wizard and launch sandbox.
1321
+
1322
+ This is the shared entrypoint for starting sessions from both the CLI
1323
+ (scc start with no args) and the dashboard (Enter on empty containers).
1324
+
1325
+ The function runs outside any Rich Live context to avoid nested Live
1326
+ conflicts. It handles the complete flow:
1327
+ 1. Run interactive wizard to get user selections
1328
+ 2. If user cancels, return False/None
1329
+ 3. Otherwise, validate and launch the sandbox
1330
+
1331
+ Args:
1332
+ skip_quick_resume: If True, bypass the Quick Resume picker and go
1333
+ directly to project source selection. Used when starting from
1334
+ dashboard empty states where "resume" doesn't make sense.
1335
+ allow_back: If True, Esc returns BACK sentinel (for dashboard context).
1336
+ If False, Esc returns None (for CLI context).
1337
+
1338
+ Returns:
1339
+ True if sandbox was launched successfully.
1340
+ False if user pressed Esc to go back (only when allow_back=True).
1341
+ None if user pressed q to quit or an error occurred.
1342
+ """
1343
+ # Step 1: First-run detection
1344
+ if setup.is_setup_needed():
1345
+ if not setup.maybe_run_setup(console):
1346
+ return None # Error during setup
1347
+
1348
+ cfg = config.load_config()
1349
+
1350
+ # Step 2: Run interactive wizard
1351
+ # Note: standalone_override=False (default) is correct here - dashboard path
1352
+ # doesn't have CLI flags, so we rely on config.is_standalone_mode() inside
1353
+ # interactive_start() to detect standalone mode from user's config file.
1354
+ workspace, team, session_name, worktree_name = interactive_start(
1355
+ cfg, skip_quick_resume=skip_quick_resume, allow_back=allow_back
1356
+ )
1357
+
1358
+ # Three-state return handling:
1359
+ # - workspace is BACK → user pressed Esc (go back to dashboard)
1360
+ # - workspace is None → user pressed q (quit app)
1361
+ if workspace is BACK:
1362
+ return False # Go back to dashboard
1363
+ if workspace is None:
1364
+ return None # Quit app
1365
+
1366
+ try:
1367
+ # Step 3: Docker availability check
1368
+ with Status("[cyan]Checking Docker...[/cyan]", console=console, spinner=Spinners.DOCKER):
1369
+ docker.check_docker_available()
1370
+
1371
+ # Step 4: Workspace validation
1372
+ workspace_path = _validate_and_resolve_workspace(workspace)
1373
+
1374
+ # Step 5: Workspace preparation (worktree, deps, git safety)
1375
+ workspace_path = _prepare_workspace(workspace_path, worktree_name, install_deps=False)
1376
+
1377
+ # Step 6: Team configuration
1378
+ _configure_team_settings(team, cfg)
1379
+
1380
+ # Step 6.5: Sync marketplace settings
1381
+ _sync_marketplace_settings(workspace_path, team)
1382
+
1383
+ # Step 7: Resolve mount path and branch
1384
+ mount_path, current_branch = _resolve_mount_and_branch(workspace_path)
1385
+
1386
+ # Step 8: Launch sandbox (fresh start, not resume)
1387
+ _launch_sandbox(
1388
+ workspace_path=workspace_path,
1389
+ mount_path=mount_path,
1390
+ team=team,
1391
+ session_name=session_name,
1392
+ current_branch=current_branch,
1393
+ should_continue_session=False, # Fresh start
1394
+ fresh=False,
1395
+ )
1396
+ return True
1397
+
1398
+ except Exception as e:
1399
+ console.print(f"[red]Error launching sandbox: {e}[/red]")
1400
+ return False