scc-cli 1.4.0__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 (112) 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 +683 -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 +1400 -0
  16. scc_cli/cli_org.py +1433 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +858 -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 +603 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1082 -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 +1405 -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/compute.py +377 -0
  46. scc_cli/marketplace/constants.py +87 -0
  47. scc_cli/marketplace/managed.py +135 -0
  48. scc_cli/marketplace/materialize.py +723 -0
  49. scc_cli/marketplace/normalize.py +548 -0
  50. scc_cli/marketplace/render.py +238 -0
  51. scc_cli/marketplace/resolve.py +459 -0
  52. scc_cli/marketplace/schema.py +502 -0
  53. scc_cli/marketplace/sync.py +257 -0
  54. scc_cli/marketplace/team_cache.py +195 -0
  55. scc_cli/marketplace/team_fetch.py +688 -0
  56. scc_cli/marketplace/trust.py +244 -0
  57. scc_cli/models/__init__.py +41 -0
  58. scc_cli/models/exceptions.py +273 -0
  59. scc_cli/models/plugin_audit.py +434 -0
  60. scc_cli/org_templates.py +269 -0
  61. scc_cli/output_mode.py +167 -0
  62. scc_cli/panels.py +113 -0
  63. scc_cli/platform.py +350 -0
  64. scc_cli/profiles.py +1034 -0
  65. scc_cli/remote.py +443 -0
  66. scc_cli/schemas/__init__.py +1 -0
  67. scc_cli/schemas/org-v1.schema.json +456 -0
  68. scc_cli/schemas/team-config.v1.schema.json +163 -0
  69. scc_cli/sessions.py +425 -0
  70. scc_cli/setup.py +582 -0
  71. scc_cli/source_resolver.py +470 -0
  72. scc_cli/stats.py +378 -0
  73. scc_cli/stores/__init__.py +13 -0
  74. scc_cli/stores/exception_store.py +251 -0
  75. scc_cli/subprocess_utils.py +88 -0
  76. scc_cli/teams.py +339 -0
  77. scc_cli/templates/__init__.py +2 -0
  78. scc_cli/templates/org/__init__.py +0 -0
  79. scc_cli/templates/org/minimal.json +19 -0
  80. scc_cli/templates/org/reference.json +74 -0
  81. scc_cli/templates/org/strict.json +38 -0
  82. scc_cli/templates/org/teams.json +42 -0
  83. scc_cli/templates/statusline.sh +75 -0
  84. scc_cli/theme.py +348 -0
  85. scc_cli/ui/__init__.py +124 -0
  86. scc_cli/ui/branding.py +68 -0
  87. scc_cli/ui/chrome.py +395 -0
  88. scc_cli/ui/dashboard/__init__.py +62 -0
  89. scc_cli/ui/dashboard/_dashboard.py +669 -0
  90. scc_cli/ui/dashboard/loaders.py +369 -0
  91. scc_cli/ui/dashboard/models.py +184 -0
  92. scc_cli/ui/dashboard/orchestrator.py +337 -0
  93. scc_cli/ui/formatters.py +443 -0
  94. scc_cli/ui/gate.py +350 -0
  95. scc_cli/ui/help.py +157 -0
  96. scc_cli/ui/keys.py +521 -0
  97. scc_cli/ui/list_screen.py +431 -0
  98. scc_cli/ui/picker.py +700 -0
  99. scc_cli/ui/prompts.py +200 -0
  100. scc_cli/ui/wizard.py +490 -0
  101. scc_cli/update.py +680 -0
  102. scc_cli/utils/__init__.py +39 -0
  103. scc_cli/utils/fixit.py +264 -0
  104. scc_cli/utils/fuzzy.py +124 -0
  105. scc_cli/utils/locks.py +101 -0
  106. scc_cli/utils/ttl.py +376 -0
  107. scc_cli/validate.py +455 -0
  108. scc_cli-1.4.0.dist-info/METADATA +369 -0
  109. scc_cli-1.4.0.dist-info/RECORD +112 -0
  110. scc_cli-1.4.0.dist-info/WHEEL +4 -0
  111. scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
  112. scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
scc_cli/ui/prompts.py ADDED
@@ -0,0 +1,200 @@
1
+ """Simple Rich-based prompts for CLI interactions.
2
+
3
+ This module provides straightforward prompt utilities for user input that don't
4
+ require full TUI screens. For more complex interactive pickers with keyboard
5
+ navigation, see picker.py and wizard.py.
6
+
7
+ Functions:
8
+ render_error: Display an SCCError with user-friendly formatting
9
+ select_session: Interactive session selection from a list
10
+ select_team: Interactive team selection menu
11
+ prompt_custom_workspace: Prompt for custom workspace path
12
+ prompt_repo_url: Prompt for Git repository URL
13
+ """
14
+
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.prompt import Confirm, IntPrompt, Prompt
22
+ from rich.table import Table
23
+
24
+ from scc_cli.theme import Borders, Colors
25
+
26
+ if TYPE_CHECKING:
27
+ from scc_cli.errors import SCCError
28
+
29
+
30
+ def render_error(console: Console, error: "SCCError", debug: bool = False) -> None:
31
+ """Render an error with user-friendly formatting.
32
+
33
+ Philosophy: "One message, one action"
34
+ - Display what went wrong (user_message)
35
+ - Display what to do next (suggested_action)
36
+ - Display debug info only if --debug flag is used
37
+
38
+ Args:
39
+ console: Rich console for output.
40
+ error: The SCCError to render.
41
+ debug: Whether to show debug context.
42
+ """
43
+ lines = []
44
+
45
+ # Main error message
46
+ lines.append(f"[bold]{error.user_message}[/bold]")
47
+
48
+ # Suggested action (if available)
49
+ if error.suggested_action:
50
+ lines.append("")
51
+ lines.append(f"[{Colors.SECONDARY}]->[/{Colors.SECONDARY}] {error.suggested_action}")
52
+
53
+ # Debug context (only with --debug)
54
+ if debug and error.debug_context:
55
+ lines.append("")
56
+ lines.append(f"[{Colors.SECONDARY}]--- Debug Info ---[/{Colors.SECONDARY}]")
57
+ lines.append(f"[{Colors.SECONDARY}]{error.debug_context}[/{Colors.SECONDARY}]")
58
+ elif error.debug_context and not debug:
59
+ lines.append("")
60
+ lines.append(
61
+ f"[{Colors.SECONDARY}]Run with --debug for technical details[/{Colors.SECONDARY}]"
62
+ )
63
+
64
+ # Create panel with error styling
65
+ panel = Panel(
66
+ "\n".join(lines),
67
+ title=f"[{Colors.ERROR_BOLD}]Error[/{Colors.ERROR_BOLD}]",
68
+ border_style=Borders.PANEL_ERROR,
69
+ padding=(0, 1),
70
+ )
71
+
72
+ console.print()
73
+ console.print(panel)
74
+ console.print()
75
+
76
+
77
+ def select_session(console: Console, sessions_list: list[dict[str, Any]]) -> dict[str, Any] | None:
78
+ """Display an interactive session selection menu.
79
+
80
+ Args:
81
+ console: Rich console for output.
82
+ sessions_list: List of session dicts with 'name', 'workspace', 'last_used', etc.
83
+
84
+ Returns:
85
+ Selected session dict or None if cancelled.
86
+ """
87
+ if not sessions_list:
88
+ console.print(f"[{Colors.WARNING}]No sessions available.[/{Colors.WARNING}]")
89
+ return None
90
+
91
+ console.print(f"\n[{Colors.BRAND_BOLD}]Select a session:[/{Colors.BRAND_BOLD}]\n")
92
+
93
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
94
+ table.add_column("Option", style=Colors.WARNING, width=4)
95
+ table.add_column("Name", style=Colors.BRAND)
96
+ table.add_column("Workspace", style=Colors.PRIMARY)
97
+ table.add_column("Last Used", style=Colors.SECONDARY)
98
+
99
+ for i, session in enumerate(sessions_list, 1):
100
+ name = session.get("name", "-")
101
+ workspace = session.get("workspace", "-")
102
+ last_used = session.get("last_used", "-")
103
+ table.add_row(f"[{i}]", name, workspace, last_used)
104
+
105
+ table.add_row("[0]", "<- Cancel", "", "")
106
+
107
+ console.print(table)
108
+
109
+ valid_choices = [str(i) for i in range(0, len(sessions_list) + 1)]
110
+ choice = IntPrompt.ask(
111
+ f"\n[{Colors.BRAND}]Select session[/{Colors.BRAND}]",
112
+ default=1,
113
+ choices=valid_choices,
114
+ )
115
+
116
+ if choice == 0:
117
+ return None
118
+
119
+ return sessions_list[choice - 1]
120
+
121
+
122
+ def select_team(console: Console, cfg: dict[str, Any]) -> str | None:
123
+ """Display an interactive team selection menu and return the chosen team.
124
+
125
+ Args:
126
+ console: Rich console for output.
127
+ cfg: Configuration dict containing 'profiles' key with team definitions.
128
+
129
+ Returns:
130
+ Selected team name or None if no teams available.
131
+ """
132
+ teams: dict[str, Any] = cfg.get("profiles", {})
133
+ team_list: list[str] = list(teams.keys())
134
+
135
+ if not team_list:
136
+ return None
137
+
138
+ console.print(f"\n[{Colors.BRAND_BOLD}]Select your team:[/{Colors.BRAND_BOLD}]\n")
139
+
140
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
141
+ table.add_column("Option", style=Colors.WARNING, width=4)
142
+ table.add_column("Team", style=Colors.BRAND)
143
+ table.add_column("Description", style=Colors.PRIMARY)
144
+
145
+ for i, team_name in enumerate(team_list, 1):
146
+ team_info = teams[team_name]
147
+ desc = team_info.get("description", "")
148
+ table.add_row(f"[{i}]", team_name, desc)
149
+
150
+ console.print(table)
151
+
152
+ choice = IntPrompt.ask(
153
+ f"\n[{Colors.BRAND}]Select team[/{Colors.BRAND}]",
154
+ default=1,
155
+ choices=[str(i) for i in range(1, len(team_list) + 1)],
156
+ )
157
+
158
+ selected = team_list[choice - 1]
159
+ console.print(f"\n[{Colors.SUCCESS}]Selected: {selected}[/{Colors.SUCCESS}]")
160
+
161
+ return selected
162
+
163
+
164
+ def prompt_custom_workspace(console: Console) -> str | None:
165
+ """Prompt the user to enter a custom workspace path.
166
+
167
+ Args:
168
+ console: Rich console for output.
169
+
170
+ Returns:
171
+ Resolved absolute path string, or None if cancelled or path invalid.
172
+ """
173
+ path = Prompt.ask(f"\n[{Colors.BRAND}]Enter workspace path[/{Colors.BRAND}]")
174
+
175
+ if not path:
176
+ return None
177
+
178
+ expanded = Path(path).expanduser().resolve()
179
+
180
+ if not expanded.exists():
181
+ console.print(f"[{Colors.ERROR}]Path does not exist: {expanded}[/{Colors.ERROR}]")
182
+ if Confirm.ask(f"[{Colors.BRAND}]Create this directory?[/{Colors.BRAND}]", default=False):
183
+ expanded.mkdir(parents=True, exist_ok=True)
184
+ return str(expanded)
185
+ return None
186
+
187
+ return str(expanded)
188
+
189
+
190
+ def prompt_repo_url(console: Console) -> str:
191
+ """Prompt the user to enter a Git repository URL.
192
+
193
+ Args:
194
+ console: Rich console for output.
195
+
196
+ Returns:
197
+ The entered URL string (may be empty if user pressed Enter).
198
+ """
199
+ url = Prompt.ask(f"\n[{Colors.BRAND}]Repository URL (HTTPS or SSH)[/{Colors.BRAND}]")
200
+ return url
scc_cli/ui/wizard.py ADDED
@@ -0,0 +1,490 @@
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
+ def _is_valid_workspace(path: Path) -> bool:
210
+ """Check if a directory looks like a valid workspace.
211
+
212
+ A valid workspace must have at least one of:
213
+ - .git directory or file (for worktrees)
214
+ - .scc.yaml config file
215
+
216
+ Random directories (like $HOME) are NOT valid workspaces.
217
+
218
+ Args:
219
+ path: Directory to check.
220
+
221
+ Returns:
222
+ True if directory exists and has workspace markers.
223
+ """
224
+ if not path.is_dir():
225
+ return False
226
+
227
+ # Check for git (directory or file for worktrees)
228
+ git_path = path / ".git"
229
+ if git_path.exists():
230
+ return True
231
+
232
+ # Check for SCC config
233
+ if (path / ".scc.yaml").exists():
234
+ return True
235
+
236
+ # No workspace markers found - not a valid workspace
237
+ return False
238
+
239
+
240
+ def pick_workspace_source(
241
+ has_team_repos: bool = False,
242
+ team: str | None = None,
243
+ *,
244
+ standalone: bool = False,
245
+ allow_back: bool = False,
246
+ context_label: str | None = None,
247
+ ) -> WorkspaceSource | _BackSentinel | None:
248
+ """Show picker for workspace source selection.
249
+
250
+ Three-state return contract:
251
+ - Success: Returns WorkspaceSource (user selected an option)
252
+ - Back: Returns BACK sentinel (user pressed Esc, only if allow_back=True)
253
+ - Quit: Returns None (user pressed q)
254
+
255
+ Args:
256
+ has_team_repos: Whether team repositories are available.
257
+ team: Current team name (used for context label if not provided).
258
+ standalone: If True, dim the "t teams" hint (not available without org).
259
+ allow_back: If True, Esc returns BACK (for sub-screen context like Dashboard).
260
+ If False, Esc returns None (for top-level CLI context).
261
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
262
+
263
+ Returns:
264
+ Selected WorkspaceSource, BACK if allow_back and Esc pressed, or None if quit.
265
+ """
266
+ # Build subtitle based on context
267
+ subtitle = "Pick a project source"
268
+ resolved_context_label = context_label
269
+ if resolved_context_label is None and team:
270
+ resolved_context_label = f"Team: {team}"
271
+
272
+ # Build items list - start with CWD option if valid
273
+ items: list[ListItem[WorkspaceSource]] = []
274
+
275
+ # Check if current directory is a valid workspace
276
+ cwd = Path.cwd()
277
+ if _is_valid_workspace(cwd):
278
+ # Show CWD name (last path component)
279
+ cwd_name = cwd.name or str(cwd)
280
+ items.append(
281
+ ListItem(
282
+ label="📍 Use current directory",
283
+ description=cwd_name,
284
+ value=WorkspaceSource.CURRENT_DIR,
285
+ )
286
+ )
287
+
288
+ # Add standard options
289
+ items.append(
290
+ ListItem(
291
+ label="📂 Recent workspaces",
292
+ description="Continue working on previous project",
293
+ value=WorkspaceSource.RECENT,
294
+ )
295
+ )
296
+
297
+ if has_team_repos:
298
+ items.append(
299
+ ListItem(
300
+ label="🏢 Team repositories",
301
+ description="Choose from team's common repos",
302
+ value=WorkspaceSource.TEAM_REPOS,
303
+ )
304
+ )
305
+
306
+ items.extend(
307
+ [
308
+ ListItem(
309
+ label="📁 Enter path",
310
+ description="Specify a local directory path",
311
+ value=WorkspaceSource.CUSTOM,
312
+ ),
313
+ ListItem(
314
+ label="🔗 Clone repository",
315
+ description="Clone a Git repository",
316
+ value=WorkspaceSource.CLONE,
317
+ ),
318
+ ]
319
+ )
320
+
321
+ return _run_single_select_picker(
322
+ items=items,
323
+ title="Where is your project?",
324
+ subtitle=subtitle,
325
+ standalone=standalone,
326
+ allow_back=allow_back,
327
+ context_label=resolved_context_label,
328
+ )
329
+
330
+
331
+ # ═══════════════════════════════════════════════════════════════════════════════
332
+ # Sub-Screen Picker: Recent Workspaces
333
+ # ═══════════════════════════════════════════════════════════════════════════════
334
+
335
+
336
+ def pick_recent_workspace(
337
+ recent: list[dict[str, Any]],
338
+ *,
339
+ standalone: bool = False,
340
+ context_label: str | None = None,
341
+ ) -> str | _BackSentinel | None:
342
+ """Show picker for recent workspace selection.
343
+
344
+ This is a sub-screen picker with three-state return contract:
345
+ - str: User selected a workspace path
346
+ - BACK: User pressed Esc (go back to previous screen)
347
+ - None: User pressed q (quit app entirely)
348
+
349
+ Args:
350
+ recent: List of recent session dicts with 'workspace' and 'last_used' keys.
351
+ standalone: If True, dim the "t teams" hint (not available without org).
352
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
353
+
354
+ Returns:
355
+ Selected workspace path, BACK if Esc pressed, or None if q pressed (quit).
356
+ """
357
+ # Build items with "← Back" first
358
+ items: list[ListItem[str | _BackSentinel]] = [
359
+ ListItem(
360
+ label="← Back",
361
+ description="",
362
+ value=BACK,
363
+ ),
364
+ ]
365
+
366
+ # Add recent workspaces
367
+ for session in recent:
368
+ workspace = session.get("workspace", "")
369
+ last_used = session.get("last_used", "")
370
+
371
+ items.append(
372
+ ListItem(
373
+ label=_normalize_path(workspace),
374
+ description=_format_relative_time(last_used),
375
+ value=workspace, # Full path as value
376
+ )
377
+ )
378
+
379
+ # Empty state hint in subtitle
380
+ if len(items) == 1: # Only "← Back"
381
+ subtitle = "No recent workspaces found"
382
+ else:
383
+ subtitle = None
384
+
385
+ return _run_subscreen_picker(
386
+ items=items,
387
+ title="Recent Workspaces",
388
+ subtitle=subtitle,
389
+ standalone=standalone,
390
+ context_label=context_label,
391
+ )
392
+
393
+
394
+ # ═══════════════════════════════════════════════════════════════════════════════
395
+ # Sub-Screen Picker: Team Repositories (Phase 3)
396
+ # ═══════════════════════════════════════════════════════════════════════════════
397
+
398
+
399
+ def pick_team_repo(
400
+ repos: list[dict[str, Any]],
401
+ workspace_base: str = "~/projects",
402
+ *,
403
+ standalone: bool = False,
404
+ context_label: str | None = None,
405
+ ) -> str | _BackSentinel | None:
406
+ """Show picker for team repository selection.
407
+
408
+ This is a sub-screen picker with three-state return contract:
409
+ - str: User selected a repo (returns existing local_path or newly cloned path)
410
+ - BACK: User pressed Esc (go back to previous screen)
411
+ - None: User pressed q (quit app entirely)
412
+
413
+ If the selected repo has a local_path that exists, returns that path.
414
+ Otherwise, clones the repository and returns the new path.
415
+
416
+ Args:
417
+ repos: List of repo dicts with 'name', 'url', optional 'description', 'local_path'.
418
+ workspace_base: Base directory for cloning new repos.
419
+ standalone: If True, dim the "t teams" hint (not available without org).
420
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
421
+
422
+ Returns:
423
+ Workspace path (existing or newly cloned), BACK if Esc pressed, or None if q pressed.
424
+ """
425
+ # Build items with "← Back" first
426
+ items: list[ListItem[dict[str, Any] | _BackSentinel]] = [
427
+ ListItem(
428
+ label="← Back",
429
+ description="",
430
+ value=BACK,
431
+ ),
432
+ ]
433
+
434
+ # Add team repos
435
+ for repo in repos:
436
+ name = repo.get("name", repo.get("url", "Unknown"))
437
+ description = repo.get("description", "")
438
+
439
+ items.append(
440
+ ListItem(
441
+ label=name,
442
+ description=description,
443
+ value=repo, # Full repo dict as value
444
+ )
445
+ )
446
+
447
+ # Empty state hint
448
+ if len(items) == 1: # Only "← Back"
449
+ subtitle = "No team repositories configured"
450
+ else:
451
+ subtitle = None
452
+
453
+ result = _run_subscreen_picker(
454
+ items=items,
455
+ title="Team Repositories",
456
+ subtitle=subtitle,
457
+ standalone=standalone,
458
+ context_label=context_label,
459
+ )
460
+
461
+ # Handle quit (q pressed)
462
+ if result is None:
463
+ return None
464
+
465
+ # Handle BACK (Esc pressed)
466
+ if result is BACK:
467
+ return BACK
468
+
469
+ # Handle repo selection - check for existing local path or clone
470
+ if isinstance(result, dict):
471
+ local_path = result.get("local_path")
472
+ if local_path:
473
+ expanded = Path(local_path).expanduser()
474
+ if expanded.exists():
475
+ return str(expanded)
476
+
477
+ # Need to clone - import git module here to avoid circular imports
478
+ from .. import git
479
+
480
+ repo_url = result.get("url", "")
481
+ if repo_url:
482
+ cloned_path = git.clone_repo(repo_url, workspace_base)
483
+ if cloned_path:
484
+ return cloned_path
485
+
486
+ # Cloning failed or no URL - return BACK to let user try again
487
+ return BACK
488
+
489
+ # Shouldn't happen, but handle gracefully
490
+ return BACK