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.
- 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 +706 -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 +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -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 +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -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 +1521 -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/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 +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -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 +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/sessions.py +425 -0
- scc_cli/setup.py +588 -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 +382 -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 +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -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 +538 -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 +675 -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.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
scc_cli/cli_admin.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""Provide CLI commands for system administration: doctor, update, statusline, status, and stats."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.status import Status
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from . import config, docker, doctor, stats
|
|
15
|
+
from .cli_common import console, handle_errors
|
|
16
|
+
from .docker.core import ContainerInfo
|
|
17
|
+
from .json_command import json_command
|
|
18
|
+
from .json_output import build_envelope
|
|
19
|
+
from .kinds import Kind
|
|
20
|
+
from .output_mode import is_json_mode, json_output_mode, print_json, set_pretty_mode
|
|
21
|
+
from .panels import create_info_panel, create_success_panel, create_warning_panel
|
|
22
|
+
from .theme import Spinners
|
|
23
|
+
|
|
24
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
# Statusline Installation Helper
|
|
26
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install_statusline() -> bool:
|
|
30
|
+
"""Install the statusline script into the Docker sandbox volume.
|
|
31
|
+
|
|
32
|
+
This is a pure helper function that performs the statusline installation
|
|
33
|
+
without any console output. Can be called from CLI commands or the
|
|
34
|
+
dashboard orchestrator.
|
|
35
|
+
|
|
36
|
+
The installation injects:
|
|
37
|
+
1. The statusline.sh script into /mnt/claude-data/scc-statusline.sh
|
|
38
|
+
2. Updates settings.json with statusLine configuration
|
|
39
|
+
|
|
40
|
+
Works cross-platform (Windows, macOS, Linux) as it uses Docker volume
|
|
41
|
+
injection which runs an Alpine container to write files.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if installation succeeded, False otherwise.
|
|
45
|
+
"""
|
|
46
|
+
# Get the status line script from package resources
|
|
47
|
+
try:
|
|
48
|
+
template_files = importlib.resources.files("scc_cli.templates")
|
|
49
|
+
script_content = (template_files / "statusline.sh").read_text()
|
|
50
|
+
except (FileNotFoundError, TypeError):
|
|
51
|
+
# Fallback: read from relative path during development
|
|
52
|
+
dev_path = Path(__file__).parent / "templates" / "statusline.sh"
|
|
53
|
+
if dev_path.exists():
|
|
54
|
+
script_content = dev_path.read_text()
|
|
55
|
+
else:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
# Inject script into Docker volume (will be at /mnt/claude-data/scc-statusline.sh)
|
|
59
|
+
script_ok = docker.inject_file_to_sandbox_volume("scc-statusline.sh", script_content)
|
|
60
|
+
if not script_ok:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
# Get existing settings from Docker volume (if any)
|
|
64
|
+
existing_settings = docker.get_sandbox_settings() or {}
|
|
65
|
+
|
|
66
|
+
# Add statusline config (path inside container)
|
|
67
|
+
existing_settings["statusLine"] = {
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "/mnt/claude-data/scc-statusline.sh",
|
|
70
|
+
"padding": 0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Inject settings into Docker volume
|
|
74
|
+
settings_ok = docker.inject_file_to_sandbox_volume(
|
|
75
|
+
"settings.json", json.dumps(existing_settings, indent=2)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return script_ok and settings_ok
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
# Admin App
|
|
83
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
admin_app = typer.Typer(
|
|
86
|
+
name="admin",
|
|
87
|
+
help="System administration commands.",
|
|
88
|
+
no_args_is_help=False,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
# Status Command - Pure Function
|
|
94
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_status_data(
|
|
98
|
+
cfg: dict[str, Any],
|
|
99
|
+
org: dict[str, Any] | None,
|
|
100
|
+
running_containers: list[ContainerInfo],
|
|
101
|
+
workspace_path: Path | None = None,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""Build status data structure from configuration and state.
|
|
104
|
+
|
|
105
|
+
This is a pure function that assembles all status information.
|
|
106
|
+
No I/O operations - just data transformation.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
cfg: User configuration dict
|
|
110
|
+
org: Organization configuration dict (may be None)
|
|
111
|
+
running_containers: List of running container info
|
|
112
|
+
workspace_path: Current workspace path (optional)
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Status data dict suitable for JSON output or human display
|
|
116
|
+
"""
|
|
117
|
+
# Organization info
|
|
118
|
+
org_source = cfg.get("organization_source") or {}
|
|
119
|
+
org_url = org_source.get("url")
|
|
120
|
+
org_name = org.get("name") if org else None
|
|
121
|
+
|
|
122
|
+
organization = {
|
|
123
|
+
"name": org_name,
|
|
124
|
+
"configured": bool(org_url),
|
|
125
|
+
"source_url": org_url,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Team info
|
|
129
|
+
team_name = cfg.get("selected_profile")
|
|
130
|
+
team_details: dict[str, Any] = {"name": team_name}
|
|
131
|
+
|
|
132
|
+
# Look up delegation info if org config available
|
|
133
|
+
if org and team_name:
|
|
134
|
+
# Profiles are at TOP LEVEL of org_config as a DICT (not under "organization")
|
|
135
|
+
profiles = org.get("profiles", {})
|
|
136
|
+
profile = profiles.get(team_name)
|
|
137
|
+
if profile:
|
|
138
|
+
delegation = profile.get("delegation", {})
|
|
139
|
+
team_details["delegation"] = {
|
|
140
|
+
"allow_additional_plugins": delegation.get("allow_additional_plugins", False),
|
|
141
|
+
"allow_additional_mcp_servers": delegation.get(
|
|
142
|
+
"allow_additional_mcp_servers", False
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Session info
|
|
147
|
+
session: dict[str, Any] = {
|
|
148
|
+
"active": len(running_containers) > 0,
|
|
149
|
+
"count": len(running_containers),
|
|
150
|
+
"containers": [],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for container in running_containers:
|
|
154
|
+
session["containers"].append(
|
|
155
|
+
{
|
|
156
|
+
"name": container.name,
|
|
157
|
+
"status": container.status,
|
|
158
|
+
"workspace": container.workspace,
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Workspace info
|
|
163
|
+
workspace: dict[str, Any] = {"path": None, "has_scc_yaml": False}
|
|
164
|
+
if workspace_path:
|
|
165
|
+
workspace["path"] = str(workspace_path)
|
|
166
|
+
scc_yaml = workspace_path / ".scc.yaml"
|
|
167
|
+
workspace["has_scc_yaml"] = scc_yaml.exists()
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"organization": organization,
|
|
171
|
+
"team": team_details,
|
|
172
|
+
"session": session,
|
|
173
|
+
"workspace": workspace,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
# Status Command
|
|
179
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@json_command(Kind.STATUS)
|
|
183
|
+
@handle_errors
|
|
184
|
+
def status_cmd(
|
|
185
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed information"),
|
|
186
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON envelope"),
|
|
187
|
+
pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""Show current SCC configuration status.
|
|
190
|
+
|
|
191
|
+
Displays organization, team, workspace, and session information
|
|
192
|
+
in a concise format. Use --verbose for detailed governance info.
|
|
193
|
+
|
|
194
|
+
Examples:
|
|
195
|
+
scc status # Quick status overview
|
|
196
|
+
scc status --verbose # Include delegation details
|
|
197
|
+
scc status --json # Output as JSON
|
|
198
|
+
"""
|
|
199
|
+
cfg = config.load_user_config()
|
|
200
|
+
org_config = config.load_cached_org_config()
|
|
201
|
+
|
|
202
|
+
# Get running containers
|
|
203
|
+
running_containers = docker.list_running_sandboxes()
|
|
204
|
+
|
|
205
|
+
# Get current workspace
|
|
206
|
+
workspace_path = Path.cwd()
|
|
207
|
+
|
|
208
|
+
# Build status data
|
|
209
|
+
data = build_status_data(cfg, org_config, running_containers, workspace_path)
|
|
210
|
+
|
|
211
|
+
# Human-readable output
|
|
212
|
+
if not is_json_mode():
|
|
213
|
+
_render_status_human(data, verbose=verbose)
|
|
214
|
+
|
|
215
|
+
return data
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _render_status_human(data: dict[str, Any], verbose: bool = False) -> None:
|
|
219
|
+
"""Render status data as human-readable output."""
|
|
220
|
+
lines = []
|
|
221
|
+
|
|
222
|
+
# Organization
|
|
223
|
+
org = data["organization"]
|
|
224
|
+
if org["name"]:
|
|
225
|
+
lines.append(f"[bold]Organization:[/bold] {org['name']}")
|
|
226
|
+
elif org["configured"]:
|
|
227
|
+
lines.append("[bold]Organization:[/bold] [dim](configured, no name)[/dim]")
|
|
228
|
+
else:
|
|
229
|
+
lines.append("[bold]Organization:[/bold] [dim]Not configured[/dim]")
|
|
230
|
+
|
|
231
|
+
# Team
|
|
232
|
+
team = data["team"]
|
|
233
|
+
if team["name"]:
|
|
234
|
+
team_line = f"[bold]Team:[/bold] [cyan]{team['name']}[/cyan]"
|
|
235
|
+
if "delegation" in team and verbose:
|
|
236
|
+
delegation = team["delegation"]
|
|
237
|
+
perms = []
|
|
238
|
+
if delegation.get("allow_additional_plugins"):
|
|
239
|
+
perms.append("plugins")
|
|
240
|
+
if delegation.get("allow_additional_mcp_servers"):
|
|
241
|
+
perms.append("mcp-servers")
|
|
242
|
+
if perms:
|
|
243
|
+
team_line += f" [dim](can add: {', '.join(perms)})[/dim]"
|
|
244
|
+
else:
|
|
245
|
+
team_line += " [dim](no additional permissions)[/dim]"
|
|
246
|
+
lines.append(team_line)
|
|
247
|
+
else:
|
|
248
|
+
lines.append("[bold]Team:[/bold] [dim]None selected[/dim]")
|
|
249
|
+
|
|
250
|
+
# Workspace
|
|
251
|
+
workspace = data["workspace"]
|
|
252
|
+
if workspace["path"]:
|
|
253
|
+
ws_line = f"[bold]Workspace:[/bold] {workspace['path']}"
|
|
254
|
+
if workspace["has_scc_yaml"]:
|
|
255
|
+
ws_line += " [green](.scc.yaml found)[/green]"
|
|
256
|
+
lines.append(ws_line)
|
|
257
|
+
|
|
258
|
+
# Session
|
|
259
|
+
session = data["session"]
|
|
260
|
+
if session["active"]:
|
|
261
|
+
count = session["count"]
|
|
262
|
+
session_word = "session" if count == 1 else "sessions"
|
|
263
|
+
lines.append(f"[bold]Session:[/bold] [green]{count} active {session_word}[/green]")
|
|
264
|
+
if verbose and session["containers"]:
|
|
265
|
+
for container in session["containers"]:
|
|
266
|
+
lines.append(f" [dim]• {container['name']} ({container['status']})[/dim]")
|
|
267
|
+
else:
|
|
268
|
+
lines.append("[bold]Session:[/bold] [dim]No active sessions[/dim]")
|
|
269
|
+
|
|
270
|
+
# Verbose: show source URL
|
|
271
|
+
if verbose and org["source_url"]:
|
|
272
|
+
lines.append("")
|
|
273
|
+
lines.append(f"[dim]Source: {org['source_url']}[/dim]")
|
|
274
|
+
|
|
275
|
+
# Print as panel
|
|
276
|
+
content = "\n".join(lines)
|
|
277
|
+
panel = Panel(
|
|
278
|
+
content,
|
|
279
|
+
title="[bold cyan]SCC Status[/bold cyan]",
|
|
280
|
+
border_style="cyan",
|
|
281
|
+
padding=(1, 2),
|
|
282
|
+
)
|
|
283
|
+
console.print()
|
|
284
|
+
console.print(panel)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
288
|
+
# Doctor Command
|
|
289
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@handle_errors
|
|
293
|
+
def doctor_cmd(
|
|
294
|
+
workspace: str | None = typer.Argument(None, help="Optional workspace to check"),
|
|
295
|
+
quick: bool = typer.Option(False, "--quick", "-q", help="Quick status only"),
|
|
296
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
297
|
+
pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Check prerequisites and system health."""
|
|
300
|
+
workspace_path = Path(workspace).expanduser().resolve() if workspace else None
|
|
301
|
+
|
|
302
|
+
# --pretty implies --json
|
|
303
|
+
if pretty:
|
|
304
|
+
json_output = True
|
|
305
|
+
set_pretty_mode(True)
|
|
306
|
+
|
|
307
|
+
if json_output:
|
|
308
|
+
with json_output_mode():
|
|
309
|
+
result = doctor.run_doctor(workspace_path)
|
|
310
|
+
data = doctor.build_doctor_json_data(result)
|
|
311
|
+
envelope = build_envelope(Kind.DOCTOR_REPORT, data=data, ok=result.all_ok)
|
|
312
|
+
print_json(envelope)
|
|
313
|
+
if not result.all_ok:
|
|
314
|
+
raise typer.Exit(3) # Prerequisites failed
|
|
315
|
+
raise typer.Exit(0)
|
|
316
|
+
|
|
317
|
+
with Status("[cyan]Running health checks...[/cyan]", console=console, spinner=Spinners.DEFAULT):
|
|
318
|
+
result = doctor.run_doctor(workspace_path)
|
|
319
|
+
|
|
320
|
+
if quick:
|
|
321
|
+
doctor.render_quick_status(console, result)
|
|
322
|
+
else:
|
|
323
|
+
doctor.render_doctor_results(console, result)
|
|
324
|
+
|
|
325
|
+
# Return proper exit code
|
|
326
|
+
if not result.all_ok:
|
|
327
|
+
raise typer.Exit(3) # Prerequisites failed
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
331
|
+
# Update Command
|
|
332
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@handle_errors
|
|
336
|
+
def update_cmd(
|
|
337
|
+
force: bool = typer.Option(False, "--force", "-f", help="Force check even if recently checked"),
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Check for updates to scc-cli CLI and organization config."""
|
|
340
|
+
from . import update as update_module
|
|
341
|
+
|
|
342
|
+
cfg = config.load_config()
|
|
343
|
+
|
|
344
|
+
with Status("[cyan]Checking for updates...[/cyan]", console=console, spinner=Spinners.NETWORK):
|
|
345
|
+
result = update_module.check_all_updates(cfg, force=force)
|
|
346
|
+
|
|
347
|
+
# Render detailed update status panel
|
|
348
|
+
update_module.render_update_status_panel(console, result)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
352
|
+
# Statusline Command
|
|
353
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@handle_errors
|
|
357
|
+
def statusline_cmd(
|
|
358
|
+
install: bool = typer.Option(
|
|
359
|
+
False, "--install", "-i", help="Install the SCC status line script"
|
|
360
|
+
),
|
|
361
|
+
uninstall: bool = typer.Option(
|
|
362
|
+
False, "--uninstall", help="Remove the status line configuration"
|
|
363
|
+
),
|
|
364
|
+
show: bool = typer.Option(False, "--show", "-s", help="Show current status line config"),
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Configure Claude Code status line to show git worktree info.
|
|
367
|
+
|
|
368
|
+
The status line displays: Model | Git branch/worktree | Context usage | Cost
|
|
369
|
+
|
|
370
|
+
Examples:
|
|
371
|
+
scc statusline --install # Install the SCC status line
|
|
372
|
+
scc statusline --show # Show current configuration
|
|
373
|
+
scc statusline --uninstall # Remove status line config
|
|
374
|
+
"""
|
|
375
|
+
if show:
|
|
376
|
+
# Show current configuration from Docker sandbox volume
|
|
377
|
+
with Status(
|
|
378
|
+
"[cyan]Reading Docker sandbox settings...[/cyan]",
|
|
379
|
+
console=console,
|
|
380
|
+
spinner=Spinners.DOCKER,
|
|
381
|
+
):
|
|
382
|
+
settings = docker.get_sandbox_settings()
|
|
383
|
+
|
|
384
|
+
if settings and "statusLine" in settings:
|
|
385
|
+
console.print(
|
|
386
|
+
create_info_panel(
|
|
387
|
+
"Status Line Configuration (Docker Sandbox)",
|
|
388
|
+
f"Script: {settings['statusLine'].get('command', 'Not set')}",
|
|
389
|
+
"Run 'scc statusline --uninstall' to remove",
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
elif settings:
|
|
393
|
+
console.print(
|
|
394
|
+
create_info_panel(
|
|
395
|
+
"No Status Line",
|
|
396
|
+
"Status line is not configured in Docker sandbox.",
|
|
397
|
+
"Run 'scc statusline --install' to set it up",
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
console.print(
|
|
402
|
+
create_info_panel(
|
|
403
|
+
"No Configuration",
|
|
404
|
+
"Docker sandbox settings.json does not exist yet.",
|
|
405
|
+
"Run 'scc statusline --install' to create it",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
if uninstall:
|
|
411
|
+
# Remove status line configuration from Docker sandbox
|
|
412
|
+
with Status(
|
|
413
|
+
"[cyan]Removing statusline from Docker sandbox...[/cyan]",
|
|
414
|
+
console=console,
|
|
415
|
+
spinner=Spinners.DOCKER,
|
|
416
|
+
):
|
|
417
|
+
# Get existing settings
|
|
418
|
+
existing_settings = docker.get_sandbox_settings()
|
|
419
|
+
|
|
420
|
+
if existing_settings and "statusLine" in existing_settings:
|
|
421
|
+
del existing_settings["statusLine"]
|
|
422
|
+
# Write updated settings back
|
|
423
|
+
docker.inject_file_to_sandbox_volume(
|
|
424
|
+
"settings.json", json.dumps(existing_settings, indent=2)
|
|
425
|
+
)
|
|
426
|
+
console.print(
|
|
427
|
+
create_success_panel(
|
|
428
|
+
"Status Line Removed (Docker Sandbox)",
|
|
429
|
+
{"Settings": "Updated"},
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
else:
|
|
433
|
+
console.print(
|
|
434
|
+
create_info_panel(
|
|
435
|
+
"Nothing to Remove",
|
|
436
|
+
"Status line was not configured in Docker sandbox.",
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
if install:
|
|
442
|
+
# SCC philosophy: Everything stays in Docker sandbox, not on host
|
|
443
|
+
# Inject statusline script and settings into Docker sandbox volume
|
|
444
|
+
|
|
445
|
+
with Status(
|
|
446
|
+
"[cyan]Injecting statusline into Docker sandbox...[/cyan]",
|
|
447
|
+
console=console,
|
|
448
|
+
spinner=Spinners.DOCKER,
|
|
449
|
+
):
|
|
450
|
+
success = install_statusline()
|
|
451
|
+
|
|
452
|
+
if success:
|
|
453
|
+
console.print(
|
|
454
|
+
create_success_panel(
|
|
455
|
+
"Status Line Installed (Docker Sandbox)",
|
|
456
|
+
{
|
|
457
|
+
"Script": "/mnt/claude-data/scc-statusline.sh",
|
|
458
|
+
"Settings": "/mnt/claude-data/settings.json",
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
console.print()
|
|
463
|
+
console.print(
|
|
464
|
+
"[dim]The status line shows: "
|
|
465
|
+
"[bold]Model[/bold] | [cyan]🌿 branch[/cyan] or [magenta]⎇ worktree[/magenta]:branch | "
|
|
466
|
+
"[green]Ctx %[/green] | [yellow]$cost[/yellow][/dim]"
|
|
467
|
+
)
|
|
468
|
+
console.print("[dim]Restart Claude Code sandbox to see the changes.[/dim]")
|
|
469
|
+
else:
|
|
470
|
+
console.print(
|
|
471
|
+
create_warning_panel(
|
|
472
|
+
"Installation Failed",
|
|
473
|
+
"Could not inject statusline into Docker sandbox volume.",
|
|
474
|
+
"Ensure Docker Desktop is running",
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
raise typer.Exit(1)
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
# No flags - show help
|
|
481
|
+
console.print(
|
|
482
|
+
create_info_panel(
|
|
483
|
+
"Status Line",
|
|
484
|
+
"Configure a custom status line for Claude Code.",
|
|
485
|
+
"Use --install to set up, --show to view, --uninstall to remove",
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
491
|
+
# Stats Sub-App
|
|
492
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
stats_app = typer.Typer(
|
|
495
|
+
name="stats",
|
|
496
|
+
help="View and export usage statistics.",
|
|
497
|
+
no_args_is_help=False,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@stats_app.callback(invoke_without_command=True)
|
|
502
|
+
@handle_errors
|
|
503
|
+
def stats_cmd(
|
|
504
|
+
ctx: typer.Context,
|
|
505
|
+
days: int | None = typer.Option(None, "--days", "-d", help="Filter to last N days"),
|
|
506
|
+
) -> None:
|
|
507
|
+
"""View your usage statistics.
|
|
508
|
+
|
|
509
|
+
Shows session counts, duration, and per-project breakdown.
|
|
510
|
+
|
|
511
|
+
Examples:
|
|
512
|
+
scc stats # Show all-time stats
|
|
513
|
+
scc stats --days 7 # Show last 7 days
|
|
514
|
+
scc stats export --json # Export as JSON
|
|
515
|
+
"""
|
|
516
|
+
# If a subcommand was invoked, don't run the default
|
|
517
|
+
if ctx.invoked_subcommand is not None:
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
report = stats.get_stats(days=days)
|
|
521
|
+
|
|
522
|
+
# Handle empty stats
|
|
523
|
+
if report.total_sessions == 0:
|
|
524
|
+
console.print(
|
|
525
|
+
create_info_panel(
|
|
526
|
+
"Usage Statistics",
|
|
527
|
+
"No sessions recorded yet.",
|
|
528
|
+
"Run 'scc start' to begin tracking usage",
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
# Build summary panel
|
|
534
|
+
duration_hours = report.total_duration_minutes // 60
|
|
535
|
+
duration_mins = report.total_duration_minutes % 60
|
|
536
|
+
|
|
537
|
+
summary_lines = [
|
|
538
|
+
f"Total sessions: {report.total_sessions}",
|
|
539
|
+
f"Total duration: {duration_hours}h {duration_mins}m",
|
|
540
|
+
]
|
|
541
|
+
if report.incomplete_sessions > 0:
|
|
542
|
+
summary_lines.append(f"Incomplete sessions: {report.incomplete_sessions}")
|
|
543
|
+
|
|
544
|
+
period_str = ""
|
|
545
|
+
if days is not None:
|
|
546
|
+
period_str = f"Last {days} days"
|
|
547
|
+
else:
|
|
548
|
+
period_str = "All time"
|
|
549
|
+
|
|
550
|
+
console.print(
|
|
551
|
+
create_info_panel(
|
|
552
|
+
f"Usage Statistics ({period_str})",
|
|
553
|
+
"\n".join(summary_lines),
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Show per-project breakdown if available
|
|
558
|
+
if report.by_project:
|
|
559
|
+
console.print()
|
|
560
|
+
table = Table(title="Per-Project Breakdown", box=box.SIMPLE)
|
|
561
|
+
table.add_column("Project", style="cyan")
|
|
562
|
+
table.add_column("Sessions", justify="right")
|
|
563
|
+
table.add_column("Duration", justify="right")
|
|
564
|
+
|
|
565
|
+
for project, data in report.by_project.items():
|
|
566
|
+
proj_hours = data["duration_minutes"] // 60
|
|
567
|
+
proj_mins = data["duration_minutes"] % 60
|
|
568
|
+
table.add_row(
|
|
569
|
+
project,
|
|
570
|
+
str(data["sessions"]),
|
|
571
|
+
f"{proj_hours}h {proj_mins}m",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
console.print(table)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@stats_app.command(name="export")
|
|
578
|
+
@handle_errors
|
|
579
|
+
def stats_export_cmd(
|
|
580
|
+
json_format: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
581
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="Export raw events"),
|
|
582
|
+
days: int | None = typer.Option(None, "--days", "-d", help="Filter to last N days"),
|
|
583
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output file path"),
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Export statistics as JSON.
|
|
586
|
+
|
|
587
|
+
Examples:
|
|
588
|
+
scc stats export --json # Export aggregated stats
|
|
589
|
+
scc stats export --raw # Export raw event data
|
|
590
|
+
scc stats export --json -o stats.json # Export to file
|
|
591
|
+
"""
|
|
592
|
+
import json as json_module
|
|
593
|
+
|
|
594
|
+
if raw:
|
|
595
|
+
# Export raw events
|
|
596
|
+
events = stats.read_usage_events()
|
|
597
|
+
result = json_module.dumps(events, indent=2)
|
|
598
|
+
else:
|
|
599
|
+
# Export aggregated stats
|
|
600
|
+
report = stats.get_stats(days=days)
|
|
601
|
+
result = json_module.dumps(report.to_dict(), indent=2)
|
|
602
|
+
|
|
603
|
+
if output:
|
|
604
|
+
output.write_text(result)
|
|
605
|
+
console.print(
|
|
606
|
+
create_success_panel(
|
|
607
|
+
"Stats Exported",
|
|
608
|
+
{"Output file": str(output)},
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
# Print to stdout
|
|
613
|
+
print(result)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@stats_app.command(name="aggregate")
|
|
617
|
+
@handle_errors
|
|
618
|
+
def stats_aggregate_cmd(
|
|
619
|
+
files: list[Path] = typer.Argument(
|
|
620
|
+
None, help="Stats JSON files to aggregate (supports glob patterns)"
|
|
621
|
+
),
|
|
622
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Output file path"),
|
|
623
|
+
) -> None:
|
|
624
|
+
"""Aggregate multiple stats files.
|
|
625
|
+
|
|
626
|
+
Useful for team leads to combine exported stats from team members.
|
|
627
|
+
|
|
628
|
+
Examples:
|
|
629
|
+
scc stats aggregate stats1.json stats2.json
|
|
630
|
+
scc stats aggregate stats-*.json --output team-stats.json
|
|
631
|
+
"""
|
|
632
|
+
import glob
|
|
633
|
+
import json as json_module
|
|
634
|
+
|
|
635
|
+
if not files:
|
|
636
|
+
console.print("[red]Error: No input files provided[/red]")
|
|
637
|
+
raise typer.Exit(1)
|
|
638
|
+
|
|
639
|
+
# Expand glob patterns
|
|
640
|
+
expanded_files: list[Path] = []
|
|
641
|
+
for file_pattern in files:
|
|
642
|
+
pattern_str = str(file_pattern)
|
|
643
|
+
if "*" in pattern_str or "?" in pattern_str:
|
|
644
|
+
matched = glob.glob(pattern_str)
|
|
645
|
+
if matched:
|
|
646
|
+
expanded_files.extend(Path(m) for m in matched)
|
|
647
|
+
else:
|
|
648
|
+
console.print(f"[yellow]Warning: No files matched pattern '{pattern_str}'[/yellow]")
|
|
649
|
+
else:
|
|
650
|
+
expanded_files.append(file_pattern)
|
|
651
|
+
|
|
652
|
+
if not expanded_files:
|
|
653
|
+
console.print("[red]Error: No files found to aggregate[/red]")
|
|
654
|
+
raise typer.Exit(1)
|
|
655
|
+
|
|
656
|
+
# Read and aggregate
|
|
657
|
+
aggregated: dict[str, Any] = {
|
|
658
|
+
"total_sessions": 0,
|
|
659
|
+
"total_duration_minutes": 0,
|
|
660
|
+
"incomplete_sessions": 0,
|
|
661
|
+
"by_project": {},
|
|
662
|
+
"files_aggregated": [],
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
for file_path in expanded_files:
|
|
666
|
+
if not file_path.exists():
|
|
667
|
+
console.print(f"[red]Error: File not found: {file_path}[/red]")
|
|
668
|
+
raise typer.Exit(1)
|
|
669
|
+
|
|
670
|
+
try:
|
|
671
|
+
data = json_module.loads(file_path.read_text())
|
|
672
|
+
except json_module.JSONDecodeError:
|
|
673
|
+
console.print(f"[red]Error: Invalid JSON in file: {file_path}[/red]")
|
|
674
|
+
raise typer.Exit(1)
|
|
675
|
+
|
|
676
|
+
# Aggregate totals
|
|
677
|
+
aggregated["total_sessions"] += data.get("total_sessions", 0)
|
|
678
|
+
aggregated["total_duration_minutes"] += data.get("total_duration_minutes", 0)
|
|
679
|
+
aggregated["incomplete_sessions"] += data.get("incomplete_sessions", 0)
|
|
680
|
+
aggregated["files_aggregated"].append(str(file_path))
|
|
681
|
+
|
|
682
|
+
# Merge by_project
|
|
683
|
+
for project, proj_data in data.get("by_project", {}).items():
|
|
684
|
+
if project not in aggregated["by_project"]:
|
|
685
|
+
aggregated["by_project"][project] = {"sessions": 0, "duration_minutes": 0}
|
|
686
|
+
aggregated["by_project"][project]["sessions"] += proj_data.get("sessions", 0)
|
|
687
|
+
aggregated["by_project"][project]["duration_minutes"] += proj_data.get(
|
|
688
|
+
"duration_minutes", 0
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
result = json_module.dumps(aggregated, indent=2)
|
|
692
|
+
|
|
693
|
+
if output:
|
|
694
|
+
output.write_text(result)
|
|
695
|
+
console.print(
|
|
696
|
+
create_success_panel(
|
|
697
|
+
"Stats Aggregated",
|
|
698
|
+
{
|
|
699
|
+
"Files processed": str(len(expanded_files)),
|
|
700
|
+
"Total sessions": str(aggregated["total_sessions"]),
|
|
701
|
+
"Output file": str(output),
|
|
702
|
+
},
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
print(result)
|