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