scc-cli 1.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
scc_cli/cli.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SCC - Sandboxed Claude CLI
|
|
4
|
+
|
|
5
|
+
A command-line tool for safely running Claude Code in Docker sandboxes
|
|
6
|
+
with team-specific configurations and worktree management.
|
|
7
|
+
|
|
8
|
+
This module serves as the thin orchestrator that composes commands from:
|
|
9
|
+
- commands/launch.py: Start command and interactive mode
|
|
10
|
+
- commands/worktree.py: Worktree, session, and container management
|
|
11
|
+
- commands/config.py: Teams, setup, and configuration commands
|
|
12
|
+
- commands/admin.py: Doctor, update, statusline, and stats commands
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import PackageNotFoundError
|
|
16
|
+
from importlib.metadata import version as get_installed_version
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from .cli_common import console, state
|
|
21
|
+
from .commands.admin import (
|
|
22
|
+
doctor_cmd,
|
|
23
|
+
stats_app,
|
|
24
|
+
status_cmd,
|
|
25
|
+
statusline_cmd,
|
|
26
|
+
update_cmd,
|
|
27
|
+
)
|
|
28
|
+
from .commands.audit import audit_app
|
|
29
|
+
from .commands.config import (
|
|
30
|
+
config_cmd,
|
|
31
|
+
setup_cmd,
|
|
32
|
+
)
|
|
33
|
+
from .commands.exceptions import exceptions_app, unblock_cmd
|
|
34
|
+
from .commands.init import init_cmd
|
|
35
|
+
|
|
36
|
+
# Import command functions from domain modules
|
|
37
|
+
from .commands.launch import start
|
|
38
|
+
from .commands.org import org_app
|
|
39
|
+
from .commands.support import support_app
|
|
40
|
+
from .commands.team import team_app
|
|
41
|
+
from .commands.worktree import (
|
|
42
|
+
container_app,
|
|
43
|
+
context_app,
|
|
44
|
+
list_cmd,
|
|
45
|
+
prune_cmd,
|
|
46
|
+
session_app,
|
|
47
|
+
sessions_cmd,
|
|
48
|
+
stop_cmd,
|
|
49
|
+
worktree_app,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
# App Configuration
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
app = typer.Typer(
|
|
57
|
+
name="scc-cli",
|
|
58
|
+
help="Safely run Claude Code with team configurations and worktree management.",
|
|
59
|
+
no_args_is_help=False,
|
|
60
|
+
rich_markup_mode="rich",
|
|
61
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
# Global Callback (--debug flag)
|
|
67
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.callback(invoke_without_command=True)
|
|
71
|
+
def main_callback(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
debug: bool = typer.Option(
|
|
74
|
+
False,
|
|
75
|
+
"--debug",
|
|
76
|
+
help="Show detailed error information for troubleshooting.",
|
|
77
|
+
is_eager=True,
|
|
78
|
+
),
|
|
79
|
+
version: bool = typer.Option(
|
|
80
|
+
False,
|
|
81
|
+
"--version",
|
|
82
|
+
"-v",
|
|
83
|
+
help="Show version and exit.",
|
|
84
|
+
is_eager=True,
|
|
85
|
+
),
|
|
86
|
+
interactive: bool = typer.Option(
|
|
87
|
+
False,
|
|
88
|
+
"-i",
|
|
89
|
+
"--interactive",
|
|
90
|
+
help="Force interactive workspace picker (shortcut for 'scc start -i').",
|
|
91
|
+
),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""
|
|
94
|
+
[bold cyan]SCC[/bold cyan] - Sandboxed Claude CLI
|
|
95
|
+
|
|
96
|
+
Safely run Claude Code in Docker sandboxes with team configurations.
|
|
97
|
+
"""
|
|
98
|
+
state.debug = debug
|
|
99
|
+
|
|
100
|
+
if version:
|
|
101
|
+
from .ui.branding import get_version_header
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
pkg_version = get_installed_version("scc-cli")
|
|
105
|
+
except PackageNotFoundError:
|
|
106
|
+
pkg_version = "unknown"
|
|
107
|
+
console.print(get_version_header(pkg_version))
|
|
108
|
+
raise typer.Exit()
|
|
109
|
+
|
|
110
|
+
# If no command provided and not showing version, use context-aware routing
|
|
111
|
+
if ctx.invoked_subcommand is None:
|
|
112
|
+
from pathlib import Path
|
|
113
|
+
|
|
114
|
+
from rich.prompt import Prompt
|
|
115
|
+
|
|
116
|
+
from . import config as scc_config
|
|
117
|
+
from . import setup as scc_setup
|
|
118
|
+
from .services.workspace import resolve_launch_context
|
|
119
|
+
from .ui.gate import is_interactive_allowed
|
|
120
|
+
|
|
121
|
+
# Use strong-signal resolver (git or .scc.yaml) for parity with 'scc start'
|
|
122
|
+
# Weak markers (package.json, etc.) are NOT used for auto-launch
|
|
123
|
+
cwd = Path.cwd()
|
|
124
|
+
result = resolve_launch_context(cwd, workspace_arg=None)
|
|
125
|
+
workspace_detected = result is not None and result.is_auto_eligible()
|
|
126
|
+
|
|
127
|
+
if is_interactive_allowed():
|
|
128
|
+
# If no org is configured and standalone isn't explicit, offer setup
|
|
129
|
+
user_cfg = scc_config.load_user_config()
|
|
130
|
+
org_source = user_cfg.get("organization_source") or {}
|
|
131
|
+
has_org = bool(org_source.get("url"))
|
|
132
|
+
if not has_org and not user_cfg.get("standalone"):
|
|
133
|
+
choice = Prompt.ask(
|
|
134
|
+
"[yellow]No organization configured.[/yellow] Choose setup mode",
|
|
135
|
+
choices=["setup", "standalone", "quit"],
|
|
136
|
+
default="setup",
|
|
137
|
+
)
|
|
138
|
+
if choice == "setup":
|
|
139
|
+
if not scc_setup.run_setup_wizard(console):
|
|
140
|
+
raise typer.Exit(0)
|
|
141
|
+
elif choice == "standalone":
|
|
142
|
+
user_cfg["standalone"] = True
|
|
143
|
+
scc_config.save_user_config(user_cfg)
|
|
144
|
+
else:
|
|
145
|
+
raise typer.Exit(0)
|
|
146
|
+
|
|
147
|
+
# Offer to start immediately after setup/standalone choice
|
|
148
|
+
start_now = Prompt.ask(
|
|
149
|
+
"[cyan]Start a session now?[/cyan]",
|
|
150
|
+
choices=["yes", "no"],
|
|
151
|
+
default="yes",
|
|
152
|
+
)
|
|
153
|
+
if start_now == "yes":
|
|
154
|
+
ctx.invoke(
|
|
155
|
+
start,
|
|
156
|
+
workspace=str(cwd) if workspace_detected else None,
|
|
157
|
+
team=None,
|
|
158
|
+
session_name=None,
|
|
159
|
+
resume=False,
|
|
160
|
+
select=False,
|
|
161
|
+
continue_session=False,
|
|
162
|
+
worktree_name=None,
|
|
163
|
+
fresh=False,
|
|
164
|
+
install_deps=False,
|
|
165
|
+
offline=False,
|
|
166
|
+
standalone=False,
|
|
167
|
+
dry_run=False,
|
|
168
|
+
json_output=False,
|
|
169
|
+
pretty=False,
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
if interactive:
|
|
174
|
+
# -i flag: force interactive workspace picker via start -i
|
|
175
|
+
ctx.invoke(
|
|
176
|
+
start,
|
|
177
|
+
workspace=None,
|
|
178
|
+
team=None,
|
|
179
|
+
session_name=None,
|
|
180
|
+
resume=False,
|
|
181
|
+
select=False,
|
|
182
|
+
continue_session=False,
|
|
183
|
+
worktree_name=None,
|
|
184
|
+
fresh=False,
|
|
185
|
+
install_deps=False,
|
|
186
|
+
offline=False,
|
|
187
|
+
standalone=False,
|
|
188
|
+
dry_run=False,
|
|
189
|
+
json_output=False,
|
|
190
|
+
pretty=False,
|
|
191
|
+
)
|
|
192
|
+
elif workspace_detected:
|
|
193
|
+
# Strong signal found (git repo or .scc.yaml) → use smart start flow
|
|
194
|
+
# This shows Quick Resume (if sessions exist) or launches immediately
|
|
195
|
+
ctx.invoke(
|
|
196
|
+
start,
|
|
197
|
+
workspace=str(cwd),
|
|
198
|
+
team=None,
|
|
199
|
+
session_name=None,
|
|
200
|
+
resume=False,
|
|
201
|
+
select=False,
|
|
202
|
+
continue_session=False,
|
|
203
|
+
worktree_name=None,
|
|
204
|
+
fresh=False,
|
|
205
|
+
install_deps=False,
|
|
206
|
+
offline=False,
|
|
207
|
+
standalone=False,
|
|
208
|
+
dry_run=False,
|
|
209
|
+
json_output=False,
|
|
210
|
+
pretty=False,
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
# No strong signal (not in git repo, no .scc.yaml) → show dashboard
|
|
214
|
+
from .ui.dashboard import run_dashboard
|
|
215
|
+
|
|
216
|
+
run_dashboard()
|
|
217
|
+
else:
|
|
218
|
+
# Non-interactive - invoke start with defaults (will fail F1/F2 if no signal)
|
|
219
|
+
# NOTE: Must pass ALL defaults explicitly - ctx.invoke() doesn't resolve
|
|
220
|
+
# typer.Argument/Option defaults, it passes raw ArgumentInfo/OptionInfo
|
|
221
|
+
ctx.invoke(
|
|
222
|
+
start,
|
|
223
|
+
workspace=str(cwd) if workspace_detected else None,
|
|
224
|
+
team=None,
|
|
225
|
+
session_name=None,
|
|
226
|
+
resume=False,
|
|
227
|
+
select=False,
|
|
228
|
+
continue_session=False,
|
|
229
|
+
worktree_name=None,
|
|
230
|
+
fresh=False,
|
|
231
|
+
install_deps=False,
|
|
232
|
+
offline=False,
|
|
233
|
+
standalone=False,
|
|
234
|
+
dry_run=False,
|
|
235
|
+
json_output=False,
|
|
236
|
+
pretty=False,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
# Help Panel Group Names
|
|
242
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
PANEL_SESSION = "Session Management"
|
|
245
|
+
PANEL_WORKSPACE = "Workspace"
|
|
246
|
+
PANEL_CONFIG = "Configuration"
|
|
247
|
+
PANEL_ADMIN = "Administration"
|
|
248
|
+
PANEL_GOVERNANCE = "Governance"
|
|
249
|
+
|
|
250
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
# Register Commands from Domain Modules
|
|
252
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
# Launch commands
|
|
255
|
+
app.command(rich_help_panel=PANEL_SESSION)(start)
|
|
256
|
+
|
|
257
|
+
# Worktree command group
|
|
258
|
+
app.add_typer(worktree_app, name="worktree", rich_help_panel=PANEL_WORKSPACE)
|
|
259
|
+
|
|
260
|
+
# Session and container commands
|
|
261
|
+
app.command(name="sessions", rich_help_panel=PANEL_SESSION)(sessions_cmd)
|
|
262
|
+
app.command(name="list", rich_help_panel=PANEL_SESSION)(list_cmd)
|
|
263
|
+
app.command(name="stop", rich_help_panel=PANEL_SESSION)(stop_cmd)
|
|
264
|
+
app.command(name="prune", rich_help_panel=PANEL_SESSION)(prune_cmd)
|
|
265
|
+
|
|
266
|
+
# Configuration commands
|
|
267
|
+
app.add_typer(team_app, name="team", rich_help_panel=PANEL_CONFIG)
|
|
268
|
+
app.command(name="setup", rich_help_panel=PANEL_CONFIG)(setup_cmd)
|
|
269
|
+
app.command(name="config", rich_help_panel=PANEL_CONFIG)(config_cmd)
|
|
270
|
+
app.command(name="init", rich_help_panel=PANEL_CONFIG)(init_cmd)
|
|
271
|
+
|
|
272
|
+
# Admin commands
|
|
273
|
+
app.command(name="doctor", rich_help_panel=PANEL_ADMIN)(doctor_cmd)
|
|
274
|
+
app.command(name="update", rich_help_panel=PANEL_ADMIN)(update_cmd)
|
|
275
|
+
app.command(name="status", rich_help_panel=PANEL_ADMIN)(status_cmd)
|
|
276
|
+
app.command(name="statusline", rich_help_panel=PANEL_ADMIN)(statusline_cmd)
|
|
277
|
+
|
|
278
|
+
# Add stats sub-app
|
|
279
|
+
app.add_typer(stats_app, name="stats", rich_help_panel=PANEL_ADMIN)
|
|
280
|
+
|
|
281
|
+
# Exception management commands
|
|
282
|
+
app.add_typer(exceptions_app, name="exceptions", rich_help_panel=PANEL_GOVERNANCE)
|
|
283
|
+
app.command(name="unblock", rich_help_panel=PANEL_GOVERNANCE)(unblock_cmd)
|
|
284
|
+
|
|
285
|
+
# Audit commands
|
|
286
|
+
app.add_typer(audit_app, name="audit", rich_help_panel=PANEL_GOVERNANCE)
|
|
287
|
+
|
|
288
|
+
# Support commands
|
|
289
|
+
app.add_typer(support_app, name="support", rich_help_panel=PANEL_GOVERNANCE)
|
|
290
|
+
|
|
291
|
+
# Org admin commands
|
|
292
|
+
app.add_typer(org_app, name="org", rich_help_panel=PANEL_GOVERNANCE)
|
|
293
|
+
|
|
294
|
+
# Symmetric alias apps (Phase 8)
|
|
295
|
+
app.add_typer(session_app, name="session", rich_help_panel=PANEL_WORKSPACE)
|
|
296
|
+
app.add_typer(container_app, name="container", rich_help_panel=PANEL_WORKSPACE)
|
|
297
|
+
app.add_typer(context_app, name="context", rich_help_panel=PANEL_WORKSPACE)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
# Entry Point
|
|
302
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main() -> None:
|
|
306
|
+
"""Entry point for the CLI."""
|
|
307
|
+
app()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
main()
|
scc_cli/cli_common.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI Common Utilities.
|
|
3
|
+
|
|
4
|
+
Shared utilities, constants, and decorators used across all CLI modules.
|
|
5
|
+
This module is extracted to prevent circular imports and enable clean composition.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from typing import Any, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich import box
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from .core.errors import SCCError
|
|
18
|
+
from .core.exit_codes import EXIT_CANCELLED
|
|
19
|
+
from .output_mode import is_json_command_mode
|
|
20
|
+
from .panels import create_warning_panel
|
|
21
|
+
from .ui.prompts import render_error
|
|
22
|
+
|
|
23
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
24
|
+
|
|
25
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
# Display Constants
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
# Maximum length for displaying file paths before truncation
|
|
30
|
+
MAX_DISPLAY_PATH_LENGTH = 50
|
|
31
|
+
# Characters to keep when truncating (MAX - 3 for "...")
|
|
32
|
+
PATH_TRUNCATE_LENGTH = 47
|
|
33
|
+
# Terminal width threshold for wide mode tables
|
|
34
|
+
WIDE_MODE_THRESHOLD = 110
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
# Shared Console and State
|
|
39
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
console = Console()
|
|
42
|
+
err_console = Console(stderr=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AppState:
|
|
46
|
+
"""Global application state for CLI flags."""
|
|
47
|
+
|
|
48
|
+
debug: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
state = AppState()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
# Error Boundary Decorator
|
|
56
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def handle_errors(func: F) -> F:
|
|
60
|
+
"""Catch SCCError exceptions and render user-friendly error output.
|
|
61
|
+
|
|
62
|
+
Wrap CLI command functions to provide consistent error handling:
|
|
63
|
+
- SCCError: Render with render_error and exit with error's exit_code
|
|
64
|
+
- KeyboardInterrupt: Print cancellation message and exit 130
|
|
65
|
+
- Other exceptions: Show warning panel (or full traceback with --debug)
|
|
66
|
+
|
|
67
|
+
JSON Mode: This is the SINGLE LOCATION for JSON error envelope output.
|
|
68
|
+
All errors in JSON mode are handled here to ensure consistency.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
func: The CLI command function to wrap.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Wrapped function with error handling.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
@wraps(func)
|
|
78
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
79
|
+
try:
|
|
80
|
+
return func(*args, **kwargs)
|
|
81
|
+
except SCCError as e:
|
|
82
|
+
if is_json_command_mode():
|
|
83
|
+
# JSON mode: emit structured error envelope to stdout
|
|
84
|
+
from .core.exit_codes import get_exit_code_for_exception
|
|
85
|
+
from .json_output import build_error_envelope
|
|
86
|
+
from .output_mode import print_json
|
|
87
|
+
|
|
88
|
+
envelope = build_error_envelope(e)
|
|
89
|
+
print_json(envelope)
|
|
90
|
+
raise typer.Exit(get_exit_code_for_exception(e))
|
|
91
|
+
# Human mode: use stderr for errors (stdout purity for shell wrappers)
|
|
92
|
+
render_error(err_console, e, debug=state.debug)
|
|
93
|
+
raise typer.Exit(e.exit_code)
|
|
94
|
+
except KeyboardInterrupt:
|
|
95
|
+
if is_json_command_mode():
|
|
96
|
+
# JSON mode: emit cancellation envelope
|
|
97
|
+
from .json_output import build_error_envelope
|
|
98
|
+
from .output_mode import print_json
|
|
99
|
+
|
|
100
|
+
# Create a pseudo-exception for the envelope
|
|
101
|
+
cancel_exc = Exception("Operation cancelled by user")
|
|
102
|
+
envelope = build_error_envelope(cancel_exc)
|
|
103
|
+
print_json(envelope)
|
|
104
|
+
raise typer.Exit(EXIT_CANCELLED)
|
|
105
|
+
# Human mode: use stderr
|
|
106
|
+
err_console.print("\n[dim]Operation cancelled.[/dim]")
|
|
107
|
+
raise typer.Exit(EXIT_CANCELLED)
|
|
108
|
+
except (typer.Exit, SystemExit):
|
|
109
|
+
# Let typer exits pass through
|
|
110
|
+
raise
|
|
111
|
+
except Exception as e:
|
|
112
|
+
if is_json_command_mode():
|
|
113
|
+
# JSON mode: emit structured error envelope for unexpected errors
|
|
114
|
+
from .core.exit_codes import EXIT_INTERNAL
|
|
115
|
+
from .json_output import build_error_envelope
|
|
116
|
+
from .output_mode import print_json
|
|
117
|
+
|
|
118
|
+
envelope = build_error_envelope(e)
|
|
119
|
+
print_json(envelope)
|
|
120
|
+
raise typer.Exit(EXIT_INTERNAL)
|
|
121
|
+
# Human mode: unexpected errors to stderr
|
|
122
|
+
if state.debug:
|
|
123
|
+
err_console.print_exception()
|
|
124
|
+
else:
|
|
125
|
+
err_console.print(
|
|
126
|
+
create_warning_panel(
|
|
127
|
+
"Unexpected Error",
|
|
128
|
+
str(e),
|
|
129
|
+
"Run with --debug for full traceback",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(5)
|
|
133
|
+
|
|
134
|
+
return cast(F, wrapper)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
# UI Helpers (Consistent Aesthetic)
|
|
139
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def render_responsive_table(
|
|
143
|
+
title: str,
|
|
144
|
+
columns: list[tuple[str, str]], # (header, style)
|
|
145
|
+
rows: list[list[str]],
|
|
146
|
+
wide_columns: list[tuple[str, str]] | None = None, # Extra columns for wide mode
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Render a table that adapts to terminal width.
|
|
149
|
+
|
|
150
|
+
Display base columns on narrow terminals, adding extra columns when
|
|
151
|
+
terminal width exceeds WIDE_MODE_THRESHOLD.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
title: Table title displayed above the table.
|
|
155
|
+
columns: Base columns as list of (header, style) tuples.
|
|
156
|
+
rows: Data rows where each row contains values for all columns
|
|
157
|
+
(base + wide). Extra values are ignored on narrow terminals.
|
|
158
|
+
wide_columns: Additional columns shown only on wide terminals.
|
|
159
|
+
"""
|
|
160
|
+
width = console.width
|
|
161
|
+
wide_mode = width >= WIDE_MODE_THRESHOLD
|
|
162
|
+
|
|
163
|
+
table = Table(
|
|
164
|
+
title=f"[bold cyan]{title}[/bold cyan]",
|
|
165
|
+
box=box.ROUNDED,
|
|
166
|
+
header_style="bold cyan",
|
|
167
|
+
expand=True,
|
|
168
|
+
show_lines=False,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Add base columns
|
|
172
|
+
for header, style in columns:
|
|
173
|
+
table.add_column(header, style=style)
|
|
174
|
+
|
|
175
|
+
# Add extra columns in wide mode
|
|
176
|
+
if wide_mode and wide_columns:
|
|
177
|
+
for header, style in wide_columns:
|
|
178
|
+
table.add_column(header, style=style)
|
|
179
|
+
|
|
180
|
+
# Add rows
|
|
181
|
+
for row in rows:
|
|
182
|
+
if wide_mode and wide_columns:
|
|
183
|
+
table.add_row(*row)
|
|
184
|
+
else:
|
|
185
|
+
# Truncate to base columns only
|
|
186
|
+
table.add_row(*row[: len(columns)])
|
|
187
|
+
|
|
188
|
+
console.print()
|
|
189
|
+
console.print(table)
|
|
190
|
+
console.print()
|