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,528 @@
1
+ """Provide CLI commands for managing teams, configuration, and setup."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from .. import config, profiles, setup
8
+ from ..cli_common import console, handle_errors
9
+ from ..core.exit_codes import EXIT_USAGE
10
+ from ..panels import create_error_panel, create_info_panel
11
+ from ..source_resolver import ResolveError, resolve_source
12
+ from ..stores.exception_store import RepoStore, UserStore
13
+ from ..utils.ttl import format_relative
14
+
15
+ # ─────────────────────────────────────────────────────────────────────────────
16
+ # Config App
17
+ # ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ config_app = typer.Typer(
20
+ name="config",
21
+ help="Manage configuration and team profiles.",
22
+ no_args_is_help=False,
23
+ context_settings={"help_option_names": ["-h", "--help"]},
24
+ )
25
+
26
+
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+ # Setup Command
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+
31
+
32
+ @handle_errors
33
+ def setup_cmd(
34
+ quick: bool = typer.Option(False, "--quick", "-q", help="Quick setup with defaults"),
35
+ reset: bool = typer.Option(False, "--reset", help="Reset configuration"),
36
+ org: str | None = typer.Option(
37
+ None,
38
+ "--org",
39
+ help="Organization source (URL or shorthand like github:org/repo)",
40
+ ),
41
+ org_url: str | None = typer.Option(
42
+ None, "--org-url", help="Organization config URL (deprecated, use --org)"
43
+ ),
44
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile/team to select"),
45
+ team: str | None = typer.Option(
46
+ None, "--team", "-t", help="Team profile to select (alias for --profile)"
47
+ ),
48
+ auth: str | None = typer.Option(None, "--auth", help="Auth spec (env:VAR or command:CMD)"),
49
+ standalone: bool = typer.Option(
50
+ False, "--standalone", help="Standalone mode (no organization)"
51
+ ),
52
+ non_interactive: bool = typer.Option(
53
+ False,
54
+ "--non-interactive",
55
+ "--no-interactive",
56
+ help="Fail fast instead of prompting for missing setup inputs",
57
+ ),
58
+ ) -> None:
59
+ """Run initial setup wizard.
60
+
61
+ Examples:
62
+ scc setup # Interactive wizard
63
+ scc setup --standalone # Standalone mode
64
+ scc setup --org github:acme/config --profile dev # Non-interactive with shorthand
65
+ scc setup --org-url <url> --team dev # Non-interactive (legacy)
66
+ """
67
+ if reset:
68
+ setup.reset_setup(console)
69
+ return
70
+
71
+ # Handle --profile/--team alias (prefer --profile)
72
+ selected_profile = profile or team
73
+
74
+ # Handle --org/--org-url (prefer --org)
75
+ resolved_url: str | None = None
76
+ if org:
77
+ # Resolve shorthand to URL
78
+ result = resolve_source(org)
79
+ if isinstance(result, ResolveError):
80
+ console.print(
81
+ create_error_panel(
82
+ "Invalid Source",
83
+ result.message,
84
+ hint=result.suggestion or "",
85
+ )
86
+ )
87
+ raise typer.Exit(1)
88
+ resolved_url = result.resolved_url
89
+ elif org_url:
90
+ resolved_url = org_url
91
+
92
+ if non_interactive and not (resolved_url or standalone):
93
+ console.print(
94
+ create_error_panel(
95
+ "Missing Setup Inputs",
96
+ "Non-interactive setup requires --org or --standalone.",
97
+ hint="Provide --org <source> or use interactive setup without --non-interactive.",
98
+ )
99
+ )
100
+ raise typer.Exit(EXIT_USAGE)
101
+
102
+ # Non-interactive mode if org source or standalone specified
103
+ if resolved_url or standalone:
104
+ success = setup.run_non_interactive_setup(
105
+ console,
106
+ org_url=resolved_url,
107
+ team=selected_profile,
108
+ auth=auth,
109
+ standalone=standalone,
110
+ )
111
+ if not success:
112
+ raise typer.Exit(1)
113
+ return
114
+
115
+ # Run the setup wizard (--quick flag is a no-op for now, wizard handles all cases)
116
+ setup.run_setup_wizard(console)
117
+
118
+
119
+ # ─────────────────────────────────────────────────────────────────────────────
120
+ # Config Command
121
+ # ─────────────────────────────────────────────────────────────────────────────
122
+
123
+
124
+ @handle_errors
125
+ def config_cmd(
126
+ action: str = typer.Argument(None, help="Action: set, get, show, edit, explain"),
127
+ key: str = typer.Argument(None, help="Config key (for set/get, e.g. hooks.enabled)"),
128
+ value: str = typer.Argument(None, help="Value (for set only)"),
129
+ show: bool = typer.Option(False, "--show", help="Show current config"),
130
+ edit: bool = typer.Option(False, "--edit", help="Open config in editor"),
131
+ field: str | None = typer.Option(
132
+ None, "--field", help="Filter explain output to specific field (plugins, session, etc.)"
133
+ ),
134
+ workspace: str | None = typer.Option(
135
+ None, "--workspace", help="Workspace path for project config (default: current directory)"
136
+ ),
137
+ ) -> None:
138
+ """View or edit configuration.
139
+
140
+ Examples:
141
+ scc config --show # Show all config
142
+ scc config get selected_profile # Get specific key
143
+ scc config set hooks.enabled true # Set a value
144
+ scc config --edit # Open in editor
145
+ scc config explain # Explain effective config
146
+ scc config explain --field plugins # Explain only plugins
147
+ """
148
+ # Handle action-based commands
149
+ if action == "set":
150
+ if not key or value is None:
151
+ console.print("[red]Usage: scc config set <key> <value>[/red]")
152
+ raise typer.Exit(1)
153
+ _config_set(key, value)
154
+ return
155
+
156
+ if action == "get":
157
+ if not key:
158
+ console.print("[red]Usage: scc config get <key>[/red]")
159
+ raise typer.Exit(1)
160
+ _config_get(key)
161
+ return
162
+
163
+ if action == "explain":
164
+ _config_explain(field_filter=field, workspace_path=workspace)
165
+ return
166
+
167
+ # Handle --show and --edit flags
168
+ if show or action == "show":
169
+ cfg = config.load_user_config()
170
+ console.print(
171
+ create_info_panel(
172
+ "Configuration",
173
+ f"Current settings loaded from {config.CONFIG_FILE}",
174
+ )
175
+ )
176
+ console.print()
177
+ console.print_json(data=cfg)
178
+ elif edit or action == "edit":
179
+ config.open_in_editor()
180
+ else:
181
+ console.print(
182
+ create_info_panel(
183
+ "Configuration Help",
184
+ "Commands:\n scc config --show View current settings\n scc config --edit Edit in your editor\n scc config get <key> Get a specific value\n scc config set <key> <value> Set a value",
185
+ f"Config location: {config.CONFIG_FILE}",
186
+ )
187
+ )
188
+
189
+
190
+ def _config_set(key: str, value: str) -> None:
191
+ """Set a configuration value by dotted key path."""
192
+ cfg = config.load_user_config()
193
+
194
+ # Parse dotted key path (e.g., "hooks.enabled")
195
+ keys = key.split(".")
196
+ obj = cfg
197
+ for k in keys[:-1]:
198
+ if k not in obj:
199
+ obj[k] = {}
200
+ obj = obj[k]
201
+
202
+ # Parse value (handle booleans and numbers)
203
+ parsed_value: bool | int | str
204
+ if value.lower() == "true":
205
+ parsed_value = True
206
+ elif value.lower() == "false":
207
+ parsed_value = False
208
+ elif value.isdigit():
209
+ parsed_value = int(value)
210
+ else:
211
+ parsed_value = value
212
+
213
+ obj[keys[-1]] = parsed_value
214
+ config.save_user_config(cfg)
215
+ console.print(f"[green]✓ Set {key} = {parsed_value}[/green]")
216
+
217
+
218
+ def _config_get(key: str) -> None:
219
+ """Get a configuration value by dotted key path."""
220
+ cfg = config.load_user_config()
221
+
222
+ # Navigate dotted key path
223
+ keys = key.split(".")
224
+ obj = cfg
225
+ for k in keys:
226
+ if isinstance(obj, dict) and k in obj:
227
+ obj = obj[k]
228
+ else:
229
+ console.print(f"[yellow]Key '{key}' not found[/yellow]")
230
+ return
231
+
232
+ # Display value
233
+ if isinstance(obj, dict):
234
+ console.print_json(data=obj)
235
+ else:
236
+ console.print(str(obj))
237
+
238
+
239
+ def _config_explain(field_filter: str | None = None, workspace_path: str | None = None) -> None:
240
+ """Explain the effective configuration with source attribution.
241
+
242
+ Shows:
243
+ - Effective config values and where they came from
244
+ - Blocked items and the patterns that blocked them
245
+ - Denied additions and why they were denied
246
+ """
247
+ # Load org config
248
+ org_config = config.load_cached_org_config()
249
+ if not org_config:
250
+ console.print("[red]No organization config found. Run 'scc setup' first.[/red]")
251
+ raise typer.Exit(1)
252
+
253
+ # Get selected profile/team
254
+ team = config.get_selected_profile()
255
+ if not team:
256
+ console.print("[red]No team selected. Run 'scc team switch <name>' first.[/red]")
257
+ raise typer.Exit(1)
258
+
259
+ # Determine workspace path
260
+ ws_path = Path(workspace_path) if workspace_path else Path.cwd()
261
+
262
+ # Compute effective config
263
+ effective = profiles.compute_effective_config(
264
+ org_config=org_config,
265
+ team_name=team,
266
+ workspace_path=ws_path,
267
+ )
268
+
269
+ # Build output
270
+ console.print(
271
+ create_info_panel(
272
+ "Effective Configuration",
273
+ f"Organization: {org_config.get('organization', {}).get('name', 'Unknown')}",
274
+ f"Team: {team}",
275
+ )
276
+ )
277
+ console.print()
278
+
279
+ # Show decisions (config values with source attribution)
280
+ _render_config_decisions(effective, field_filter)
281
+
282
+ # Show blocked items
283
+ if effective.blocked_items and (not field_filter or field_filter == "blocked"):
284
+ _render_blocked_items(effective.blocked_items)
285
+
286
+ # Show denied additions
287
+ if effective.denied_additions and (not field_filter or field_filter == "denied"):
288
+ _render_denied_additions(effective.denied_additions)
289
+
290
+ # Show active exceptions
291
+ if not field_filter or field_filter == "exceptions":
292
+ expired_count = _render_active_exceptions()
293
+ if expired_count > 0:
294
+ console.print(
295
+ f"[dim]Note: {expired_count} expired local overrides "
296
+ f"(run `scc exceptions cleanup`)[/dim]"
297
+ )
298
+ console.print()
299
+
300
+
301
+ def _render_config_decisions(effective: profiles.EffectiveConfig, field_filter: str | None) -> None:
302
+ """Render config decisions grouped by field."""
303
+ # Group decisions by field
304
+ by_field: dict[str, list[profiles.ConfigDecision]] = {}
305
+ for decision in effective.decisions:
306
+ field = decision.field.split(".")[0] # Get top-level field
307
+ if field_filter and field != field_filter:
308
+ continue
309
+ if field not in by_field:
310
+ by_field[field] = []
311
+ by_field[field].append(decision)
312
+
313
+ # Also show effective values even if no explicit decisions
314
+ if not field_filter or field_filter == "plugins":
315
+ console.print("[bold cyan]Plugins[/bold cyan]")
316
+ if effective.plugins:
317
+ for plugin in sorted(effective.plugins):
318
+ # Find decision for this plugin
319
+ plugin_decision = next(
320
+ (d for d in effective.decisions if d.field == "plugins" and d.value == plugin),
321
+ None,
322
+ )
323
+ if plugin_decision:
324
+ console.print(
325
+ f" [green]✓[/green] {plugin} [dim](from {plugin_decision.source})[/dim]"
326
+ )
327
+ else:
328
+ console.print(f" [green]✓[/green] {plugin}")
329
+ # Plugin trust model note
330
+ console.print()
331
+ console.print(
332
+ " [dim]Note: Plugins may bundle .mcp.json MCP servers. "
333
+ "SCC does not inspect plugin contents; to restrict, block the plugin.[/dim]"
334
+ )
335
+ else:
336
+ console.print(" [dim]None configured[/dim]")
337
+ console.print()
338
+
339
+ if not field_filter or field_filter == "session":
340
+ console.print("[bold cyan]Session Config[/bold cyan]")
341
+ timeout = effective.session_config.timeout_hours or 8
342
+ auto_resume = effective.session_config.auto_resume
343
+ # Find decision for timeout
344
+ timeout_decision = next(
345
+ (d for d in effective.decisions if "timeout" in d.field.lower()),
346
+ None,
347
+ )
348
+ if timeout_decision:
349
+ console.print(f" timeout_hours: {timeout} [dim](from {timeout_decision.source})[/dim]")
350
+ else:
351
+ console.print(f" timeout_hours: {timeout} [dim](default)[/dim]")
352
+ console.print(f" auto_resume: {auto_resume}")
353
+ console.print()
354
+
355
+ if not field_filter or field_filter == "network":
356
+ console.print("[bold cyan]Network Policy[/bold cyan]")
357
+ policy = effective.network_policy or "default"
358
+ policy_decision = next(
359
+ (d for d in effective.decisions if d.field == "network_policy"),
360
+ None,
361
+ )
362
+ if policy_decision:
363
+ console.print(f" {policy} [dim](from {policy_decision.source})[/dim]")
364
+ else:
365
+ console.print(f" {policy}")
366
+ console.print()
367
+
368
+ if not field_filter or field_filter == "mcp_servers":
369
+ console.print("[bold cyan]MCP Servers[/bold cyan]")
370
+ if effective.mcp_servers:
371
+ for server in effective.mcp_servers:
372
+ # Find decision for this server
373
+ server_decision = next(
374
+ (
375
+ d
376
+ for d in effective.decisions
377
+ if d.field == "mcp_servers" and d.value == server.name
378
+ ),
379
+ None,
380
+ )
381
+ server_info = f"{server.name} ({server.type})"
382
+ if server_decision:
383
+ console.print(
384
+ f" [green]✓[/green] {server_info} [dim](from {server_decision.source})[/dim]"
385
+ )
386
+ else:
387
+ console.print(f" [green]✓[/green] {server_info}")
388
+ else:
389
+ console.print(" [dim]None configured[/dim]")
390
+ console.print()
391
+
392
+
393
+ def _render_blocked_items(blocked_items: list[profiles.BlockedItem]) -> None:
394
+ """Render blocked items with patterns and fix-it commands."""
395
+ from scc_cli.utils.fixit import generate_policy_exception_command
396
+
397
+ console.print("[bold red]Blocked Items[/bold red]")
398
+ for item in blocked_items:
399
+ console.print(
400
+ f" [red]✗[/red] [bold]{item.item}[/bold] [dim](blocked by pattern '{item.blocked_by}' from {item.source})[/dim]"
401
+ )
402
+ # Infer target type from source or pattern
403
+ target_type = _infer_target_type(item.item, item.source)
404
+ cmd = generate_policy_exception_command(item.item, target_type)
405
+ console.print(" [dim]To request exception (requires PR):[/dim]")
406
+ console.print(f" [cyan]{cmd}[/cyan]")
407
+ console.print()
408
+
409
+
410
+ def _infer_target_type(item: str, source: str) -> str:
411
+ """Infer target type from item name or source context.
412
+
413
+ Args:
414
+ item: The item name (plugin, server, image)
415
+ source: The source context (e.g., "org.security")
416
+
417
+ Returns:
418
+ One of "plugin", "mcp_server", or "base_image"
419
+ """
420
+ # Check for common patterns
421
+ item_lower = item.lower()
422
+
423
+ # Image patterns (contains : or @ for tags/digests, or common registries)
424
+ if (
425
+ ":" in item
426
+ or "@" in item
427
+ or any(reg in item_lower for reg in ["docker", "ghcr.io", "registry", ".io/", ".com/"])
428
+ ):
429
+ return "base_image"
430
+
431
+ # MCP server patterns (often have -api, -server, -mcp suffix or look like URLs)
432
+ if any(pattern in item_lower for pattern in ["-api", "-server", "-mcp", "/"]):
433
+ return "mcp_server"
434
+
435
+ # Default to plugin (most common case for blocked items)
436
+ return "plugin"
437
+
438
+
439
+ def _render_denied_additions(denied_additions: list[profiles.DelegationDenied]) -> None:
440
+ """Render denied additions with reasons and fix-it commands."""
441
+ from scc_cli.utils.fixit import generate_unblock_command
442
+
443
+ console.print("[bold yellow]Denied Additions[/bold yellow]")
444
+ for denied in denied_additions:
445
+ console.print(
446
+ f" [yellow]⚠[/yellow] [bold]{denied.item}[/bold] [dim](requested by {denied.requested_by}: {denied.reason})[/dim]"
447
+ )
448
+ # Infer target type from item name
449
+ target_type = _infer_target_type(denied.item, denied.requested_by)
450
+ cmd = generate_unblock_command(denied.item, target_type)
451
+ console.print(" [dim]To unblock locally:[/dim]")
452
+ console.print(f" [cyan]{cmd}[/cyan]")
453
+ console.print()
454
+
455
+
456
+ def _render_active_exceptions() -> int:
457
+ """Render active exceptions from user and repo stores.
458
+
459
+ Returns the count of expired exceptions found (for user notification).
460
+ """
461
+ from datetime import datetime, timezone
462
+
463
+ from ..models.exceptions import Exception as SccException
464
+
465
+ # Load exceptions from both stores
466
+ user_store = UserStore()
467
+ repo_store = RepoStore(Path.cwd())
468
+
469
+ user_file = user_store.read()
470
+ repo_file = repo_store.read()
471
+
472
+ # Filter active exceptions
473
+ now = datetime.now(timezone.utc)
474
+ active: list[tuple[str, SccException]] = [] # (source, exception)
475
+ expired_count = 0
476
+
477
+ for exc in user_file.exceptions:
478
+ try:
479
+ expires = datetime.fromisoformat(exc.expires_at.replace("Z", "+00:00"))
480
+ if expires > now:
481
+ active.append(("user", exc))
482
+ else:
483
+ expired_count += 1
484
+ except (ValueError, AttributeError):
485
+ expired_count += 1
486
+
487
+ for exc in repo_file.exceptions:
488
+ try:
489
+ expires = datetime.fromisoformat(exc.expires_at.replace("Z", "+00:00"))
490
+ if expires > now:
491
+ active.append(("repo", exc))
492
+ else:
493
+ expired_count += 1
494
+ except (ValueError, AttributeError):
495
+ expired_count += 1
496
+
497
+ if not active:
498
+ return expired_count
499
+
500
+ console.print("[bold cyan]Active Exceptions[/bold cyan]")
501
+
502
+ for source, exc in active:
503
+ # Format the exception target
504
+ targets: list[str] = []
505
+ if exc.allow.plugins:
506
+ targets.extend(f"plugin:{p}" for p in exc.allow.plugins)
507
+ if exc.allow.mcp_servers:
508
+ targets.extend(f"mcp:{s}" for s in exc.allow.mcp_servers)
509
+ if exc.allow.base_images:
510
+ targets.extend(f"image:{i}" for i in exc.allow.base_images)
511
+
512
+ target_str = ", ".join(targets) if targets else "none"
513
+
514
+ # Calculate expires_in
515
+ try:
516
+ expires = datetime.fromisoformat(exc.expires_at.replace("Z", "+00:00"))
517
+ expires_in = format_relative(expires)
518
+ except (ValueError, AttributeError):
519
+ expires_in = "unknown"
520
+
521
+ scope_badge = "[dim][local][/dim]" if exc.scope == "local" else "[cyan][policy][/cyan]"
522
+ console.print(
523
+ f" {scope_badge} {exc.id} {target_str} "
524
+ f"[dim]expires in {expires_in}[/dim] [dim](source: {source})[/dim]"
525
+ )
526
+
527
+ console.print()
528
+ return expired_count