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.

Files changed (113) 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 +259 -0
  8. scc_cli/cli_admin.py +706 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1454 -0
  16. scc_cli/cli_org.py +1428 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +892 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +604 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1074 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1521 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/adapter.py +74 -0
  46. scc_cli/marketplace/compute.py +377 -0
  47. scc_cli/marketplace/constants.py +87 -0
  48. scc_cli/marketplace/managed.py +135 -0
  49. scc_cli/marketplace/materialize.py +723 -0
  50. scc_cli/marketplace/normalize.py +548 -0
  51. scc_cli/marketplace/render.py +257 -0
  52. scc_cli/marketplace/resolve.py +459 -0
  53. scc_cli/marketplace/schema.py +506 -0
  54. scc_cli/marketplace/sync.py +260 -0
  55. scc_cli/marketplace/team_cache.py +195 -0
  56. scc_cli/marketplace/team_fetch.py +688 -0
  57. scc_cli/marketplace/trust.py +244 -0
  58. scc_cli/models/__init__.py +41 -0
  59. scc_cli/models/exceptions.py +273 -0
  60. scc_cli/models/plugin_audit.py +434 -0
  61. scc_cli/org_templates.py +269 -0
  62. scc_cli/output_mode.py +167 -0
  63. scc_cli/panels.py +113 -0
  64. scc_cli/platform.py +350 -0
  65. scc_cli/profiles.py +960 -0
  66. scc_cli/remote.py +443 -0
  67. scc_cli/schemas/__init__.py +1 -0
  68. scc_cli/schemas/org-v1.schema.json +456 -0
  69. scc_cli/schemas/team-config.v1.schema.json +163 -0
  70. scc_cli/sessions.py +425 -0
  71. scc_cli/setup.py +588 -0
  72. scc_cli/source_resolver.py +470 -0
  73. scc_cli/stats.py +378 -0
  74. scc_cli/stores/__init__.py +13 -0
  75. scc_cli/stores/exception_store.py +251 -0
  76. scc_cli/subprocess_utils.py +88 -0
  77. scc_cli/teams.py +382 -0
  78. scc_cli/templates/__init__.py +2 -0
  79. scc_cli/templates/org/__init__.py +0 -0
  80. scc_cli/templates/org/minimal.json +19 -0
  81. scc_cli/templates/org/reference.json +74 -0
  82. scc_cli/templates/org/strict.json +38 -0
  83. scc_cli/templates/org/teams.json +42 -0
  84. scc_cli/templates/statusline.sh +75 -0
  85. scc_cli/theme.py +348 -0
  86. scc_cli/ui/__init__.py +124 -0
  87. scc_cli/ui/branding.py +68 -0
  88. scc_cli/ui/chrome.py +395 -0
  89. scc_cli/ui/dashboard/__init__.py +62 -0
  90. scc_cli/ui/dashboard/_dashboard.py +677 -0
  91. scc_cli/ui/dashboard/loaders.py +395 -0
  92. scc_cli/ui/dashboard/models.py +184 -0
  93. scc_cli/ui/dashboard/orchestrator.py +390 -0
  94. scc_cli/ui/formatters.py +443 -0
  95. scc_cli/ui/gate.py +350 -0
  96. scc_cli/ui/help.py +157 -0
  97. scc_cli/ui/keys.py +538 -0
  98. scc_cli/ui/list_screen.py +431 -0
  99. scc_cli/ui/picker.py +700 -0
  100. scc_cli/ui/prompts.py +200 -0
  101. scc_cli/ui/wizard.py +675 -0
  102. scc_cli/update.py +680 -0
  103. scc_cli/utils/__init__.py +39 -0
  104. scc_cli/utils/fixit.py +264 -0
  105. scc_cli/utils/fuzzy.py +124 -0
  106. scc_cli/utils/locks.py +101 -0
  107. scc_cli/utils/ttl.py +376 -0
  108. scc_cli/validate.py +455 -0
  109. scc_cli-1.4.1.dist-info/METADATA +369 -0
  110. scc_cli-1.4.1.dist-info/RECORD +113 -0
  111. scc_cli-1.4.1.dist-info/WHEEL +4 -0
  112. scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
  113. scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
scc_cli/ui/wizard.py ADDED
@@ -0,0 +1,675 @@
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 .keys import BACK, _BackSentinel
46
+ from .list_screen import ListItem
47
+ from .picker import _run_single_select_picker
48
+
49
+ if TYPE_CHECKING:
50
+ pass
51
+
52
+ # Type variable for generic picker return types
53
+ T = TypeVar("T")
54
+
55
+
56
+ # ═══════════════════════════════════════════════════════════════════════════════
57
+ # Workspace Source Enum
58
+ # ═══════════════════════════════════════════════════════════════════════════════
59
+
60
+
61
+ class WorkspaceSource(Enum):
62
+ """Options for where to get the workspace from."""
63
+
64
+ CURRENT_DIR = "current_dir" # Use current working directory
65
+ RECENT = "recent"
66
+ TEAM_REPOS = "team_repos"
67
+ CUSTOM = "custom"
68
+ CLONE = "clone"
69
+
70
+
71
+ # ═══════════════════════════════════════════════════════════════════════════════
72
+ # Local Helpers
73
+ # ═══════════════════════════════════════════════════════════════════════════════
74
+
75
+
76
+ def _normalize_path(path: str) -> str:
77
+ """Collapse HOME to ~ and truncate keeping last 2 segments.
78
+
79
+ Uses Path.parts for cross-platform robustness.
80
+
81
+ Examples:
82
+ /Users/dev/projects/api → ~/projects/api
83
+ /Users/dev/very/long/path/to/project → ~/…/to/project
84
+ /opt/data/files → /opt/data/files (no home prefix)
85
+ """
86
+ p = Path(path)
87
+ home = Path.home()
88
+
89
+ # Try to make path relative to home
90
+ try:
91
+ relative = p.relative_to(home)
92
+ display = "~/" + str(relative)
93
+ starts_with_home = True
94
+ except ValueError:
95
+ display = str(p)
96
+ starts_with_home = False
97
+
98
+ # Truncate if too long, keeping last 2 segments for context
99
+ if len(display) > 50:
100
+ parts = p.parts
101
+ if len(parts) >= 2:
102
+ tail = "/".join(parts[-2:])
103
+ elif parts:
104
+ tail = parts[-1]
105
+ else:
106
+ tail = ""
107
+
108
+ prefix = "~" if starts_with_home else ""
109
+ display = f"{prefix}/…/{tail}"
110
+
111
+ return display
112
+
113
+
114
+ def _format_relative_time(iso_timestamp: str) -> str:
115
+ """Format an ISO timestamp as relative time.
116
+
117
+ Examples:
118
+ 2 minutes ago → "2m ago"
119
+ 3 hours ago → "3h ago"
120
+ yesterday → "yesterday"
121
+ 5 days ago → "5d ago"
122
+ older → "Dec 20" (month day format)
123
+ """
124
+ try:
125
+ # Handle Z suffix for UTC
126
+ if iso_timestamp.endswith("Z"):
127
+ iso_timestamp = iso_timestamp[:-1] + "+00:00"
128
+
129
+ timestamp = datetime.fromisoformat(iso_timestamp)
130
+
131
+ # Ensure timezone-aware comparison
132
+ now = datetime.now(timezone.utc)
133
+ if timestamp.tzinfo is None:
134
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
135
+
136
+ delta = now - timestamp
137
+ seconds = delta.total_seconds()
138
+
139
+ if seconds < 60:
140
+ return "just now"
141
+ elif seconds < 3600:
142
+ minutes = int(seconds / 60)
143
+ return f"{minutes}m ago"
144
+ elif seconds < 86400:
145
+ hours = int(seconds / 3600)
146
+ return f"{hours}h ago"
147
+ elif seconds < 172800: # 2 days
148
+ return "yesterday"
149
+ elif seconds < 604800: # 7 days
150
+ days = int(seconds / 86400)
151
+ return f"{days}d ago"
152
+ else:
153
+ # Older than a week - show month day
154
+ return timestamp.strftime("%b %d")
155
+
156
+ except (ValueError, AttributeError):
157
+ return ""
158
+
159
+
160
+ # ═══════════════════════════════════════════════════════════════════════════════
161
+ # Sub-screen Picker Wrapper
162
+ # ═══════════════════════════════════════════════════════════════════════════════
163
+
164
+
165
+ def _run_subscreen_picker(
166
+ items: list[ListItem[T]],
167
+ title: str,
168
+ subtitle: str | None = None,
169
+ *,
170
+ standalone: bool = False,
171
+ context_label: str | None = None,
172
+ ) -> T | _BackSentinel | None:
173
+ """Run picker for sub-screens with three-state return contract.
174
+
175
+ Sub-screen pickers distinguish between:
176
+ - Esc (go back to previous screen) → BACK sentinel
177
+ - q (quit app entirely) → None
178
+
179
+ Args:
180
+ items: List items to display (first item should be "← Back").
181
+ title: Title for chrome header.
182
+ subtitle: Optional subtitle.
183
+ standalone: If True, dim the "t teams" hint (not available without org).
184
+
185
+ Returns:
186
+ Selected item value, BACK if Esc pressed, or None if q pressed (quit).
187
+ """
188
+ # Pass allow_back=True so picker distinguishes Esc (BACK) from q (None)
189
+ result = _run_single_select_picker(
190
+ items,
191
+ title=title,
192
+ subtitle=subtitle,
193
+ standalone=standalone,
194
+ allow_back=True,
195
+ context_label=context_label,
196
+ )
197
+ # Three-state contract:
198
+ # - T value: user selected an item
199
+ # - BACK: user pressed Esc (go back)
200
+ # - None: user pressed q (quit app)
201
+ return result
202
+
203
+
204
+ # ═══════════════════════════════════════════════════════════════════════════════
205
+ # Top-Level Picker: Workspace Source
206
+ # ═══════════════════════════════════════════════════════════════════════════════
207
+
208
+
209
+ # Common project markers across languages/frameworks
210
+ # Split into direct checks (fast) and glob patterns (slower, checked only if needed)
211
+ _PROJECT_MARKERS_DIRECT = (
212
+ ".git", # Git repository (directory or file for worktrees)
213
+ ".scc.yaml", # SCC config
214
+ ".gitignore", # Often at project root
215
+ "package.json", # Node.js / JavaScript
216
+ "tsconfig.json", # TypeScript
217
+ "pyproject.toml", # Python (modern)
218
+ "setup.py", # Python (legacy)
219
+ "requirements.txt", # Python dependencies
220
+ "Pipfile", # Pipenv
221
+ "Cargo.toml", # Rust
222
+ "go.mod", # Go
223
+ "pom.xml", # Java Maven
224
+ "build.gradle", # Java/Kotlin Gradle
225
+ "gradlew", # Gradle wrapper (strong signal)
226
+ "Gemfile", # Ruby
227
+ "composer.json", # PHP
228
+ "mix.exs", # Elixir
229
+ "Makefile", # Make-based projects
230
+ "CMakeLists.txt", # CMake C/C++
231
+ ".project", # Eclipse
232
+ "Dockerfile", # Docker projects
233
+ "docker-compose.yml", # Docker Compose
234
+ "compose.yaml", # Docker Compose (new name)
235
+ )
236
+
237
+ # Glob patterns for project markers (checked only if direct checks fail)
238
+ _PROJECT_MARKERS_GLOB = (
239
+ "*.sln", # .NET solution
240
+ "*.csproj", # .NET C# project
241
+ )
242
+
243
+ # Unix directories that should NOT be used as workspace
244
+ _SUSPICIOUS_DIRS_UNIX = {
245
+ "/",
246
+ "/tmp",
247
+ "/var",
248
+ "/usr",
249
+ "/etc",
250
+ "/opt",
251
+ "/proc",
252
+ "/dev",
253
+ "/sys",
254
+ "/run",
255
+ "/Applications", # macOS
256
+ "/Library", # macOS
257
+ "/System", # macOS
258
+ "/Volumes", # macOS mount points
259
+ "/mnt", # Linux mount points
260
+ "/home", # Parent of all user homes
261
+ "/Users", # macOS parent of all user homes
262
+ }
263
+
264
+ # Windows directories that should NOT be used as workspace
265
+ _SUSPICIOUS_DIRS_WINDOWS = {
266
+ "C:\\",
267
+ "C:\\Windows",
268
+ "C:\\Program Files",
269
+ "C:\\Program Files (x86)",
270
+ "C:\\ProgramData",
271
+ "C:\\Users",
272
+ "D:\\",
273
+ }
274
+
275
+
276
+ def _safe_resolve(path: Path) -> Path:
277
+ """Safely resolve a path, falling back to absolute() on errors.
278
+
279
+ Args:
280
+ path: Path to resolve.
281
+
282
+ Returns:
283
+ Resolved path, or absolute path if resolution fails.
284
+ """
285
+ try:
286
+ return path.resolve(strict=False)
287
+ except (OSError, RuntimeError):
288
+ try:
289
+ return path.absolute()
290
+ except (OSError, RuntimeError):
291
+ return path
292
+
293
+
294
+ def _is_suspicious_directory(path: Path) -> bool:
295
+ """Check if directory is suspicious (should not be used as workspace).
296
+
297
+ Cross-platform detection of directories that are likely not project roots:
298
+ - System directories (/, /tmp, C:\\Windows, etc.)
299
+ - User home directory itself
300
+ - Common non-project locations (Downloads, Desktop)
301
+
302
+ Args:
303
+ path: Directory to check.
304
+
305
+ Returns:
306
+ True if this is a suspicious directory.
307
+ """
308
+ resolved = _safe_resolve(path)
309
+ home = _safe_resolve(Path.home())
310
+
311
+ # User's home directory itself is suspicious
312
+ if resolved == home:
313
+ return True
314
+
315
+ str_path = str(resolved)
316
+
317
+ # Check platform-specific suspicious directories
318
+ import sys
319
+
320
+ if sys.platform == "win32":
321
+ # Windows: case-insensitive comparison
322
+ str_path_lower = str_path.lower()
323
+ for suspicious in _SUSPICIOUS_DIRS_WINDOWS:
324
+ if str_path_lower == suspicious.lower():
325
+ return True
326
+ # Also check if it's a drive root (e.g., "D:\")
327
+ if len(str_path) <= 3 and str_path[1:3] == ":\\":
328
+ return True
329
+ else:
330
+ # Unix-like systems
331
+ for suspicious in _SUSPICIOUS_DIRS_UNIX:
332
+ if str_path == suspicious:
333
+ return True
334
+
335
+ # Common non-project locations under home
336
+ suspicious_home_subdirs = ("Downloads", "Desktop", "Documents", "Library")
337
+ for subdir in suspicious_home_subdirs:
338
+ if resolved == home / subdir:
339
+ return True
340
+
341
+ return False
342
+
343
+
344
+ def _has_project_markers(path: Path) -> bool:
345
+ """Check if a directory has common project markers.
346
+
347
+ Uses a two-phase approach for performance:
348
+ 1. Fast direct existence checks for common markers
349
+ 2. Slower glob patterns only if direct checks fail
350
+
351
+ Args:
352
+ path: Directory to check.
353
+
354
+ Returns:
355
+ True if directory has any recognizable project markers.
356
+ """
357
+ if not path.is_dir():
358
+ return False
359
+
360
+ # Phase 1: Fast direct checks
361
+ for marker in _PROJECT_MARKERS_DIRECT:
362
+ if (path / marker).exists():
363
+ return True
364
+
365
+ # Phase 2: Slower glob checks (only if no direct markers found)
366
+ for pattern in _PROJECT_MARKERS_GLOB:
367
+ try:
368
+ if next(path.glob(pattern), None) is not None:
369
+ return True
370
+ except (OSError, StopIteration):
371
+ continue
372
+
373
+ return False
374
+
375
+
376
+ def _is_valid_workspace(path: Path) -> bool:
377
+ """Check if a directory looks like a valid workspace.
378
+
379
+ A valid workspace must have at least one of:
380
+ - .git directory or file (for worktrees)
381
+ - .scc.yaml config file
382
+ - Common project markers (package.json, pyproject.toml, etc.)
383
+
384
+ Random directories (like $HOME) are NOT valid workspaces.
385
+
386
+ Args:
387
+ path: Directory to check.
388
+
389
+ Returns:
390
+ True if directory exists and has workspace markers.
391
+ """
392
+ return _has_project_markers(path)
393
+
394
+
395
+ def pick_workspace_source(
396
+ has_team_repos: bool = False,
397
+ team: str | None = None,
398
+ *,
399
+ standalone: bool = False,
400
+ allow_back: bool = False,
401
+ context_label: str | None = None,
402
+ ) -> WorkspaceSource | _BackSentinel | None:
403
+ """Show picker for workspace source selection.
404
+
405
+ Three-state return contract:
406
+ - Success: Returns WorkspaceSource (user selected an option)
407
+ - Back: Returns BACK sentinel (user pressed Esc, only if allow_back=True)
408
+ - Quit: Returns None (user pressed q)
409
+
410
+ Args:
411
+ has_team_repos: Whether team repositories are available.
412
+ team: Current team name (used for context label if not provided).
413
+ standalone: If True, dim the "t teams" hint (not available without org).
414
+ allow_back: If True, Esc returns BACK (for sub-screen context like Dashboard).
415
+ If False, Esc returns None (for top-level CLI context).
416
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
417
+
418
+ Returns:
419
+ Selected WorkspaceSource, BACK if allow_back and Esc pressed, or None if quit.
420
+ """
421
+ # Build subtitle based on context
422
+ subtitle = "Pick a project source"
423
+ resolved_context_label = context_label
424
+ if resolved_context_label is None and team:
425
+ resolved_context_label = f"Team: {team}"
426
+
427
+ # Build items list - start with CWD option if appropriate
428
+ items: list[ListItem[WorkspaceSource]] = []
429
+
430
+ # Check current directory for project markers and git status
431
+ # Import here to avoid circular dependencies
432
+ from scc_cli import git
433
+
434
+ cwd = Path.cwd()
435
+ cwd_name = cwd.name or str(cwd)
436
+ is_git = git.is_git_repo(cwd)
437
+
438
+ # Three-tier logic with git awareness:
439
+ # 1. Suspicious directory (home, /, tmp) → don't show
440
+ # 2. Has project markers + git → show folder name (confident)
441
+ # 3. Has project markers, no git → show "folder (no git)"
442
+ # 4. No markers, not suspicious → show "folder (no git)"
443
+ if not _is_suspicious_directory(cwd):
444
+ if _has_project_markers(cwd):
445
+ if is_git:
446
+ # Valid project with git - show with confidence
447
+ items.append(
448
+ ListItem(
449
+ label="📍 Current directory",
450
+ description=cwd_name,
451
+ value=WorkspaceSource.CURRENT_DIR,
452
+ )
453
+ )
454
+ else:
455
+ # Has project markers but no git
456
+ items.append(
457
+ ListItem(
458
+ label="📍 Current directory",
459
+ description=f"{cwd_name} (no git)",
460
+ value=WorkspaceSource.CURRENT_DIR,
461
+ )
462
+ )
463
+ else:
464
+ # Not a project but still allow - show with hint about git
465
+ items.append(
466
+ ListItem(
467
+ label="📍 Current directory",
468
+ description=f"{cwd_name} (no git)",
469
+ value=WorkspaceSource.CURRENT_DIR,
470
+ )
471
+ )
472
+
473
+ # Add standard options
474
+ items.append(
475
+ ListItem(
476
+ label="📂 Recent workspaces",
477
+ description="Continue working on previous project",
478
+ value=WorkspaceSource.RECENT,
479
+ )
480
+ )
481
+
482
+ if has_team_repos:
483
+ items.append(
484
+ ListItem(
485
+ label="🏢 Team repositories",
486
+ description="Choose from team's common repos",
487
+ value=WorkspaceSource.TEAM_REPOS,
488
+ )
489
+ )
490
+
491
+ items.extend(
492
+ [
493
+ ListItem(
494
+ label="📁 Enter path",
495
+ description="Specify a local directory path",
496
+ value=WorkspaceSource.CUSTOM,
497
+ ),
498
+ ListItem(
499
+ label="🔗 Clone repository",
500
+ description="Clone a Git repository",
501
+ value=WorkspaceSource.CLONE,
502
+ ),
503
+ ]
504
+ )
505
+
506
+ return _run_single_select_picker(
507
+ items=items,
508
+ title="Where is your project?",
509
+ subtitle=subtitle,
510
+ standalone=standalone,
511
+ allow_back=allow_back,
512
+ context_label=resolved_context_label,
513
+ )
514
+
515
+
516
+ # ═══════════════════════════════════════════════════════════════════════════════
517
+ # Sub-Screen Picker: Recent Workspaces
518
+ # ═══════════════════════════════════════════════════════════════════════════════
519
+
520
+
521
+ def pick_recent_workspace(
522
+ recent: list[dict[str, Any]],
523
+ *,
524
+ standalone: bool = False,
525
+ context_label: str | None = None,
526
+ ) -> str | _BackSentinel | None:
527
+ """Show picker for recent workspace selection.
528
+
529
+ This is a sub-screen picker with three-state return contract:
530
+ - str: User selected a workspace path
531
+ - BACK: User pressed Esc (go back to previous screen)
532
+ - None: User pressed q (quit app entirely)
533
+
534
+ Args:
535
+ recent: List of recent session dicts with 'workspace' and 'last_used' keys.
536
+ standalone: If True, dim the "t teams" hint (not available without org).
537
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
538
+
539
+ Returns:
540
+ Selected workspace path, BACK if Esc pressed, or None if q pressed (quit).
541
+ """
542
+ # Build items with "← Back" first
543
+ items: list[ListItem[str | _BackSentinel]] = [
544
+ ListItem(
545
+ label="← Back",
546
+ description="",
547
+ value=BACK,
548
+ ),
549
+ ]
550
+
551
+ # Add recent workspaces
552
+ for session in recent:
553
+ workspace = session.get("workspace", "")
554
+ last_used = session.get("last_used", "")
555
+
556
+ items.append(
557
+ ListItem(
558
+ label=_normalize_path(workspace),
559
+ description=_format_relative_time(last_used),
560
+ value=workspace, # Full path as value
561
+ )
562
+ )
563
+
564
+ # Empty state hint in subtitle
565
+ if len(items) == 1: # Only "← Back"
566
+ subtitle = "No recent workspaces found"
567
+ else:
568
+ subtitle = None
569
+
570
+ return _run_subscreen_picker(
571
+ items=items,
572
+ title="Recent Workspaces",
573
+ subtitle=subtitle,
574
+ standalone=standalone,
575
+ context_label=context_label,
576
+ )
577
+
578
+
579
+ # ═══════════════════════════════════════════════════════════════════════════════
580
+ # Sub-Screen Picker: Team Repositories (Phase 3)
581
+ # ═══════════════════════════════════════════════════════════════════════════════
582
+
583
+
584
+ def pick_team_repo(
585
+ repos: list[dict[str, Any]],
586
+ workspace_base: str = "~/projects",
587
+ *,
588
+ standalone: bool = False,
589
+ context_label: str | None = None,
590
+ ) -> str | _BackSentinel | None:
591
+ """Show picker for team repository selection.
592
+
593
+ This is a sub-screen picker with three-state return contract:
594
+ - str: User selected a repo (returns existing local_path or newly cloned path)
595
+ - BACK: User pressed Esc (go back to previous screen)
596
+ - None: User pressed q (quit app entirely)
597
+
598
+ If the selected repo has a local_path that exists, returns that path.
599
+ Otherwise, clones the repository and returns the new path.
600
+
601
+ Args:
602
+ repos: List of repo dicts with 'name', 'url', optional 'description', 'local_path'.
603
+ workspace_base: Base directory for cloning new repos.
604
+ standalone: If True, dim the "t teams" hint (not available without org).
605
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
606
+
607
+ Returns:
608
+ Workspace path (existing or newly cloned), BACK if Esc pressed, or None if q pressed.
609
+ """
610
+ # Build items with "← Back" first
611
+ items: list[ListItem[dict[str, Any] | _BackSentinel]] = [
612
+ ListItem(
613
+ label="← Back",
614
+ description="",
615
+ value=BACK,
616
+ ),
617
+ ]
618
+
619
+ # Add team repos
620
+ for repo in repos:
621
+ name = repo.get("name", repo.get("url", "Unknown"))
622
+ description = repo.get("description", "")
623
+
624
+ items.append(
625
+ ListItem(
626
+ label=name,
627
+ description=description,
628
+ value=repo, # Full repo dict as value
629
+ )
630
+ )
631
+
632
+ # Empty state hint
633
+ if len(items) == 1: # Only "← Back"
634
+ subtitle = "No team repositories configured"
635
+ else:
636
+ subtitle = None
637
+
638
+ result = _run_subscreen_picker(
639
+ items=items,
640
+ title="Team Repositories",
641
+ subtitle=subtitle,
642
+ standalone=standalone,
643
+ context_label=context_label,
644
+ )
645
+
646
+ # Handle quit (q pressed)
647
+ if result is None:
648
+ return None
649
+
650
+ # Handle BACK (Esc pressed)
651
+ if result is BACK:
652
+ return BACK
653
+
654
+ # Handle repo selection - check for existing local path or clone
655
+ if isinstance(result, dict):
656
+ local_path = result.get("local_path")
657
+ if local_path:
658
+ expanded = Path(local_path).expanduser()
659
+ if expanded.exists():
660
+ return str(expanded)
661
+
662
+ # Need to clone - import git module here to avoid circular imports
663
+ from .. import git
664
+
665
+ repo_url = result.get("url", "")
666
+ if repo_url:
667
+ cloned_path = git.clone_repo(repo_url, workspace_base)
668
+ if cloned_path:
669
+ return cloned_path
670
+
671
+ # Cloning failed or no URL - return BACK to let user try again
672
+ return BACK
673
+
674
+ # Shouldn't happen, but handle gracefully
675
+ return BACK