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