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.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- 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
|