scc-cli 1.5.3__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
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