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
scc_cli/ui/wizard.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Wizard-specific pickers with three-state navigation support.
|
|
2
|
+
|
|
3
|
+
This module provides picker functions for the interactive start wizard,
|
|
4
|
+
with proper navigation support for nested screens. All pickers follow
|
|
5
|
+
a three-state return contract:
|
|
6
|
+
|
|
7
|
+
- Success: Returns the selected value (WorkspaceSource, str path, etc.)
|
|
8
|
+
- Back: Returns BACK sentinel (Esc pressed - go to previous screen)
|
|
9
|
+
- Quit: Returns None (q pressed - exit app entirely)
|
|
10
|
+
|
|
11
|
+
The BACK sentinel provides type-safe back navigation that callers can
|
|
12
|
+
check with identity comparison: `if result is BACK`.
|
|
13
|
+
|
|
14
|
+
Top-level vs Sub-screen behavior:
|
|
15
|
+
- Top-level (pick_workspace_source with allow_back=False): Esc returns None
|
|
16
|
+
- Sub-screens (pick_recent_workspace, pick_team_repo): Esc returns BACK, q returns None
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
>>> from scc_cli.ui.wizard import (
|
|
20
|
+
... BACK, WorkspaceSource,
|
|
21
|
+
... pick_workspace_source, pick_recent_workspace
|
|
22
|
+
... )
|
|
23
|
+
>>>
|
|
24
|
+
>>> while True:
|
|
25
|
+
... source = pick_workspace_source(team="platform")
|
|
26
|
+
... if source is None:
|
|
27
|
+
... break # User pressed q or Esc at top level - quit
|
|
28
|
+
...
|
|
29
|
+
... if source == WorkspaceSource.RECENT:
|
|
30
|
+
... workspace = pick_recent_workspace(recent_sessions)
|
|
31
|
+
... if workspace is None:
|
|
32
|
+
... break # User pressed q - quit app
|
|
33
|
+
... if workspace is BACK:
|
|
34
|
+
... continue # User pressed Esc - go back to source picker
|
|
35
|
+
... return workspace # Got a valid path
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from enum import Enum
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
44
|
+
|
|
45
|
+
from ..services.workspace import is_suspicious_directory
|
|
46
|
+
from .keys import BACK, _BackSentinel
|
|
47
|
+
from .list_screen import ListItem
|
|
48
|
+
from .picker import _run_single_select_picker
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# Type variable for generic picker return types
|
|
54
|
+
T = TypeVar("T")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
# Workspace Source Enum
|
|
59
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WorkspaceSource(Enum):
|
|
63
|
+
"""Options for where to get the workspace from."""
|
|
64
|
+
|
|
65
|
+
CURRENT_DIR = "current_dir" # Use current working directory
|
|
66
|
+
RECENT = "recent"
|
|
67
|
+
TEAM_REPOS = "team_repos"
|
|
68
|
+
CUSTOM = "custom"
|
|
69
|
+
CLONE = "clone"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
# Local Helpers
|
|
74
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalize_path(path: str) -> str:
|
|
78
|
+
"""Collapse HOME to ~ and truncate keeping last 2 segments.
|
|
79
|
+
|
|
80
|
+
Uses Path.parts for cross-platform robustness.
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
/Users/dev/projects/api → ~/projects/api
|
|
84
|
+
/Users/dev/very/long/path/to/project → ~/…/to/project
|
|
85
|
+
/opt/data/files → /opt/data/files (no home prefix)
|
|
86
|
+
"""
|
|
87
|
+
p = Path(path)
|
|
88
|
+
home = Path.home()
|
|
89
|
+
|
|
90
|
+
# Try to make path relative to home
|
|
91
|
+
try:
|
|
92
|
+
relative = p.relative_to(home)
|
|
93
|
+
display = "~/" + str(relative)
|
|
94
|
+
starts_with_home = True
|
|
95
|
+
except ValueError:
|
|
96
|
+
display = str(p)
|
|
97
|
+
starts_with_home = False
|
|
98
|
+
|
|
99
|
+
# Truncate if too long, keeping last 2 segments for context
|
|
100
|
+
if len(display) > 50:
|
|
101
|
+
parts = p.parts
|
|
102
|
+
if len(parts) >= 2:
|
|
103
|
+
tail = "/".join(parts[-2:])
|
|
104
|
+
elif parts:
|
|
105
|
+
tail = parts[-1]
|
|
106
|
+
else:
|
|
107
|
+
tail = ""
|
|
108
|
+
|
|
109
|
+
prefix = "~" if starts_with_home else ""
|
|
110
|
+
display = f"{prefix}/…/{tail}"
|
|
111
|
+
|
|
112
|
+
return display
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _format_relative_time(iso_timestamp: str) -> str:
|
|
116
|
+
"""Format an ISO timestamp as relative time.
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
2 minutes ago → "2m ago"
|
|
120
|
+
3 hours ago → "3h ago"
|
|
121
|
+
yesterday → "yesterday"
|
|
122
|
+
5 days ago → "5d ago"
|
|
123
|
+
older → "Dec 20" (month day format)
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
# Handle Z suffix for UTC
|
|
127
|
+
if iso_timestamp.endswith("Z"):
|
|
128
|
+
iso_timestamp = iso_timestamp[:-1] + "+00:00"
|
|
129
|
+
|
|
130
|
+
timestamp = datetime.fromisoformat(iso_timestamp)
|
|
131
|
+
|
|
132
|
+
# Ensure timezone-aware comparison
|
|
133
|
+
now = datetime.now(timezone.utc)
|
|
134
|
+
if timestamp.tzinfo is None:
|
|
135
|
+
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
|
136
|
+
|
|
137
|
+
delta = now - timestamp
|
|
138
|
+
seconds = delta.total_seconds()
|
|
139
|
+
|
|
140
|
+
if seconds < 60:
|
|
141
|
+
return "just now"
|
|
142
|
+
elif seconds < 3600:
|
|
143
|
+
minutes = int(seconds / 60)
|
|
144
|
+
return f"{minutes}m ago"
|
|
145
|
+
elif seconds < 86400:
|
|
146
|
+
hours = int(seconds / 3600)
|
|
147
|
+
return f"{hours}h ago"
|
|
148
|
+
elif seconds < 172800: # 2 days
|
|
149
|
+
return "yesterday"
|
|
150
|
+
elif seconds < 604800: # 7 days
|
|
151
|
+
days = int(seconds / 86400)
|
|
152
|
+
return f"{days}d ago"
|
|
153
|
+
else:
|
|
154
|
+
# Older than a week - show month day
|
|
155
|
+
return timestamp.strftime("%b %d")
|
|
156
|
+
|
|
157
|
+
except (ValueError, AttributeError):
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
162
|
+
# Sub-screen Picker Wrapper
|
|
163
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run_subscreen_picker(
|
|
167
|
+
items: list[ListItem[T]],
|
|
168
|
+
title: str,
|
|
169
|
+
subtitle: str | None = None,
|
|
170
|
+
*,
|
|
171
|
+
standalone: bool = False,
|
|
172
|
+
context_label: str | None = None,
|
|
173
|
+
) -> T | _BackSentinel | None:
|
|
174
|
+
"""Run picker for sub-screens with three-state return contract.
|
|
175
|
+
|
|
176
|
+
Sub-screen pickers distinguish between:
|
|
177
|
+
- Esc (go back to previous screen) → BACK sentinel
|
|
178
|
+
- q (quit app entirely) → None
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
items: List items to display (first item should be "← Back").
|
|
182
|
+
title: Title for chrome header.
|
|
183
|
+
subtitle: Optional subtitle.
|
|
184
|
+
standalone: If True, dim the "t teams" hint (not available without org).
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Selected item value, BACK if Esc pressed, or None if q pressed (quit).
|
|
188
|
+
"""
|
|
189
|
+
# Pass allow_back=True so picker distinguishes Esc (BACK) from q (None)
|
|
190
|
+
result = _run_single_select_picker(
|
|
191
|
+
items,
|
|
192
|
+
title=title,
|
|
193
|
+
subtitle=subtitle,
|
|
194
|
+
standalone=standalone,
|
|
195
|
+
allow_back=True,
|
|
196
|
+
context_label=context_label,
|
|
197
|
+
)
|
|
198
|
+
# Three-state contract:
|
|
199
|
+
# - T value: user selected an item
|
|
200
|
+
# - BACK: user pressed Esc (go back)
|
|
201
|
+
# - None: user pressed q (quit app)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
206
|
+
# Top-Level Picker: Workspace Source
|
|
207
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Common project markers across languages/frameworks
|
|
211
|
+
# Split into direct checks (fast) and glob patterns (slower, checked only if needed)
|
|
212
|
+
_PROJECT_MARKERS_DIRECT = (
|
|
213
|
+
".git", # Git repository (directory or file for worktrees)
|
|
214
|
+
".scc.yaml", # SCC config
|
|
215
|
+
".gitignore", # Often at project root
|
|
216
|
+
"package.json", # Node.js / JavaScript
|
|
217
|
+
"tsconfig.json", # TypeScript
|
|
218
|
+
"pyproject.toml", # Python (modern)
|
|
219
|
+
"setup.py", # Python (legacy)
|
|
220
|
+
"requirements.txt", # Python dependencies
|
|
221
|
+
"Pipfile", # Pipenv
|
|
222
|
+
"Cargo.toml", # Rust
|
|
223
|
+
"go.mod", # Go
|
|
224
|
+
"pom.xml", # Java Maven
|
|
225
|
+
"build.gradle", # Java/Kotlin Gradle
|
|
226
|
+
"gradlew", # Gradle wrapper (strong signal)
|
|
227
|
+
"Gemfile", # Ruby
|
|
228
|
+
"composer.json", # PHP
|
|
229
|
+
"mix.exs", # Elixir
|
|
230
|
+
"Makefile", # Make-based projects
|
|
231
|
+
"CMakeLists.txt", # CMake C/C++
|
|
232
|
+
".project", # Eclipse
|
|
233
|
+
"Dockerfile", # Docker projects
|
|
234
|
+
"docker-compose.yml", # Docker Compose
|
|
235
|
+
"compose.yaml", # Docker Compose (new name)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Glob patterns for project markers (checked only if direct checks fail)
|
|
239
|
+
_PROJECT_MARKERS_GLOB = (
|
|
240
|
+
"*.sln", # .NET solution
|
|
241
|
+
"*.csproj", # .NET C# project
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _has_project_markers(path: Path) -> bool:
|
|
246
|
+
"""Check if a directory has common project markers.
|
|
247
|
+
|
|
248
|
+
Uses a two-phase approach for performance:
|
|
249
|
+
1. Fast direct existence checks for common markers
|
|
250
|
+
2. Slower glob patterns only if direct checks fail
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
path: Directory to check.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if directory has any recognizable project markers.
|
|
257
|
+
"""
|
|
258
|
+
if not path.is_dir():
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
# Phase 1: Fast direct checks
|
|
262
|
+
for marker in _PROJECT_MARKERS_DIRECT:
|
|
263
|
+
if (path / marker).exists():
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
# Phase 2: Slower glob checks (only if no direct markers found)
|
|
267
|
+
for pattern in _PROJECT_MARKERS_GLOB:
|
|
268
|
+
try:
|
|
269
|
+
if next(path.glob(pattern), None) is not None:
|
|
270
|
+
return True
|
|
271
|
+
except (OSError, StopIteration):
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _is_valid_workspace(path: Path) -> bool:
|
|
278
|
+
"""Check if a directory looks like a valid workspace.
|
|
279
|
+
|
|
280
|
+
A valid workspace must have at least one of:
|
|
281
|
+
- .git directory or file (for worktrees)
|
|
282
|
+
- .scc.yaml config file
|
|
283
|
+
- Common project markers (package.json, pyproject.toml, etc.)
|
|
284
|
+
|
|
285
|
+
Random directories (like $HOME) are NOT valid workspaces.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
path: Directory to check.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
True if directory exists and has workspace markers.
|
|
292
|
+
"""
|
|
293
|
+
return _has_project_markers(path)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def pick_workspace_source(
|
|
297
|
+
has_team_repos: bool = False,
|
|
298
|
+
team: str | None = None,
|
|
299
|
+
*,
|
|
300
|
+
standalone: bool = False,
|
|
301
|
+
allow_back: bool = False,
|
|
302
|
+
context_label: str | None = None,
|
|
303
|
+
) -> WorkspaceSource | _BackSentinel | None:
|
|
304
|
+
"""Show picker for workspace source selection.
|
|
305
|
+
|
|
306
|
+
Three-state return contract:
|
|
307
|
+
- Success: Returns WorkspaceSource (user selected an option)
|
|
308
|
+
- Back: Returns BACK sentinel (user pressed Esc, only if allow_back=True)
|
|
309
|
+
- Quit: Returns None (user pressed q)
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
has_team_repos: Whether team repositories are available.
|
|
313
|
+
team: Current team name (used for context label if not provided).
|
|
314
|
+
standalone: If True, dim the "t teams" hint (not available without org).
|
|
315
|
+
allow_back: If True, Esc returns BACK (for sub-screen context like Dashboard).
|
|
316
|
+
If False, Esc returns None (for top-level CLI context).
|
|
317
|
+
context_label: Optional context label (e.g., "Team: platform") shown in header.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Selected WorkspaceSource, BACK if allow_back and Esc pressed, or None if quit.
|
|
321
|
+
"""
|
|
322
|
+
# Build subtitle based on context
|
|
323
|
+
subtitle = "Pick a project source (press 't' to switch team)"
|
|
324
|
+
resolved_context_label = context_label
|
|
325
|
+
if resolved_context_label is None and team:
|
|
326
|
+
resolved_context_label = f"Team: {team}"
|
|
327
|
+
|
|
328
|
+
# Build items list - start with CWD option if appropriate
|
|
329
|
+
items: list[ListItem[WorkspaceSource]] = []
|
|
330
|
+
|
|
331
|
+
# Check current directory for project markers and git status
|
|
332
|
+
# Import here to avoid circular dependencies
|
|
333
|
+
from scc_cli import git
|
|
334
|
+
|
|
335
|
+
cwd = Path.cwd()
|
|
336
|
+
cwd_name = cwd.name or str(cwd)
|
|
337
|
+
is_git = git.is_git_repo(cwd)
|
|
338
|
+
|
|
339
|
+
# Three-tier logic with git awareness:
|
|
340
|
+
# 1. Suspicious directory (home, /, tmp) → don't show
|
|
341
|
+
# 2. Has project markers + git → show folder name (confident)
|
|
342
|
+
# 3. Has project markers, no git → show "folder (no git)"
|
|
343
|
+
# 4. No markers, not suspicious → show "folder (no git)"
|
|
344
|
+
if not is_suspicious_directory(cwd):
|
|
345
|
+
if _has_project_markers(cwd):
|
|
346
|
+
if is_git:
|
|
347
|
+
# Valid project with git - show with confidence
|
|
348
|
+
items.append(
|
|
349
|
+
ListItem(
|
|
350
|
+
label="📍 Current directory",
|
|
351
|
+
description=cwd_name,
|
|
352
|
+
value=WorkspaceSource.CURRENT_DIR,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
# Has project markers but no git
|
|
357
|
+
items.append(
|
|
358
|
+
ListItem(
|
|
359
|
+
label="📍 Current directory",
|
|
360
|
+
description=f"{cwd_name} (no git)",
|
|
361
|
+
value=WorkspaceSource.CURRENT_DIR,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
# Not a project but still allow - show with hint about git
|
|
366
|
+
items.append(
|
|
367
|
+
ListItem(
|
|
368
|
+
label="📍 Current directory",
|
|
369
|
+
description=f"{cwd_name} (no git)",
|
|
370
|
+
value=WorkspaceSource.CURRENT_DIR,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Add standard options
|
|
375
|
+
items.append(
|
|
376
|
+
ListItem(
|
|
377
|
+
label="📂 Recent workspaces",
|
|
378
|
+
description="Continue working on previous project",
|
|
379
|
+
value=WorkspaceSource.RECENT,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if has_team_repos:
|
|
384
|
+
items.append(
|
|
385
|
+
ListItem(
|
|
386
|
+
label="🏢 Team repositories",
|
|
387
|
+
description="Choose from team's common repos",
|
|
388
|
+
value=WorkspaceSource.TEAM_REPOS,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
items.extend(
|
|
393
|
+
[
|
|
394
|
+
ListItem(
|
|
395
|
+
label="📁 Enter path",
|
|
396
|
+
description="Specify a local directory path",
|
|
397
|
+
value=WorkspaceSource.CUSTOM,
|
|
398
|
+
),
|
|
399
|
+
ListItem(
|
|
400
|
+
label="🔗 Clone repository",
|
|
401
|
+
description="Clone a Git repository",
|
|
402
|
+
value=WorkspaceSource.CLONE,
|
|
403
|
+
),
|
|
404
|
+
]
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
return _run_single_select_picker(
|
|
408
|
+
items=items,
|
|
409
|
+
title="Where is your project?",
|
|
410
|
+
subtitle=subtitle,
|
|
411
|
+
standalone=standalone,
|
|
412
|
+
allow_back=allow_back,
|
|
413
|
+
context_label=resolved_context_label,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
418
|
+
# Sub-Screen Picker: Recent Workspaces
|
|
419
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def pick_recent_workspace(
|
|
423
|
+
recent: list[dict[str, Any]],
|
|
424
|
+
*,
|
|
425
|
+
standalone: bool = False,
|
|
426
|
+
context_label: str | None = None,
|
|
427
|
+
) -> str | _BackSentinel | None:
|
|
428
|
+
"""Show picker for recent workspace selection.
|
|
429
|
+
|
|
430
|
+
This is a sub-screen picker with three-state return contract:
|
|
431
|
+
- str: User selected a workspace path
|
|
432
|
+
- BACK: User pressed Esc (go back to previous screen)
|
|
433
|
+
- None: User pressed q (quit app entirely)
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
recent: List of recent session dicts with 'workspace' and 'last_used' keys.
|
|
437
|
+
standalone: If True, dim the "t teams" hint (not available without org).
|
|
438
|
+
context_label: Optional context label (e.g., "Team: platform") shown in header.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Selected workspace path, BACK if Esc pressed, or None if q pressed (quit).
|
|
442
|
+
"""
|
|
443
|
+
# Build items with "← Back" first
|
|
444
|
+
items: list[ListItem[str | _BackSentinel]] = [
|
|
445
|
+
ListItem(
|
|
446
|
+
label="← Back",
|
|
447
|
+
description="",
|
|
448
|
+
value=BACK,
|
|
449
|
+
),
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
# Add recent workspaces
|
|
453
|
+
for session in recent:
|
|
454
|
+
workspace = session.get("workspace", "")
|
|
455
|
+
last_used = session.get("last_used", "")
|
|
456
|
+
|
|
457
|
+
items.append(
|
|
458
|
+
ListItem(
|
|
459
|
+
label=_normalize_path(workspace),
|
|
460
|
+
description=_format_relative_time(last_used),
|
|
461
|
+
value=workspace, # Full path as value
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# Empty state hint in subtitle
|
|
466
|
+
if len(items) == 1: # Only "← Back"
|
|
467
|
+
subtitle = "No recent workspaces found"
|
|
468
|
+
else:
|
|
469
|
+
subtitle = None
|
|
470
|
+
|
|
471
|
+
return _run_subscreen_picker(
|
|
472
|
+
items=items,
|
|
473
|
+
title="Recent Workspaces",
|
|
474
|
+
subtitle=subtitle,
|
|
475
|
+
standalone=standalone,
|
|
476
|
+
context_label=context_label,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
481
|
+
# Sub-Screen Picker: Team Repositories (Phase 3)
|
|
482
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def pick_team_repo(
|
|
486
|
+
repos: list[dict[str, Any]],
|
|
487
|
+
workspace_base: str = "~/projects",
|
|
488
|
+
*,
|
|
489
|
+
standalone: bool = False,
|
|
490
|
+
context_label: str | None = None,
|
|
491
|
+
) -> str | _BackSentinel | None:
|
|
492
|
+
"""Show picker for team repository selection.
|
|
493
|
+
|
|
494
|
+
This is a sub-screen picker with three-state return contract:
|
|
495
|
+
- str: User selected a repo (returns existing local_path or newly cloned path)
|
|
496
|
+
- BACK: User pressed Esc (go back to previous screen)
|
|
497
|
+
- None: User pressed q (quit app entirely)
|
|
498
|
+
|
|
499
|
+
If the selected repo has a local_path that exists, returns that path.
|
|
500
|
+
Otherwise, clones the repository and returns the new path.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
repos: List of repo dicts with 'name', 'url', optional 'description', 'local_path'.
|
|
504
|
+
workspace_base: Base directory for cloning new repos.
|
|
505
|
+
standalone: If True, dim the "t teams" hint (not available without org).
|
|
506
|
+
context_label: Optional context label (e.g., "Team: platform") shown in header.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Workspace path (existing or newly cloned), BACK if Esc pressed, or None if q pressed.
|
|
510
|
+
"""
|
|
511
|
+
# Build items with "← Back" first
|
|
512
|
+
items: list[ListItem[dict[str, Any] | _BackSentinel]] = [
|
|
513
|
+
ListItem(
|
|
514
|
+
label="← Back",
|
|
515
|
+
description="",
|
|
516
|
+
value=BACK,
|
|
517
|
+
),
|
|
518
|
+
]
|
|
519
|
+
|
|
520
|
+
# Add team repos
|
|
521
|
+
for repo in repos:
|
|
522
|
+
name = repo.get("name", repo.get("url", "Unknown"))
|
|
523
|
+
description = repo.get("description", "")
|
|
524
|
+
|
|
525
|
+
items.append(
|
|
526
|
+
ListItem(
|
|
527
|
+
label=name,
|
|
528
|
+
description=description,
|
|
529
|
+
value=repo, # Full repo dict as value
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Empty state hint
|
|
534
|
+
if len(items) == 1: # Only "← Back"
|
|
535
|
+
subtitle = "No team repositories configured"
|
|
536
|
+
else:
|
|
537
|
+
subtitle = None
|
|
538
|
+
|
|
539
|
+
result = _run_subscreen_picker(
|
|
540
|
+
items=items,
|
|
541
|
+
title="Team Repositories",
|
|
542
|
+
subtitle=subtitle,
|
|
543
|
+
standalone=standalone,
|
|
544
|
+
context_label=context_label,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Handle quit (q pressed)
|
|
548
|
+
if result is None:
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
# Handle BACK (Esc pressed)
|
|
552
|
+
if result is BACK:
|
|
553
|
+
return BACK
|
|
554
|
+
|
|
555
|
+
# Handle repo selection - check for existing local path or clone
|
|
556
|
+
if isinstance(result, dict):
|
|
557
|
+
local_path = result.get("local_path")
|
|
558
|
+
if local_path:
|
|
559
|
+
expanded = Path(local_path).expanduser()
|
|
560
|
+
if expanded.exists():
|
|
561
|
+
return str(expanded)
|
|
562
|
+
|
|
563
|
+
# Need to clone - import git module here to avoid circular imports
|
|
564
|
+
from .. import git
|
|
565
|
+
|
|
566
|
+
repo_url = result.get("url", "")
|
|
567
|
+
if repo_url:
|
|
568
|
+
cloned_path = git.clone_repo(repo_url, workspace_base)
|
|
569
|
+
if cloned_path:
|
|
570
|
+
return cloned_path
|
|
571
|
+
|
|
572
|
+
# Cloning failed or no URL - return BACK to let user try again
|
|
573
|
+
return BACK
|
|
574
|
+
|
|
575
|
+
# Shouldn't happen, but handle gracefully
|
|
576
|
+
return BACK
|