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