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.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -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 +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- 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
|