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.

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