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.

Files changed (113) 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 +706 -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 +1454 -0
  16. scc_cli/cli_org.py +1428 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +892 -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 +604 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1074 -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 +1521 -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/adapter.py +74 -0
  46. scc_cli/marketplace/compute.py +377 -0
  47. scc_cli/marketplace/constants.py +87 -0
  48. scc_cli/marketplace/managed.py +135 -0
  49. scc_cli/marketplace/materialize.py +723 -0
  50. scc_cli/marketplace/normalize.py +548 -0
  51. scc_cli/marketplace/render.py +257 -0
  52. scc_cli/marketplace/resolve.py +459 -0
  53. scc_cli/marketplace/schema.py +506 -0
  54. scc_cli/marketplace/sync.py +260 -0
  55. scc_cli/marketplace/team_cache.py +195 -0
  56. scc_cli/marketplace/team_fetch.py +688 -0
  57. scc_cli/marketplace/trust.py +244 -0
  58. scc_cli/models/__init__.py +41 -0
  59. scc_cli/models/exceptions.py +273 -0
  60. scc_cli/models/plugin_audit.py +434 -0
  61. scc_cli/org_templates.py +269 -0
  62. scc_cli/output_mode.py +167 -0
  63. scc_cli/panels.py +113 -0
  64. scc_cli/platform.py +350 -0
  65. scc_cli/profiles.py +960 -0
  66. scc_cli/remote.py +443 -0
  67. scc_cli/schemas/__init__.py +1 -0
  68. scc_cli/schemas/org-v1.schema.json +456 -0
  69. scc_cli/schemas/team-config.v1.schema.json +163 -0
  70. scc_cli/sessions.py +425 -0
  71. scc_cli/setup.py +588 -0
  72. scc_cli/source_resolver.py +470 -0
  73. scc_cli/stats.py +378 -0
  74. scc_cli/stores/__init__.py +13 -0
  75. scc_cli/stores/exception_store.py +251 -0
  76. scc_cli/subprocess_utils.py +88 -0
  77. scc_cli/teams.py +382 -0
  78. scc_cli/templates/__init__.py +2 -0
  79. scc_cli/templates/org/__init__.py +0 -0
  80. scc_cli/templates/org/minimal.json +19 -0
  81. scc_cli/templates/org/reference.json +74 -0
  82. scc_cli/templates/org/strict.json +38 -0
  83. scc_cli/templates/org/teams.json +42 -0
  84. scc_cli/templates/statusline.sh +75 -0
  85. scc_cli/theme.py +348 -0
  86. scc_cli/ui/__init__.py +124 -0
  87. scc_cli/ui/branding.py +68 -0
  88. scc_cli/ui/chrome.py +395 -0
  89. scc_cli/ui/dashboard/__init__.py +62 -0
  90. scc_cli/ui/dashboard/_dashboard.py +677 -0
  91. scc_cli/ui/dashboard/loaders.py +395 -0
  92. scc_cli/ui/dashboard/models.py +184 -0
  93. scc_cli/ui/dashboard/orchestrator.py +390 -0
  94. scc_cli/ui/formatters.py +443 -0
  95. scc_cli/ui/gate.py +350 -0
  96. scc_cli/ui/help.py +157 -0
  97. scc_cli/ui/keys.py +538 -0
  98. scc_cli/ui/list_screen.py +431 -0
  99. scc_cli/ui/picker.py +700 -0
  100. scc_cli/ui/prompts.py +200 -0
  101. scc_cli/ui/wizard.py +675 -0
  102. scc_cli/update.py +680 -0
  103. scc_cli/utils/__init__.py +39 -0
  104. scc_cli/utils/fixit.py +264 -0
  105. scc_cli/utils/fuzzy.py +124 -0
  106. scc_cli/utils/locks.py +101 -0
  107. scc_cli/utils/ttl.py +376 -0
  108. scc_cli/validate.py +455 -0
  109. scc_cli-1.4.1.dist-info/METADATA +369 -0
  110. scc_cli-1.4.1.dist-info/RECORD +113 -0
  111. scc_cli-1.4.1.dist-info/WHEEL +4 -0
  112. scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
  113. 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)