scc-cli 1.5.3__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1247 @@
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
+ from pathlib import Path
17
+ from typing import Any, cast
18
+
19
+ import typer
20
+ from rich.prompt import Prompt
21
+ from rich.status import Status
22
+
23
+ from ... import config, docker, git, sessions, setup, teams
24
+ from ...cli_common import (
25
+ console,
26
+ err_console,
27
+ handle_errors,
28
+ )
29
+ from ...confirm import Confirm
30
+ from ...contexts import load_recent_contexts, normalize_path
31
+ from ...core.errors import WorkspaceNotFoundError
32
+ from ...core.exit_codes import EXIT_CANCELLED, EXIT_CONFIG, EXIT_ERROR, EXIT_USAGE
33
+ from ...json_output import build_envelope
34
+ from ...kinds import Kind
35
+ from ...marketplace.sync import SyncError, SyncResult, sync_marketplace_settings
36
+ from ...output_mode import json_output_mode, print_json, set_pretty_mode
37
+ from ...panels import create_warning_panel
38
+ from ...theme import Colors, Indicators, Spinners, get_brand_header
39
+ from ...ui.gate import is_interactive_allowed
40
+ from ...ui.picker import (
41
+ QuickResumeResult,
42
+ TeamSwitchRequested,
43
+ pick_context_quick_resume,
44
+ pick_team,
45
+ )
46
+ from ...ui.prompts import (
47
+ prompt_custom_workspace,
48
+ prompt_repo_url,
49
+ select_session,
50
+ )
51
+ from ...ui.wizard import (
52
+ BACK,
53
+ WorkspaceSource,
54
+ pick_recent_workspace,
55
+ pick_team_repo,
56
+ pick_workspace_source,
57
+ )
58
+ from .render import (
59
+ build_dry_run_data,
60
+ show_dry_run_panel,
61
+ warn_if_non_worktree,
62
+ )
63
+ from .sandbox import launch_sandbox
64
+ from .workspace import (
65
+ prepare_workspace,
66
+ resolve_mount_and_branch,
67
+ resolve_workspace_team,
68
+ validate_and_resolve_workspace,
69
+ )
70
+
71
+ # ─────────────────────────────────────────────────────────────────────────────
72
+ # Helper Functions (extracted for maintainability)
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+
75
+
76
+ def _resolve_session_selection(
77
+ workspace: str | None,
78
+ team: str | None,
79
+ resume: bool,
80
+ select: bool,
81
+ cfg: dict[str, Any],
82
+ *,
83
+ json_mode: bool = False,
84
+ standalone_override: bool = False,
85
+ no_interactive: bool = False,
86
+ dry_run: bool = False,
87
+ ) -> tuple[str | None, str | None, str | None, str | None, bool, bool]:
88
+ """
89
+ Handle session selection logic for --select, --resume, and interactive modes.
90
+
91
+ Args:
92
+ workspace: Workspace path from command line.
93
+ team: Team name from command line.
94
+ resume: Whether --resume flag is set.
95
+ select: Whether --select flag is set.
96
+ cfg: Loaded configuration.
97
+ json_mode: Whether --json output is requested (blocks interactive).
98
+ standalone_override: Whether --standalone flag is set (overrides config).
99
+
100
+ Returns:
101
+ Tuple of (workspace, team, session_name, worktree_name, cancelled, was_auto_detected)
102
+ If user cancels or no session found, workspace will be None.
103
+ cancelled is True only for explicit user cancellation.
104
+ was_auto_detected is True if workspace was found via resolver (git/.scc.yaml).
105
+
106
+ Raises:
107
+ typer.Exit: If interactive mode required but not allowed (non-TTY, CI, --json).
108
+ """
109
+ session_name = None
110
+ worktree_name = None
111
+ cancelled = False
112
+
113
+ # Interactive mode if no workspace provided and no session flags
114
+ if workspace is None and not resume and not select:
115
+ # For --dry-run without workspace, use resolver to auto-detect (skip interactive)
116
+ if dry_run:
117
+ from pathlib import Path
118
+
119
+ from ...services.workspace import resolve_launch_context
120
+
121
+ result = resolve_launch_context(Path.cwd(), workspace_arg=None)
122
+ if result is not None:
123
+ return str(result.workspace_root), team, None, None, False, True # auto-detected
124
+ # No auto-detect possible, fall through to error
125
+ err_console.print(
126
+ "[red]Error:[/red] No workspace could be auto-detected.\n"
127
+ "[dim]Provide a workspace path: scc start --dry-run /path/to/project[/dim]",
128
+ highlight=False,
129
+ )
130
+ raise typer.Exit(EXIT_USAGE)
131
+
132
+ # Check TTY gating before entering interactive mode
133
+ if not is_interactive_allowed(
134
+ json_mode=json_mode,
135
+ no_interactive_flag=no_interactive,
136
+ ):
137
+ # Try auto-detect before failing
138
+ from pathlib import Path
139
+
140
+ from ...services.workspace import resolve_launch_context
141
+
142
+ result = resolve_launch_context(Path.cwd(), workspace_arg=None)
143
+ if result is not None:
144
+ return str(result.workspace_root), team, None, None, False, True # auto-detected
145
+
146
+ err_console.print(
147
+ "[red]Error:[/red] Interactive mode requires a terminal (TTY).\n"
148
+ "[dim]Provide a workspace path: scc start /path/to/project[/dim]",
149
+ highlight=False,
150
+ )
151
+ raise typer.Exit(EXIT_USAGE)
152
+ workspace, team, session_name, worktree_name = interactive_start(
153
+ cfg, standalone_override=standalone_override, team_override=team
154
+ )
155
+ if workspace is None:
156
+ return None, team, None, None, True, False
157
+ return workspace, team, session_name, worktree_name, False, False # user picked
158
+
159
+ # Handle --select: interactive session picker
160
+ if select and workspace is None:
161
+ # Check TTY gating before showing session picker
162
+ if not is_interactive_allowed(
163
+ json_mode=json_mode,
164
+ no_interactive_flag=no_interactive,
165
+ ):
166
+ console.print(
167
+ "[red]Error:[/red] --select requires a terminal (TTY).\n"
168
+ "[dim]Use --resume to auto-select most recent session.[/dim]",
169
+ highlight=False,
170
+ )
171
+ raise typer.Exit(EXIT_USAGE)
172
+
173
+ # Prefer explicit --team, then selected_profile for filtering
174
+ effective_team = team or cfg.get("selected_profile")
175
+ if standalone_override:
176
+ effective_team = None
177
+
178
+ # If org mode and no active team, require explicit selection
179
+ if effective_team is None and not standalone_override:
180
+ if not json_mode:
181
+ console.print(
182
+ "[yellow]No active team selected.[/yellow] "
183
+ "Run 'scc team switch' or pass --team to select."
184
+ )
185
+ return None, team, None, None, False, False
186
+
187
+ recent_sessions = sessions.list_recent(limit=10)
188
+ if effective_team is None:
189
+ filtered_sessions = [s for s in recent_sessions if s.get("team") is None]
190
+ else:
191
+ filtered_sessions = [s for s in recent_sessions if s.get("team") == effective_team]
192
+
193
+ if not filtered_sessions:
194
+ if not json_mode:
195
+ console.print("[yellow]No recent sessions found.[/yellow]")
196
+ return None, team, None, None, False, False
197
+
198
+ selected = select_session(console, filtered_sessions)
199
+ if selected is None:
200
+ return None, team, None, None, True, False
201
+ workspace = selected.get("workspace")
202
+ if not team:
203
+ team = selected.get("team")
204
+ # --standalone overrides any team from session (standalone means no team)
205
+ if standalone_override:
206
+ team = None
207
+ if not json_mode:
208
+ console.print(f"[dim]Selected: {workspace}[/dim]")
209
+
210
+ # Handle --resume: auto-select most recent session
211
+ elif resume and workspace is None:
212
+ # Prefer explicit --team, then selected_profile for resume filtering
213
+ effective_team = team or cfg.get("selected_profile")
214
+ if standalone_override:
215
+ effective_team = None
216
+
217
+ # If org mode and no active team, require explicit selection
218
+ if effective_team is None and not standalone_override:
219
+ if not json_mode:
220
+ console.print(
221
+ "[yellow]No active team selected.[/yellow] "
222
+ "Run 'scc team switch' or pass --team to resume."
223
+ )
224
+ return None, team, None, None, False, False
225
+
226
+ recent_sessions = sessions.list_recent(limit=50)
227
+ if effective_team is None:
228
+ filtered_sessions = [s for s in recent_sessions if s.get("team") is None]
229
+ else:
230
+ filtered_sessions = [s for s in recent_sessions if s.get("team") == effective_team]
231
+
232
+ if filtered_sessions:
233
+ recent_session = filtered_sessions[0]
234
+ workspace = recent_session.get("workspace")
235
+ if not team:
236
+ team = recent_session.get("team")
237
+ # --standalone overrides any team from session (standalone means no team)
238
+ if standalone_override:
239
+ team = None
240
+ if not json_mode:
241
+ console.print(f"[dim]Resuming: {workspace}[/dim]")
242
+ else:
243
+ if not json_mode:
244
+ console.print("[yellow]No recent sessions found.[/yellow]")
245
+ return None, team, None, None, False, False
246
+
247
+ return workspace, team, session_name, worktree_name, cancelled, False # explicit workspace
248
+
249
+
250
+ def _configure_team_settings(team: str | None, cfg: dict[str, Any]) -> None:
251
+ """
252
+ Validate team profile exists.
253
+
254
+ NOTE: Plugin settings are now sourced ONLY from workspace settings.local.json
255
+ (via _sync_marketplace_settings). Docker volume injection has been removed
256
+ to prevent plugin mixing across teams.
257
+
258
+ IMPORTANT: This function must remain cache-only (no network calls).
259
+ It's called in offline mode where only cached org config is available.
260
+ If you need to add network operations, gate them with an offline check
261
+ or move them to _sync_marketplace_settings() which is already offline-aware.
262
+
263
+ Raises:
264
+ typer.Exit: If team profile is not found.
265
+ """
266
+ if not team:
267
+ return
268
+
269
+ with Status(
270
+ f"[cyan]Validating {team} profile...[/cyan]", console=console, spinner=Spinners.SETUP
271
+ ):
272
+ # load_cached_org_config() reads from local cache only - safe for offline mode
273
+ org_config = config.load_cached_org_config()
274
+
275
+ validation = teams.validate_team_profile(team, cfg, org_config=org_config)
276
+ if not validation["valid"]:
277
+ console.print(
278
+ create_warning_panel(
279
+ "Team Not Found",
280
+ f"No team profile named '{team}'.",
281
+ "Run 'scc team list' to see available profiles",
282
+ )
283
+ )
284
+ raise typer.Exit(1)
285
+
286
+ # NOTE: docker.inject_team_settings() removed - workspace settings.local.json
287
+ # is now the single source of truth for plugins (prevents cross-team mixing)
288
+
289
+
290
+ def _sync_marketplace_settings(
291
+ workspace_path: Path | None,
292
+ team: str | None,
293
+ org_config_url: str | None = None,
294
+ ) -> SyncResult | None:
295
+ """
296
+ Sync marketplace settings for the workspace.
297
+
298
+ Orchestrates the full marketplace pipeline:
299
+ 1. Compute effective plugins for team
300
+ 2. Materialize required marketplaces
301
+ 3. Render and merge settings
302
+ 4. Write settings.local.json
303
+
304
+ Args:
305
+ workspace_path: Path to the workspace directory.
306
+ team: Selected team profile name.
307
+ org_config_url: URL of the org config (for tracking).
308
+
309
+ Returns:
310
+ SyncResult with details, or None if no sync needed.
311
+
312
+ Raises:
313
+ typer.Exit: If marketplace sync fails critically.
314
+ """
315
+ if workspace_path is None or team is None:
316
+ return None
317
+
318
+ org_config = config.load_cached_org_config()
319
+ if org_config is None:
320
+ return None
321
+
322
+ with Status(
323
+ "[cyan]Syncing marketplace settings...[/cyan]", console=console, spinner=Spinners.NETWORK
324
+ ):
325
+ try:
326
+ result = sync_marketplace_settings(
327
+ project_dir=workspace_path,
328
+ org_config_data=org_config,
329
+ team_id=team,
330
+ org_config_url=org_config_url,
331
+ )
332
+
333
+ # Display any warnings
334
+ if result.warnings:
335
+ console.print()
336
+ for warning in result.warnings:
337
+ console.print(f"[yellow]{warning}[/yellow]")
338
+ console.print()
339
+
340
+ # Log success
341
+ if result.plugins_enabled:
342
+ console.print(
343
+ f"[green]{Indicators.get('PASS')} Enabled {len(result.plugins_enabled)} team plugin(s)[/green]"
344
+ )
345
+ if result.marketplaces_materialized:
346
+ console.print(
347
+ f"[green]{Indicators.get('PASS')} Materialized {len(result.marketplaces_materialized)} marketplace(s)[/green]"
348
+ )
349
+
350
+ # NOTE: We intentionally do NOT inject marketplace settings to Docker volume.
351
+ # Workspace-relative paths (.claude/.scc-marketplaces/...) only resolve
352
+ # correctly from the workspace directory. The container runs with -w <workspace>
353
+ # so Claude Code reads settings.local.json from the workspace context.
354
+
355
+ return result
356
+
357
+ except SyncError as e:
358
+ console.print(
359
+ create_warning_panel(
360
+ "Marketplace Sync Failed",
361
+ str(e),
362
+ "Team plugins may not be available. Use --dry-run to diagnose.",
363
+ )
364
+ )
365
+ # Non-fatal: continue without marketplace sync
366
+ return None
367
+
368
+
369
+ # ─────────────────────────────────────────────────────────────────────────────
370
+ # Launch App
371
+ # ─────────────────────────────────────────────────────────────────────────────
372
+
373
+ launch_app = typer.Typer(
374
+ name="launch",
375
+ help="Start Claude Code in sandboxes.",
376
+ no_args_is_help=False,
377
+ context_settings={"help_option_names": ["-h", "--help"]},
378
+ )
379
+
380
+
381
+ # ─────────────────────────────────────────────────────────────────────────────
382
+ # Start Command
383
+ # ─────────────────────────────────────────────────────────────────────────────
384
+
385
+
386
+ @handle_errors
387
+ def start(
388
+ workspace: str | None = typer.Argument(None, help="Path to workspace (optional)"),
389
+ team: str | None = typer.Option(None, "-t", "--team", help="Team profile to use"),
390
+ session_name: str | None = typer.Option(None, "--session", help="Session name"),
391
+ resume: bool = typer.Option(False, "-r", "--resume", help="Resume most recent session"),
392
+ select: bool = typer.Option(False, "-s", "--select", help="Select from recent sessions"),
393
+ continue_session: bool = typer.Option(False, "-c", "--continue", hidden=True),
394
+ worktree_name: str | None = typer.Option(None, "-w", "--worktree", help="Worktree name"),
395
+ fresh: bool = typer.Option(False, "--fresh", help="Force new container"),
396
+ install_deps: bool = typer.Option(False, "--install-deps", help="Install dependencies"),
397
+ offline: bool = typer.Option(False, "--offline", help="Use cached config only (error if none)"),
398
+ standalone: bool = typer.Option(False, "--standalone", help="Run without organization config"),
399
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview config without launching"),
400
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
401
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
402
+ non_interactive: bool = typer.Option(
403
+ False,
404
+ "--non-interactive",
405
+ "--no-interactive",
406
+ help="Fail fast if interactive input would be required",
407
+ ),
408
+ allow_suspicious_workspace: bool = typer.Option(
409
+ False,
410
+ "--allow-suspicious-workspace",
411
+ help="Allow starting in suspicious directories (e.g., home, /tmp) in non-interactive mode",
412
+ ),
413
+ ) -> None:
414
+ """
415
+ Start Claude Code in a Docker sandbox.
416
+
417
+ If no arguments provided, launches interactive mode.
418
+ """
419
+ from pathlib import Path
420
+
421
+ # Capture original CWD for entry_dir tracking (before any directory changes)
422
+ original_cwd = Path.cwd()
423
+
424
+ # ── Fast Fail: Validate mode flags before any processing ──────────────────
425
+ from scc_cli.ui.gate import validate_mode_flags
426
+
427
+ validate_mode_flags(
428
+ json_mode=(json_output or pretty),
429
+ select=select,
430
+ )
431
+
432
+ # ── Step 0: Handle --standalone mode (skip org config entirely) ───────────
433
+ if standalone:
434
+ # In standalone mode, never ask for team and never load org config
435
+ team = None
436
+ if not json_output and not pretty:
437
+ console.print("[dim]Running in standalone mode (no organization config)[/dim]")
438
+
439
+ # ── Step 0.5: Handle --offline mode (cache-only, fail fast) ───────────────
440
+ if offline and not standalone:
441
+ # Check if cached org config exists
442
+ cached = config.load_cached_org_config()
443
+ if cached is None:
444
+ err_console.print(
445
+ "[red]Error:[/red] --offline requires cached organization config.\n"
446
+ "[dim]Run 'scc setup' first to cache your org config.[/dim]",
447
+ highlight=False,
448
+ )
449
+ raise typer.Exit(EXIT_CONFIG)
450
+ if not json_output and not pretty:
451
+ console.print("[dim]Using cached organization config (offline mode)[/dim]")
452
+
453
+ # ── Step 1: First-run detection ──────────────────────────────────────────
454
+ # Skip setup wizard in standalone mode (no org config needed)
455
+ # Skip in offline mode (can't fetch remote - already validated cache exists)
456
+ if not standalone and not offline and setup.is_setup_needed():
457
+ if not setup.maybe_run_setup(console):
458
+ raise typer.Exit(1)
459
+
460
+ cfg = config.load_config()
461
+
462
+ # Treat --continue as alias for --resume (backward compatibility)
463
+ if continue_session:
464
+ resume = True
465
+
466
+ # ── Step 2: Session selection (interactive, --select, --resume) ──────────
467
+ workspace, team, session_name, worktree_name, cancelled, was_auto_detected = (
468
+ _resolve_session_selection(
469
+ workspace=workspace,
470
+ team=team,
471
+ resume=resume,
472
+ select=select,
473
+ cfg=cfg,
474
+ json_mode=(json_output or pretty),
475
+ standalone_override=standalone,
476
+ no_interactive=non_interactive,
477
+ dry_run=dry_run,
478
+ )
479
+ )
480
+ if workspace is None:
481
+ if cancelled:
482
+ if not json_output and not pretty:
483
+ console.print("[dim]Cancelled.[/dim]")
484
+ raise typer.Exit(EXIT_CANCELLED)
485
+ if select or resume:
486
+ raise typer.Exit(EXIT_ERROR)
487
+ raise typer.Exit(EXIT_CANCELLED)
488
+
489
+ # ── Step 3: Docker availability check ────────────────────────────────────
490
+ # Skip Docker check for dry-run (just previewing config)
491
+ if not dry_run:
492
+ with Status("[cyan]Checking Docker...[/cyan]", console=console, spinner=Spinners.DOCKER):
493
+ docker.check_docker_available()
494
+
495
+ # ── Step 4: Workspace validation and platform checks ─────────────────────
496
+ workspace_path = validate_and_resolve_workspace(
497
+ workspace,
498
+ no_interactive=non_interactive,
499
+ allow_suspicious=allow_suspicious_workspace,
500
+ json_mode=(json_output or pretty),
501
+ )
502
+ if workspace_path is None:
503
+ if not json_output and not pretty:
504
+ console.print("[dim]Cancelled.[/dim]")
505
+ raise typer.Exit(EXIT_CANCELLED)
506
+ if not workspace_path.exists():
507
+ raise WorkspaceNotFoundError(path=str(workspace_path))
508
+
509
+ # ── Step 5: Workspace preparation (worktree, deps, git safety) ───────────
510
+ # Skip for dry-run (no worktree creation, no deps, no branch safety prompts)
511
+ if not dry_run:
512
+ workspace_path = prepare_workspace(workspace_path, worktree_name, install_deps)
513
+
514
+ # ── Step 5.5: Resolve team from workspace pinning ────────────────────────
515
+ team = resolve_workspace_team(
516
+ workspace_path,
517
+ team,
518
+ cfg,
519
+ json_mode=(json_output or pretty),
520
+ standalone=standalone,
521
+ no_interactive=non_interactive,
522
+ )
523
+
524
+ # ── Step 6: Team configuration ───────────────────────────────────────────
525
+ # Skip team config in standalone mode (no org config to apply)
526
+ # In offline mode, team config still applies from cached org config
527
+ if not dry_run and not standalone:
528
+ _configure_team_settings(team, cfg)
529
+
530
+ # ── Step 6.5: Sync marketplace settings ────────────────────────────────
531
+ # Skip sync in offline mode (can't fetch remote data)
532
+ if not offline:
533
+ _sync_marketplace_settings(workspace_path, team)
534
+
535
+ # ── Step 6.6: Resolve mount path for worktrees (needed for dry-run too) ────
536
+ # At this point workspace_path is guaranteed to exist (validated above)
537
+ assert workspace_path is not None
538
+ mount_path, current_branch = resolve_mount_and_branch(
539
+ workspace_path, json_mode=(json_output or pretty)
540
+ )
541
+
542
+ # ── Step 6.7: Handle --dry-run (preview without launching) ────────────────
543
+ if dry_run:
544
+ # Use resolver for consistent ED/MR/CW (single source of truth)
545
+ from ...services.workspace import resolve_launch_context
546
+
547
+ # Pass None for workspace_arg if auto-detected (resolver finds it again)
548
+ # Pass explicit path if user provided one (preserves their intent)
549
+ workspace_arg = None if was_auto_detected else str(workspace_path)
550
+ result = resolve_launch_context(
551
+ original_cwd, workspace_arg, allow_suspicious=allow_suspicious_workspace
552
+ )
553
+ # Workspace already validated, resolver must succeed
554
+ assert result is not None, f"Resolver failed for validated workspace: {workspace_path}"
555
+
556
+ org_config = config.load_cached_org_config()
557
+ dry_run_data = build_dry_run_data(
558
+ workspace_path=workspace_path,
559
+ team=team,
560
+ org_config=org_config,
561
+ project_config=None,
562
+ entry_dir=result.entry_dir,
563
+ mount_root=result.mount_root,
564
+ container_workdir=result.container_workdir,
565
+ resolution_reason=result.reason,
566
+ )
567
+
568
+ # Handle --pretty implies --json
569
+ if pretty:
570
+ json_output = True
571
+
572
+ if json_output:
573
+ with json_output_mode():
574
+ if pretty:
575
+ set_pretty_mode(True)
576
+ try:
577
+ envelope = build_envelope(Kind.START_DRY_RUN, data=dry_run_data)
578
+ print_json(envelope)
579
+ finally:
580
+ if pretty:
581
+ set_pretty_mode(False)
582
+ else:
583
+ show_dry_run_panel(dry_run_data)
584
+
585
+ raise typer.Exit(0)
586
+
587
+ warn_if_non_worktree(workspace_path, json_mode=(json_output or pretty))
588
+
589
+ # ── Step 8: Launch sandbox ───────────────────────────────────────────────
590
+ should_continue_session = resume or continue_session
591
+ launch_sandbox(
592
+ workspace_path=workspace_path,
593
+ mount_path=mount_path,
594
+ team=team,
595
+ session_name=session_name,
596
+ current_branch=current_branch,
597
+ should_continue_session=should_continue_session,
598
+ fresh=fresh,
599
+ )
600
+
601
+
602
+ def interactive_start(
603
+ cfg: dict[str, Any],
604
+ *,
605
+ skip_quick_resume: bool = False,
606
+ allow_back: bool = False,
607
+ standalone_override: bool = False,
608
+ team_override: str | None = None,
609
+ ) -> tuple[str | None, str | None, str | None, str | None]:
610
+ """Guide user through interactive session setup.
611
+
612
+ Prompt for team selection, workspace source, optional worktree creation,
613
+ and session naming.
614
+
615
+ The flow prioritizes quick resume by showing recent contexts first:
616
+ 0. Global Quick Resume - if contexts exist and skip_quick_resume=False
617
+ (filtered by effective_team: --team > selected_profile)
618
+ 1. Team selection - if no context selected (skipped in standalone mode)
619
+ 2. Workspace source selection
620
+ 2.5. Workspace-scoped Quick Resume - if contexts exist for selected workspace
621
+ 3. Worktree creation (optional)
622
+ 4. Session naming (optional)
623
+
624
+ Navigation Semantics:
625
+ - 'q' anywhere: Quit wizard entirely (returns None)
626
+ - Esc at Step 0: BACK to dashboard (if allow_back) or skip to Step 1
627
+ - Esc at Step 2: Go back to Step 1 (if team exists) or BACK to dashboard
628
+ - Esc at Step 2.5: Go back to Step 2 workspace picker
629
+ - 't' anywhere: Restart at Step 1 (team selection)
630
+ - 'a' at Quick Resume: Toggle between filtered and all-teams view
631
+
632
+ Args:
633
+ cfg: Application configuration dictionary containing workspace_base
634
+ and other settings.
635
+ skip_quick_resume: If True, bypass the Quick Resume picker and go
636
+ directly to project source selection. Used when starting from
637
+ dashboard empty states (no_containers, no_sessions) where resume
638
+ doesn't make sense.
639
+ allow_back: If True, Esc at top level returns BACK sentinel instead
640
+ of None. Used when called from Dashboard to enable return to
641
+ dashboard on Esc.
642
+ standalone_override: If True, force standalone mode regardless of
643
+ config. Used when --standalone CLI flag is passed.
644
+ team_override: If provided, use this team for filtering instead of
645
+ selected_profile. Set by --team CLI flag.
646
+
647
+ Returns:
648
+ Tuple of (workspace, team, session_name, worktree_name).
649
+ - Success: (path, team, session, worktree) with path always set
650
+ - Cancel: (None, None, None, None) if user pressed q
651
+ - Back: (BACK, None, None, None) if allow_back and user pressed Esc
652
+ """
653
+ console.print(get_brand_header(), style=Colors.BRAND)
654
+
655
+ # Determine mode: standalone vs organization
656
+ # CLI --standalone flag overrides config setting
657
+ standalone_mode = standalone_override or config.is_standalone_mode()
658
+
659
+ # Calculate effective_team: --team flag takes precedence over selected_profile
660
+ # This is the team used for filtering Quick Resume contexts
661
+ selected_profile = cfg.get("selected_profile")
662
+ effective_team: str | None = team_override or selected_profile
663
+
664
+ # Build display label for UI
665
+ if standalone_mode:
666
+ active_team_label = "standalone"
667
+ elif team_override:
668
+ # Show that --team flag is active with "(filtered)" indicator
669
+ active_team_label = f"{team_override} (filtered)"
670
+ elif selected_profile:
671
+ active_team_label = selected_profile
672
+ else:
673
+ active_team_label = "none (press 't' to choose)"
674
+ active_team_context = f"Team: {active_team_label}"
675
+
676
+ # Get available teams (from org config if available)
677
+ org_config = config.load_cached_org_config()
678
+ available_teams = teams.list_teams(cfg, org_config)
679
+
680
+ # Track if user dismissed global Quick Resume (to skip workspace-scoped QR)
681
+ user_dismissed_quick_resume = False
682
+
683
+ # Step 0: Global Quick Resume
684
+ # Skip when:
685
+ # - entering from dashboard empty state (skip_quick_resume=True)
686
+ # - org mode with no active team (force team selection first)
687
+ # User can press 't' to switch teams (raises TeamSwitchRequested → skip to Step 1)
688
+ #
689
+ # In org mode without an effective team, skip Quick Resume entirely.
690
+ # This prevents showing cross-team sessions and forces user to pick a team first.
691
+ should_skip_quick_resume = skip_quick_resume
692
+ if not standalone_mode and not effective_team and available_teams:
693
+ # Org mode with no active team - skip to team picker
694
+ should_skip_quick_resume = True
695
+ console.print("[dim]💡 Select a team first to see team-specific sessions[/dim]")
696
+ console.print()
697
+
698
+ if not should_skip_quick_resume:
699
+ # Track whether showing all teams (toggled by 'a' key)
700
+ show_all_teams = False
701
+
702
+ # Quick Resume loop: allows toggling between filtered and all-teams view
703
+ while True:
704
+ # Filter by effective_team unless user toggled to show all
705
+ team_filter = "all" if show_all_teams else effective_team
706
+ recent_contexts = load_recent_contexts(limit=10, team_filter=team_filter)
707
+
708
+ # Update header based on view mode and build helpful subtitle
709
+ qr_subtitle: str | None = None
710
+ if show_all_teams:
711
+ qr_context_label = "All teams"
712
+ qr_title = "Quick Resume — All Teams"
713
+ if recent_contexts:
714
+ qr_subtitle = (
715
+ "Showing all teams — resuming uses that team's plugins. "
716
+ "Press 'a' to filter."
717
+ )
718
+ else:
719
+ qr_subtitle = "No sessions yet — start fresh"
720
+ else:
721
+ qr_context_label = active_team_context
722
+ qr_title = "Quick Resume"
723
+ if not recent_contexts:
724
+ all_contexts = load_recent_contexts(limit=10, team_filter="all")
725
+ team_label = effective_team or "standalone"
726
+ if all_contexts:
727
+ qr_subtitle = (
728
+ f"No sessions yet for {team_label}. Press 'a' to show all teams."
729
+ )
730
+ else:
731
+ qr_subtitle = "No sessions yet — start fresh"
732
+
733
+ try:
734
+ result, selected_context = pick_context_quick_resume(
735
+ recent_contexts,
736
+ title=qr_title,
737
+ subtitle=qr_subtitle,
738
+ standalone=standalone_mode,
739
+ context_label=qr_context_label,
740
+ effective_team=effective_team,
741
+ )
742
+
743
+ match result:
744
+ case QuickResumeResult.SELECTED:
745
+ # User pressed Enter on a context - resume it
746
+ if selected_context is not None:
747
+ # Cross-team resume requires confirmation
748
+ if (
749
+ effective_team
750
+ and selected_context.team
751
+ and selected_context.team != effective_team
752
+ ):
753
+ console.print()
754
+ if not Confirm.ask(
755
+ f"[yellow]Resume session from team '{selected_context.team}'?[/yellow]\n"
756
+ f"[dim]This will use {selected_context.team} plugins for this session.[/dim]",
757
+ default=False,
758
+ ):
759
+ continue # Back to QR picker loop
760
+ return (
761
+ str(selected_context.worktree_path),
762
+ selected_context.team,
763
+ selected_context.last_session_id,
764
+ None, # worktree_name - not creating new worktree
765
+ )
766
+
767
+ case QuickResumeResult.BACK:
768
+ # User pressed Esc - go back if we can (Dashboard context)
769
+ if allow_back:
770
+ return (BACK, None, None, None) # type: ignore[return-value]
771
+ # CLI context: no previous screen, treat as cancel
772
+ return (None, None, None, None)
773
+
774
+ case QuickResumeResult.NEW_SESSION:
775
+ # User pressed 'n' or selected "New Session" entry
776
+ user_dismissed_quick_resume = True
777
+ console.print()
778
+ break # Exit QR loop, continue to wizard
779
+
780
+ case QuickResumeResult.TOGGLE_ALL_TEAMS:
781
+ # User pressed 'a' - toggle all-teams view
782
+ if standalone_mode:
783
+ console.print(
784
+ "[dim]All teams view is unavailable in standalone mode[/dim]"
785
+ )
786
+ console.print()
787
+ continue
788
+ show_all_teams = not show_all_teams
789
+ continue # Re-render with new filter
790
+
791
+ case QuickResumeResult.CANCELLED:
792
+ # User pressed q - cancel entire wizard
793
+ return (None, None, None, None)
794
+
795
+ except TeamSwitchRequested:
796
+ # User pressed 't' - skip to team selection (Step 1)
797
+ # Reset Quick Resume dismissal so new team's contexts are shown
798
+ user_dismissed_quick_resume = False
799
+ show_all_teams = False
800
+ console.print()
801
+ break # Exit QR loop, continue to team selection
802
+
803
+ # ─────────────────────────────────────────────────────────────────────────
804
+ # MEGA-LOOP: Wraps Steps 1-2.5 to handle 't' key (TeamSwitchRequested)
805
+ # When user presses 't' anywhere, we restart from Step 1 (team selection)
806
+ # ─────────────────────────────────────────────────────────────────────────
807
+ while True:
808
+ # Step 1: Select team (mode-aware handling)
809
+ team: str | None = None
810
+
811
+ if standalone_mode:
812
+ # P0.1: Standalone mode - skip team picker entirely
813
+ # Solo devs don't need team selection friction
814
+ # Only print banner if detected from config (CLI --standalone already printed in start())
815
+ if not standalone_override:
816
+ console.print("[dim]Running in standalone mode (no organization config)[/dim]")
817
+ console.print()
818
+ elif not available_teams:
819
+ # P0.2: Org mode with no teams configured - exit with clear error
820
+ # Get org URL for context in error message
821
+ user_cfg = config.load_user_config()
822
+ org_source = user_cfg.get("organization_source", {})
823
+ org_url = org_source.get("url", "unknown")
824
+
825
+ console.print()
826
+ console.print(
827
+ create_warning_panel(
828
+ "No Teams Configured",
829
+ f"Organization config from: {org_url}\n"
830
+ "No team profiles are defined in this organization.",
831
+ "Contact your admin to add profiles, or use: scc start --standalone",
832
+ )
833
+ )
834
+ console.print()
835
+ raise typer.Exit(EXIT_CONFIG)
836
+ elif team_override:
837
+ # --team flag provided - use it directly, skip team picker
838
+ team = team_override
839
+ console.print(f"[dim]Using team from --team flag: {team}[/dim]")
840
+ console.print()
841
+ else:
842
+ # Normal flow: org mode with teams available
843
+ selected = pick_team(
844
+ available_teams,
845
+ current_team=str(selected_profile) if selected_profile else None,
846
+ title="Select Team",
847
+ )
848
+ if selected is None:
849
+ return (None, None, None, None)
850
+ team = selected.get("name")
851
+ if team and team != selected_profile:
852
+ config.set_selected_profile(team)
853
+ selected_profile = team
854
+ effective_team = team
855
+
856
+ # Step 2: Select workspace source (with back navigation support)
857
+ workspace: str | None = None
858
+ team_context_label = active_team_context
859
+ if team:
860
+ team_context_label = f"Team: {team}"
861
+
862
+ # Check if team has repositories configured (must be inside mega-loop since team can change)
863
+ team_config = cfg.get("profiles", {}).get(team, {}) if team else {}
864
+ team_repos: list[dict[str, Any]] = team_config.get("repositories", [])
865
+ has_team_repos = bool(team_repos)
866
+
867
+ try:
868
+ # Outer loop: allows Step 2.5 to go BACK to Step 2 (workspace picker)
869
+ while True:
870
+ # Step 2: Workspace selection loop
871
+ while workspace is None:
872
+ # Top-level picker: supports three-state contract
873
+ source = pick_workspace_source(
874
+ has_team_repos=has_team_repos,
875
+ team=team,
876
+ standalone=standalone_mode,
877
+ allow_back=allow_back or (team is not None),
878
+ context_label=team_context_label,
879
+ )
880
+
881
+ # Handle three-state return contract
882
+ if source is BACK:
883
+ if team is not None:
884
+ # Esc in org mode: go back to Step 1 (team selection)
885
+ raise TeamSwitchRequested() # Will be caught by mega-loop
886
+ elif allow_back:
887
+ # Esc in standalone mode with allow_back: return to dashboard
888
+ return (BACK, None, None, None) # type: ignore[return-value]
889
+ else:
890
+ # Esc in standalone CLI mode: cancel wizard
891
+ return (None, None, None, None)
892
+
893
+ if source is None:
894
+ # q pressed: quit entirely
895
+ return (None, None, None, None)
896
+
897
+ if source == WorkspaceSource.CURRENT_DIR:
898
+ # Detect workspace root from CWD (handles subdirs + worktrees)
899
+ detected_root, _start_cwd = git.detect_workspace_root(Path.cwd())
900
+ if detected_root:
901
+ workspace = str(detected_root)
902
+ else:
903
+ # Fall back to CWD if no workspace root detected
904
+ workspace = str(Path.cwd())
905
+
906
+ elif source == WorkspaceSource.RECENT:
907
+ recent = sessions.list_recent(10)
908
+ picker_result = pick_recent_workspace(
909
+ recent,
910
+ standalone=standalone_mode,
911
+ context_label=team_context_label,
912
+ )
913
+ if picker_result is None:
914
+ return (None, None, None, None) # User pressed q - quit wizard
915
+ if picker_result is BACK:
916
+ continue # User pressed Esc - go back to source picker
917
+ workspace = cast(str, picker_result)
918
+
919
+ elif source == WorkspaceSource.TEAM_REPOS:
920
+ workspace_base = cfg.get("workspace_base", "~/projects")
921
+ picker_result = pick_team_repo(
922
+ team_repos,
923
+ workspace_base,
924
+ standalone=standalone_mode,
925
+ context_label=team_context_label,
926
+ )
927
+ if picker_result is None:
928
+ return (None, None, None, None) # User pressed q - quit wizard
929
+ if picker_result is BACK:
930
+ continue # User pressed Esc - go back to source picker
931
+ workspace = cast(str, picker_result)
932
+
933
+ elif source == WorkspaceSource.CUSTOM:
934
+ workspace = prompt_custom_workspace(console)
935
+ # Empty input means go back
936
+ if workspace is None:
937
+ continue
938
+
939
+ elif source == WorkspaceSource.CLONE:
940
+ repo_url = prompt_repo_url(console)
941
+ if repo_url:
942
+ workspace = git.clone_repo(
943
+ repo_url, cfg.get("workspace_base", "~/projects")
944
+ )
945
+ # Empty URL means go back
946
+ if workspace is None:
947
+ continue
948
+
949
+ # ─────────────────────────────────────────────────────────────────
950
+ # Step 2.5: Workspace-scoped Quick Resume
951
+ # After selecting a workspace, check if existing contexts exist
952
+ # and offer to resume one instead of starting fresh
953
+ # ─────────────────────────────────────────────────────────────────
954
+ normalized_workspace = normalize_path(workspace)
955
+
956
+ # Smart filter: Match contexts related to this workspace AND team
957
+ workspace_contexts = []
958
+ for ctx in load_recent_contexts(limit=30):
959
+ # Standalone: only show standalone contexts
960
+ if standalone_mode and ctx.team is not None:
961
+ continue
962
+ # Org mode: filter by team (prevents cross-team resume confusion)
963
+ if team is not None and ctx.team != team:
964
+ continue
965
+
966
+ # Case 1: Exact worktree match (fastest check)
967
+ if ctx.worktree_path == normalized_workspace:
968
+ workspace_contexts.append(ctx)
969
+ continue
970
+
971
+ # Case 2: User picked repo root - show all worktree contexts for this repo
972
+ if ctx.repo_root == normalized_workspace:
973
+ workspace_contexts.append(ctx)
974
+ continue
975
+
976
+ # Case 3: User picked a subdir - match if inside a known worktree/repo
977
+ try:
978
+ if normalized_workspace.is_relative_to(ctx.worktree_path):
979
+ workspace_contexts.append(ctx)
980
+ continue
981
+ if normalized_workspace.is_relative_to(ctx.repo_root):
982
+ workspace_contexts.append(ctx)
983
+ except ValueError:
984
+ # is_relative_to raises ValueError if paths are on different drives
985
+ pass
986
+
987
+ # Skip workspace-scoped Quick Resume if user already dismissed global Quick Resume
988
+ if workspace_contexts and not user_dismissed_quick_resume:
989
+ console.print()
990
+
991
+ # Workspace QR loop for handling toggle (press 'a')
992
+ workspace_qr_show_all = False
993
+ while True:
994
+ # Filter contexts based on toggle state
995
+ displayed_contexts = workspace_contexts
996
+ if workspace_qr_show_all:
997
+ # Show all contexts for this workspace (ignore team filter)
998
+ # Use same 3-case matching logic as above
999
+ displayed_contexts = []
1000
+ for ctx in load_recent_contexts(limit=30):
1001
+ # Case 1: Exact worktree match
1002
+ if ctx.worktree_path == normalized_workspace:
1003
+ displayed_contexts.append(ctx)
1004
+ continue
1005
+ # Case 2: User picked repo root
1006
+ if ctx.repo_root == normalized_workspace:
1007
+ displayed_contexts.append(ctx)
1008
+ continue
1009
+ # Case 3: User picked a subdir
1010
+ try:
1011
+ if normalized_workspace.is_relative_to(ctx.worktree_path):
1012
+ displayed_contexts.append(ctx)
1013
+ continue
1014
+ if normalized_workspace.is_relative_to(ctx.repo_root):
1015
+ displayed_contexts.append(ctx)
1016
+ except ValueError:
1017
+ pass
1018
+
1019
+ qr_subtitle = "Existing sessions found for this workspace"
1020
+ if workspace_qr_show_all:
1021
+ qr_subtitle = (
1022
+ "All teams for this workspace — resuming uses that team's plugins"
1023
+ )
1024
+
1025
+ result, selected_context = pick_context_quick_resume(
1026
+ displayed_contexts,
1027
+ title=f"Resume session in {Path(workspace).name}?",
1028
+ subtitle=qr_subtitle,
1029
+ standalone=standalone_mode,
1030
+ context_label="All teams"
1031
+ if workspace_qr_show_all
1032
+ else f"Team: {team or active_team_label}",
1033
+ effective_team=team or effective_team,
1034
+ )
1035
+ # Note: TeamSwitchRequested bubbles up to mega-loop handler
1036
+
1037
+ match result:
1038
+ case QuickResumeResult.SELECTED:
1039
+ # User wants to resume - return context info immediately
1040
+ if selected_context is not None:
1041
+ # Cross-team resume requires confirmation
1042
+ current_team = team or effective_team
1043
+ if (
1044
+ current_team
1045
+ and selected_context.team
1046
+ and selected_context.team != current_team
1047
+ ):
1048
+ console.print()
1049
+ if not Confirm.ask(
1050
+ f"[yellow]Resume session from team '{selected_context.team}'?[/yellow]\n"
1051
+ f"[dim]This will use {selected_context.team} plugins for this session.[/dim]",
1052
+ default=False,
1053
+ ):
1054
+ continue # Back to workspace QR picker loop
1055
+ return (
1056
+ str(selected_context.worktree_path),
1057
+ selected_context.team,
1058
+ selected_context.last_session_id,
1059
+ None, # worktree_name - not creating new worktree
1060
+ )
1061
+
1062
+ case QuickResumeResult.NEW_SESSION:
1063
+ # User pressed 'n' - continue with fresh session
1064
+ break # Exit workspace QR loop
1065
+
1066
+ case QuickResumeResult.BACK:
1067
+ # User pressed Esc - go back to workspace picker (Step 2)
1068
+ workspace = None
1069
+ break # Exit workspace QR loop
1070
+
1071
+ case QuickResumeResult.TOGGLE_ALL_TEAMS:
1072
+ # User pressed 'a' - toggle all-teams view
1073
+ if standalone_mode:
1074
+ console.print(
1075
+ "[dim]All teams view is unavailable in standalone mode[/dim]"
1076
+ )
1077
+ console.print()
1078
+ continue
1079
+ workspace_qr_show_all = not workspace_qr_show_all
1080
+ continue # Re-render workspace QR
1081
+
1082
+ case QuickResumeResult.CANCELLED:
1083
+ # User pressed q - cancel entire wizard
1084
+ return (None, None, None, None)
1085
+
1086
+ # Check if we need to go back to workspace picker
1087
+ if workspace is None:
1088
+ continue # Continue outer loop to re-enter Step 2
1089
+
1090
+ # No contexts or user dismissed global Quick Resume - proceed to Step 3
1091
+ break # Exit outer loop (Step 2 + 2.5)
1092
+
1093
+ except TeamSwitchRequested:
1094
+ # User pressed 't' somewhere - restart at Step 1 (team selection)
1095
+ # Reset Quick Resume dismissal so new team's contexts are shown
1096
+ user_dismissed_quick_resume = False
1097
+ console.print()
1098
+ continue # Continue mega-loop
1099
+
1100
+ # Successfully got a workspace - exit mega-loop
1101
+ break
1102
+
1103
+ # Step 3: Worktree option
1104
+ worktree_name = None
1105
+ console.print()
1106
+ if Confirm.ask(
1107
+ "[cyan]Create a worktree for isolated feature development?[/cyan]",
1108
+ default=False,
1109
+ ):
1110
+ workspace_path = Path(workspace)
1111
+ can_create_worktree = True
1112
+
1113
+ # Check if directory is a git repository
1114
+ if not git.is_git_repo(workspace_path):
1115
+ console.print()
1116
+ if Confirm.ask(
1117
+ "[yellow]⚠️ Not a git repository. Initialize git?[/yellow]",
1118
+ default=False,
1119
+ ):
1120
+ if git.init_repo(workspace_path):
1121
+ console.print(
1122
+ f" [green]{Indicators.get('PASS')}[/green] Initialized git repository"
1123
+ )
1124
+ else:
1125
+ err_console.print(
1126
+ f" [red]{Indicators.get('FAIL')}[/red] Failed to initialize git"
1127
+ )
1128
+ can_create_worktree = False
1129
+ else:
1130
+ # User declined git init - can't create worktree
1131
+ console.print(
1132
+ f" [dim]{Indicators.get('INFO')}[/dim] "
1133
+ "Skipping worktree (requires git repository)"
1134
+ )
1135
+ can_create_worktree = False
1136
+
1137
+ # Check if repository has commits (worktree requires at least one)
1138
+ if can_create_worktree and git.is_git_repo(workspace_path):
1139
+ if not git.has_commits(workspace_path):
1140
+ console.print()
1141
+ if Confirm.ask(
1142
+ "[yellow]⚠️ Worktree requires initial commit. "
1143
+ "Create empty initial commit?[/yellow]",
1144
+ default=True,
1145
+ ):
1146
+ success, error_msg = git.create_empty_initial_commit(workspace_path)
1147
+ if success:
1148
+ console.print(
1149
+ f" [green]{Indicators.get('PASS')}[/green] Created initial commit"
1150
+ )
1151
+ else:
1152
+ err_console.print(f" [red]{Indicators.get('FAIL')}[/red] {error_msg}")
1153
+ can_create_worktree = False
1154
+ else:
1155
+ # User declined empty commit - can't create worktree
1156
+ console.print(
1157
+ f" [dim]{Indicators.get('INFO')}[/dim] "
1158
+ "Skipping worktree (requires initial commit)"
1159
+ )
1160
+ can_create_worktree = False
1161
+
1162
+ # Only ask for worktree name if we have a valid git repo with commits
1163
+ if can_create_worktree:
1164
+ worktree_name = Prompt.ask("[cyan]Feature/worktree name[/cyan]")
1165
+
1166
+ # Step 4: Session name
1167
+ session_name = (
1168
+ Prompt.ask(
1169
+ "\n[cyan]Session name[/cyan] [dim](optional, for easy resume)[/dim]",
1170
+ default="",
1171
+ )
1172
+ or None
1173
+ )
1174
+
1175
+ return workspace, team, session_name, worktree_name
1176
+
1177
+
1178
+ def run_start_wizard_flow(
1179
+ *, skip_quick_resume: bool = False, allow_back: bool = False
1180
+ ) -> bool | None:
1181
+ """Run the interactive start wizard and launch sandbox.
1182
+
1183
+ This is the shared entrypoint for starting sessions from both the CLI
1184
+ (scc start with no args) and the dashboard (Enter on empty containers).
1185
+
1186
+ The function runs outside any Rich Live context to avoid nested Live
1187
+ conflicts. It handles the complete flow:
1188
+ 1. Run interactive wizard to get user selections
1189
+ 2. If user cancels, return False/None
1190
+ 3. Otherwise, validate and launch the sandbox
1191
+
1192
+ Args:
1193
+ skip_quick_resume: If True, bypass the Quick Resume picker and go
1194
+ directly to project source selection. Used when starting from
1195
+ dashboard empty states where "resume" doesn't make sense.
1196
+ allow_back: If True, Esc returns BACK sentinel (for dashboard context).
1197
+ If False, Esc returns None (for CLI context).
1198
+
1199
+ Returns:
1200
+ True if sandbox was launched successfully.
1201
+ False if user pressed Esc to go back (only when allow_back=True).
1202
+ None if user pressed q to quit or an error occurred.
1203
+ """
1204
+ # Step 1: First-run detection
1205
+ if setup.is_setup_needed():
1206
+ if not setup.maybe_run_setup(console):
1207
+ return None # Error during setup
1208
+
1209
+ cfg = config.load_config()
1210
+
1211
+ # Step 2: Run interactive wizard
1212
+ # Note: standalone_override=False (default) is correct here - dashboard path
1213
+ # doesn't have CLI flags, so we rely on config.is_standalone_mode() inside
1214
+ # interactive_start() to detect standalone mode from user's config file.
1215
+ workspace, team, session_name, worktree_name = interactive_start(
1216
+ cfg, skip_quick_resume=skip_quick_resume, allow_back=allow_back
1217
+ )
1218
+
1219
+ # Three-state return handling:
1220
+ # - workspace is BACK → user pressed Esc (go back to dashboard)
1221
+ # - workspace is None → user pressed q (quit app)
1222
+ if workspace is BACK:
1223
+ return False # Go back to dashboard
1224
+ if workspace is None:
1225
+ return None # Quit app
1226
+
1227
+ try:
1228
+ with Status("[cyan]Checking Docker...[/cyan]", console=console, spinner=Spinners.DOCKER):
1229
+ docker.check_docker_available()
1230
+ workspace_path = validate_and_resolve_workspace(workspace)
1231
+ workspace_path = prepare_workspace(workspace_path, worktree_name, install_deps=False)
1232
+ _configure_team_settings(team, cfg)
1233
+ _sync_marketplace_settings(workspace_path, team)
1234
+ mount_path, current_branch = resolve_mount_and_branch(workspace_path)
1235
+ launch_sandbox(
1236
+ workspace_path=workspace_path,
1237
+ mount_path=mount_path,
1238
+ team=team,
1239
+ session_name=session_name,
1240
+ current_branch=current_branch,
1241
+ should_continue_session=False,
1242
+ fresh=False,
1243
+ )
1244
+ return True
1245
+ except Exception as e:
1246
+ err_console.print(f"[red]Error launching sandbox: {e}[/red]")
1247
+ return False