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_team.py ADDED
@@ -0,0 +1,858 @@
1
+ """
2
+ Define team management commands for SCC CLI.
3
+
4
+ Provide structured team management:
5
+ - scc team list - List available teams
6
+ - scc team current - Show current team
7
+ - scc team switch - Switch to a different team (interactive picker)
8
+ - scc team info - Show detailed team information
9
+ - scc team validate - Validate team configuration (plugins, security, cache)
10
+
11
+ All commands support --json output with proper envelopes.
12
+ """
13
+
14
+ from typing import Any
15
+
16
+ import typer
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+
20
+ from . import config, teams
21
+ from .cli_common import console, handle_errors, render_responsive_table
22
+ from .json_command import json_command
23
+ from .kinds import Kind
24
+ from .marketplace.compute import TeamNotFoundError
25
+ from .marketplace.resolve import ConfigFetchError, EffectiveConfig, resolve_effective_config
26
+ from .marketplace.schema import OrganizationConfig
27
+ from .marketplace.team_fetch import TeamFetchResult, fetch_team_config
28
+ from .marketplace.trust import TrustViolationError
29
+ from .output_mode import is_json_mode, print_human
30
+ from .panels import create_warning_panel
31
+ from .ui.gate import InteractivityContext
32
+ from .ui.picker import TeamSwitchRequested, pick_team
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════════════
35
+ # Federation Helpers
36
+ # ═══════════════════════════════════════════════════════════════════════════════
37
+
38
+
39
+ def _get_config_source_from_raw(
40
+ org_config: dict[str, Any] | None, team_name: str
41
+ ) -> dict[str, Any] | None:
42
+ """Extract config_source from raw org_config dict for a team.
43
+
44
+ Args:
45
+ org_config: Raw org config dict (or None)
46
+ team_name: Team profile name
47
+
48
+ Returns:
49
+ Raw config_source dict if team is federated, None if inline or not found
50
+ """
51
+ if org_config is None:
52
+ return None
53
+
54
+ profiles = org_config.get("profiles", {})
55
+ if not profiles or team_name not in profiles:
56
+ return None
57
+
58
+ profile = profiles[team_name]
59
+ if not isinstance(profile, dict):
60
+ return None
61
+
62
+ return profile.get("config_source")
63
+
64
+
65
+ def _parse_config_source(raw_source: dict[str, Any]) -> Any:
66
+ """Parse raw config_source dict into ConfigSource model.
67
+
68
+ The org config uses a nested structure like:
69
+ {"github": {"owner": "...", "repo": "..."}}
70
+
71
+ The Pydantic models use a flat structure with a discriminator field:
72
+ {"source": "github", "owner": "...", "repo": "..."}
73
+
74
+ This function bridges the two formats.
75
+
76
+ Args:
77
+ raw_source: Raw config_source dict from org config
78
+
79
+ Returns:
80
+ Parsed ConfigSource model (ConfigSourceGitHub, ConfigSourceGit, or ConfigSourceURL)
81
+
82
+ Raises:
83
+ ValueError: If config_source format is invalid
84
+ """
85
+ from .marketplace.schema import (
86
+ ConfigSourceGit,
87
+ ConfigSourceGitHub,
88
+ ConfigSourceURL,
89
+ )
90
+
91
+ # Config source is a dict with a single key indicating type
92
+ # Add the discriminator field when parsing
93
+ if "github" in raw_source:
94
+ config_data = {**raw_source["github"], "source": "github"}
95
+ return ConfigSourceGitHub.model_validate(config_data)
96
+ elif "git" in raw_source:
97
+ config_data = {**raw_source["git"], "source": "git"}
98
+ return ConfigSourceGit.model_validate(config_data)
99
+ elif "url" in raw_source:
100
+ config_data = {**raw_source["url"], "source": "url"}
101
+ return ConfigSourceURL.model_validate(config_data)
102
+ else:
103
+ raise ValueError(f"Unknown config_source type: {list(raw_source.keys())}")
104
+
105
+
106
+ def _fetch_federated_team_config(
107
+ org_config: dict[str, Any] | None, team_name: str
108
+ ) -> TeamFetchResult | None:
109
+ """Fetch team config if team is federated, return None if inline.
110
+
111
+ This eagerly fetches the team config to prime the cache when
112
+ switching to a federated team.
113
+
114
+ Args:
115
+ org_config: Raw org config dict
116
+ team_name: Team name to fetch config for
117
+
118
+ Returns:
119
+ TeamFetchResult if federated team, None if inline
120
+ """
121
+ raw_source = _get_config_source_from_raw(org_config, team_name)
122
+ if raw_source is None:
123
+ return None
124
+
125
+ try:
126
+ config_source = _parse_config_source(raw_source)
127
+ return fetch_team_config(config_source, team_name)
128
+ except ValueError:
129
+ # Invalid config_source format - treat as inline
130
+ return None
131
+
132
+
133
+ # ═══════════════════════════════════════════════════════════════════════════════
134
+ # Team App Definition
135
+ # ═══════════════════════════════════════════════════════════════════════════════
136
+
137
+ team_app = typer.Typer(
138
+ name="team",
139
+ help="Team profile management",
140
+ no_args_is_help=True,
141
+ )
142
+
143
+
144
+ # ═══════════════════════════════════════════════════════════════════════════════
145
+ # Team List Command
146
+ # ═══════════════════════════════════════════════════════════════════════════════
147
+
148
+
149
+ @team_app.command("list")
150
+ @json_command(Kind.TEAM_LIST)
151
+ @handle_errors
152
+ def team_list(
153
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show full descriptions"),
154
+ sync: bool = typer.Option(False, "--sync", "-s", help="Sync team configs from organization"),
155
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON envelope"),
156
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
157
+ ) -> dict[str, Any]:
158
+ """List available team profiles.
159
+
160
+ Returns a list of teams with their names, descriptions, and plugins.
161
+ Use --verbose to show full descriptions instead of truncated versions.
162
+ Use --sync to refresh the team list from the organization config.
163
+ """
164
+ cfg = config.load_user_config()
165
+ org_config = config.load_cached_org_config()
166
+
167
+ # Sync if requested
168
+ if sync:
169
+ from .remote import fetch_org_config
170
+
171
+ org_source = cfg.get("organization_source", {})
172
+ org_url = org_source.get("url")
173
+ org_auth = org_source.get("auth")
174
+ if org_url:
175
+ fetched_config, _etag, status_code = fetch_org_config(org_url, org_auth)
176
+ if fetched_config and status_code == 200:
177
+ org_config = fetched_config
178
+ # Save to cache
179
+ config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
180
+ import json
181
+
182
+ cache_file = config.CACHE_DIR / "org_config.json"
183
+ cache_file.write_text(json.dumps(org_config, indent=2))
184
+ print_human("[green]✓ Team list synced from organization[/green]")
185
+
186
+ # Get teams
187
+ available_teams = teams.list_teams(cfg, org_config=org_config)
188
+
189
+ # Get current team for marking
190
+ current = cfg.get("selected_profile")
191
+
192
+ # Build data structure for JSON output
193
+ team_data = []
194
+ for team in available_teams:
195
+ team_data.append(
196
+ {
197
+ "name": team["name"],
198
+ "description": team.get("description", ""),
199
+ "plugin": team.get("plugin"),
200
+ "is_current": team["name"] == current,
201
+ }
202
+ )
203
+
204
+ # Human-readable output
205
+ if not is_json_mode():
206
+ if not available_teams:
207
+ # Provide context-aware messaging based on mode
208
+ if config.is_standalone_mode():
209
+ console.print(
210
+ create_warning_panel(
211
+ "Standalone Mode",
212
+ "Teams are not available in standalone mode.",
213
+ "Run 'scc setup' with an organization URL to enable teams",
214
+ )
215
+ )
216
+ else:
217
+ console.print(
218
+ create_warning_panel(
219
+ "No Teams",
220
+ "No team profiles defined in organization config.",
221
+ "Contact your organization admin to configure teams",
222
+ )
223
+ )
224
+ return {"teams": [], "current": current}
225
+
226
+ # Build rows for responsive table
227
+ rows = []
228
+ for team in available_teams:
229
+ name = team["name"]
230
+ if name == current:
231
+ name = f"[bold]{name}[/bold] ←"
232
+
233
+ desc = team.get("description", "")
234
+ if not verbose and len(desc) > 40:
235
+ desc = desc[:37] + "..."
236
+
237
+ plugin = team.get("plugin") or "-"
238
+ rows.append([name, desc, plugin])
239
+
240
+ render_responsive_table(
241
+ title="Available Team Profiles",
242
+ columns=[
243
+ ("Team", "cyan"),
244
+ ("Description", "white"),
245
+ ],
246
+ rows=rows,
247
+ wide_columns=[
248
+ ("Plugin", "yellow"),
249
+ ],
250
+ )
251
+
252
+ console.print()
253
+ console.print(
254
+ "[dim]Use: scc team switch <name> to switch, scc team info <name> for details[/dim]"
255
+ )
256
+
257
+ return {"teams": team_data, "current": current}
258
+
259
+
260
+ # ═══════════════════════════════════════════════════════════════════════════════
261
+ # Team Current Command
262
+ # ═══════════════════════════════════════════════════════════════════════════════
263
+
264
+
265
+ @team_app.command("current")
266
+ @json_command(Kind.TEAM_CURRENT)
267
+ @handle_errors
268
+ def team_current(
269
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON envelope"),
270
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
271
+ ) -> dict[str, Any]:
272
+ """Show the currently selected team profile.
273
+
274
+ Displays the current team and basic information about it.
275
+ Returns null for team if no team is selected.
276
+ """
277
+ cfg = config.load_user_config()
278
+ org_config = config.load_cached_org_config()
279
+
280
+ current = cfg.get("selected_profile")
281
+
282
+ if not current:
283
+ print_human(
284
+ "[yellow]No team currently selected.[/yellow]\n"
285
+ "[dim]Use 'scc team switch <name>' to select a team[/dim]"
286
+ )
287
+ return {"team": None, "profile": None}
288
+
289
+ # Get team details
290
+ details = teams.get_team_details(current, cfg, org_config=org_config)
291
+
292
+ if not details:
293
+ print_human(
294
+ f"[yellow]Current team '{current}' not found in configuration.[/yellow]\n"
295
+ "[dim]Run 'scc team list --sync' to refresh[/dim]"
296
+ )
297
+ return {"team": current, "profile": None, "error": "team_not_found"}
298
+
299
+ # Human output
300
+ print_human(f"[bold cyan]Current team:[/bold cyan] {current}")
301
+ if details.get("description"):
302
+ print_human(f"[dim]{details['description']}[/dim]")
303
+ if details.get("plugin"):
304
+ print_human(f"[dim]Plugin: {details['plugin']}[/dim]")
305
+
306
+ return {
307
+ "team": current,
308
+ "profile": {
309
+ "name": details.get("name"),
310
+ "description": details.get("description"),
311
+ "plugin": details.get("plugin"),
312
+ "marketplace": details.get("marketplace"),
313
+ },
314
+ }
315
+
316
+
317
+ # ═══════════════════════════════════════════════════════════════════════════════
318
+ # Team Switch Command
319
+ # ═══════════════════════════════════════════════════════════════════════════════
320
+
321
+
322
+ @team_app.command("switch")
323
+ @json_command(Kind.TEAM_SWITCH)
324
+ @handle_errors
325
+ def team_switch(
326
+ team_name: str = typer.Argument(
327
+ None, help="Team name to switch to (interactive picker if not provided)"
328
+ ),
329
+ non_interactive: bool = typer.Option(
330
+ False, "--non-interactive", help="Fail if team name not provided"
331
+ ),
332
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON envelope"),
333
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
334
+ ) -> dict[str, Any]:
335
+ """Switch to a different team profile.
336
+
337
+ If team_name is not provided, shows an interactive picker (if TTY).
338
+ Use --non-interactive to fail instead of showing picker.
339
+ """
340
+ cfg = config.load_user_config()
341
+ org_config = config.load_cached_org_config()
342
+
343
+ available_teams = teams.list_teams(cfg, org_config=org_config)
344
+
345
+ if not available_teams:
346
+ # Provide context-aware messaging based on mode
347
+ if config.is_standalone_mode():
348
+ print_human(
349
+ "[yellow]Teams are not available in standalone mode.[/yellow]\n"
350
+ "[dim]Run 'scc setup' with an organization URL to enable teams[/dim]"
351
+ )
352
+ else:
353
+ print_human(
354
+ "[yellow]No teams available to switch to.[/yellow]\n"
355
+ "[dim]No team profiles defined in organization config[/dim]"
356
+ )
357
+ return {"success": False, "error": "no_teams_available", "previous": None, "current": None}
358
+
359
+ # Get current team for picker display
360
+ current = cfg.get("selected_profile")
361
+
362
+ # Resolve team name (explicit arg, picker, or error)
363
+ resolved_name: str | None = team_name
364
+
365
+ if resolved_name is None:
366
+ # Create interactivity context from flags
367
+ ctx = InteractivityContext.create(
368
+ json_mode=is_json_mode(),
369
+ no_interactive=non_interactive,
370
+ )
371
+
372
+ if ctx.allows_prompt():
373
+ # Show interactive picker
374
+ try:
375
+ selected_team = pick_team(available_teams, current_team=current)
376
+ if selected_team is None:
377
+ # User cancelled - exit cleanly
378
+ return {
379
+ "success": False,
380
+ "cancelled": True,
381
+ "previous": current,
382
+ "current": None,
383
+ }
384
+ resolved_name = selected_team["name"]
385
+ except TeamSwitchRequested:
386
+ # Already in team picker - treat as cancel
387
+ return {"success": False, "cancelled": True, "previous": current, "current": None}
388
+ else:
389
+ # Non-interactive mode with no team specified
390
+ raise typer.BadParameter(
391
+ "Team name required in non-interactive mode. "
392
+ f"Available: {', '.join(t['name'] for t in available_teams)}"
393
+ )
394
+
395
+ # Validate team exists (when name provided directly as arg)
396
+ team_names = [t["name"] for t in available_teams]
397
+ if resolved_name not in team_names:
398
+ print_human(
399
+ f"[red]Team '{resolved_name}' not found.[/red]\n"
400
+ f"[dim]Available: {', '.join(team_names)}[/dim]"
401
+ )
402
+ return {"success": False, "error": "team_not_found", "team": resolved_name}
403
+
404
+ # Get previous team
405
+ previous = cfg.get("selected_profile")
406
+
407
+ # Switch team
408
+ cfg["selected_profile"] = resolved_name
409
+ config.save_user_config(cfg)
410
+
411
+ # Check if team is federated and fetch config to prime cache
412
+ fetch_result = _fetch_federated_team_config(org_config, resolved_name)
413
+ is_federated = fetch_result is not None
414
+
415
+ print_human(f"[green]✓ Switched to team: {resolved_name}[/green]")
416
+ if previous and previous != resolved_name:
417
+ print_human(f"[dim]Previous: {previous}[/dim]")
418
+
419
+ details = teams.get_team_details(resolved_name, cfg, org_config=org_config)
420
+ if details:
421
+ description = details.get("description")
422
+ plugin = details.get("plugin") or "none"
423
+ marketplace = details.get("marketplace") or "default"
424
+ if description:
425
+ print_human(f"[dim]Description:[/dim] {description}")
426
+ print_human(f"[dim]Plugin:[/dim] {plugin}")
427
+ print_human(f"[dim]Marketplace:[/dim] {marketplace}")
428
+
429
+ # Display federation status
430
+ if fetch_result is not None:
431
+ if fetch_result.success:
432
+ print_human(f"[dim]Federated config synced from {fetch_result.source_url}[/dim]")
433
+ else:
434
+ print_human(f"[yellow]⚠ Could not sync federated config: {fetch_result.error}[/yellow]")
435
+
436
+ # Build response with federation metadata
437
+ response: dict[str, Any] = {
438
+ "success": True,
439
+ "previous": previous,
440
+ "current": resolved_name,
441
+ "is_federated": is_federated,
442
+ }
443
+
444
+ if is_federated and fetch_result is not None:
445
+ response["source_type"] = fetch_result.source_type
446
+ response["source_url"] = fetch_result.source_url
447
+ if fetch_result.commit_sha:
448
+ response["commit_sha"] = fetch_result.commit_sha
449
+ if fetch_result.etag:
450
+ response["etag"] = fetch_result.etag
451
+ if not fetch_result.success:
452
+ response["fetch_error"] = fetch_result.error
453
+
454
+ return response
455
+
456
+
457
+ # ═══════════════════════════════════════════════════════════════════════════════
458
+ # Team Info Command
459
+ # ═══════════════════════════════════════════════════════════════════════════════
460
+
461
+
462
+ @team_app.command("info")
463
+ @json_command(Kind.TEAM_INFO)
464
+ @handle_errors
465
+ def team_info(
466
+ team_name: str = typer.Argument(..., help="Team name to show details for"),
467
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON envelope"),
468
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
469
+ ) -> dict[str, Any]:
470
+ """Show detailed information for a specific team profile.
471
+
472
+ Displays team description, plugin configuration, marketplace info,
473
+ federation status (federated vs inline), config source, and trust grants.
474
+ """
475
+ cfg = config.load_user_config()
476
+ org_config = config.load_cached_org_config()
477
+
478
+ details = teams.get_team_details(team_name, cfg, org_config=org_config)
479
+
480
+ # Detect if team is federated (has config_source)
481
+ raw_source = _get_config_source_from_raw(org_config, team_name)
482
+ is_federated = raw_source is not None
483
+
484
+ # Get config source description for federated teams
485
+ config_source_display: str | None = None
486
+ if is_federated and raw_source is not None:
487
+ if "github" in raw_source:
488
+ gh = raw_source["github"]
489
+ config_source_display = f"github.com/{gh.get('owner', '?')}/{gh.get('repo', '?')}"
490
+ elif "git" in raw_source:
491
+ git = raw_source["git"]
492
+ url = git.get("url", "")
493
+ # Normalize for display
494
+ if url.startswith("https://"):
495
+ url = url[8:]
496
+ elif url.startswith("git@"):
497
+ url = url[4:].replace(":", "/", 1)
498
+ if url.endswith(".git"):
499
+ url = url[:-4]
500
+ config_source_display = url
501
+ elif "url" in raw_source:
502
+ url = raw_source["url"].get("url", "")
503
+ if url.startswith("https://"):
504
+ url = url[8:]
505
+ config_source_display = url
506
+
507
+ # Get trust grants for federated teams
508
+ trust_grants: dict[str, Any] | None = None
509
+ if is_federated and org_config:
510
+ profiles = org_config.get("profiles", {})
511
+ profile = profiles.get(team_name, {})
512
+ if isinstance(profile, dict):
513
+ trust_grants = profile.get("trust")
514
+
515
+ if not details:
516
+ if not is_json_mode():
517
+ console.print(
518
+ create_warning_panel(
519
+ "Team Not Found",
520
+ f"No team profile named '{team_name}'.",
521
+ "Run 'scc team list' to see available profiles",
522
+ )
523
+ )
524
+ return {"team": team_name, "found": False, "profile": None}
525
+
526
+ # Get validation info
527
+ validation = teams.validate_team_profile(team_name, cfg, org_config=org_config)
528
+
529
+ # Human output
530
+ if not is_json_mode():
531
+ grid = Table.grid(padding=(0, 2))
532
+ grid.add_column(style="dim", no_wrap=True)
533
+ grid.add_column(style="white")
534
+
535
+ grid.add_row("Description:", details.get("description", "-"))
536
+
537
+ # Show federation mode
538
+ if is_federated:
539
+ grid.add_row("Mode:", "[cyan]federated[/cyan]")
540
+ if config_source_display:
541
+ grid.add_row("Config Source:", config_source_display)
542
+ else:
543
+ grid.add_row("Mode:", "[dim]inline[/dim]")
544
+
545
+ plugin = details.get("plugin")
546
+ if plugin:
547
+ marketplace = details.get("marketplace", "sundsvall")
548
+ grid.add_row("Plugin:", f"{plugin}@{marketplace}")
549
+ if details.get("marketplace_repo"):
550
+ grid.add_row("Marketplace:", details.get("marketplace_repo", "-"))
551
+ else:
552
+ grid.add_row("Plugin:", "[dim]None (base profile)[/dim]")
553
+
554
+ # Show trust grants for federated teams
555
+ if trust_grants:
556
+ grid.add_row("", "")
557
+ grid.add_row("[bold]Trust Grants:[/bold]", "")
558
+ inherit = trust_grants.get("inherit_org_marketplaces", True)
559
+ allow_add = trust_grants.get("allow_additional_marketplaces", False)
560
+ grid.add_row(
561
+ " Inherit Org Marketplaces:", "[green]yes[/green]" if inherit else "[red]no[/red]"
562
+ )
563
+ grid.add_row(
564
+ " Allow Additional Marketplaces:",
565
+ "[green]yes[/green]" if allow_add else "[red]no[/red]",
566
+ )
567
+
568
+ # Show validation warnings
569
+ if validation.get("warnings"):
570
+ grid.add_row("", "")
571
+ for warning in validation["warnings"]:
572
+ grid.add_row("[yellow]Warning:[/yellow]", warning)
573
+
574
+ panel = Panel(
575
+ grid,
576
+ title=f"[bold cyan]Team: {team_name}[/bold cyan]",
577
+ border_style="cyan",
578
+ padding=(1, 2),
579
+ )
580
+
581
+ console.print()
582
+ console.print(panel)
583
+ console.print()
584
+ console.print(f"[dim]Use: scc start -t {team_name} to use this profile[/dim]")
585
+
586
+ # Build response with federation metadata
587
+ response: dict[str, Any] = {
588
+ "team": team_name,
589
+ "found": True,
590
+ "is_federated": is_federated,
591
+ "profile": {
592
+ "name": details.get("name"),
593
+ "description": details.get("description"),
594
+ "plugin": details.get("plugin"),
595
+ "marketplace": details.get("marketplace"),
596
+ "marketplace_type": details.get("marketplace_type"),
597
+ "marketplace_repo": details.get("marketplace_repo"),
598
+ },
599
+ "validation": {
600
+ "valid": validation.get("valid", True),
601
+ "warnings": validation.get("warnings", []),
602
+ "errors": validation.get("errors", []),
603
+ },
604
+ }
605
+
606
+ # Add federation details for federated teams
607
+ if is_federated:
608
+ response["config_source"] = config_source_display
609
+ if trust_grants:
610
+ response["trust"] = trust_grants
611
+
612
+ return response
613
+
614
+
615
+ @team_app.command("validate")
616
+ @json_command(Kind.TEAM_VALIDATE)
617
+ @handle_errors
618
+ def team_validate(
619
+ team_name: str = typer.Argument(..., help="Team name to validate"),
620
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
621
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON envelope"),
622
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
623
+ ) -> dict[str, Any]:
624
+ """Validate team configuration and show effective plugins.
625
+
626
+ Resolves the team configuration (inline or federated) and validates:
627
+ - Plugin security compliance (blocked_plugins patterns)
628
+ - Plugin allowlists (allowed_plugins patterns)
629
+ - Marketplace trust grants (for federated teams)
630
+ - Cache freshness status (for federated teams)
631
+
632
+ Use --verbose to see detailed validation information including
633
+ individual blocked/disabled plugins and their reasons.
634
+ """
635
+ org_config_data = config.load_cached_org_config()
636
+ if not org_config_data:
637
+ if not is_json_mode():
638
+ console.print(
639
+ create_warning_panel(
640
+ "No Org Config",
641
+ "No organization configuration found.",
642
+ "Run 'scc setup' to configure your organization",
643
+ )
644
+ )
645
+ return {
646
+ "team": team_name,
647
+ "valid": False,
648
+ "error": "No organization configuration found",
649
+ }
650
+
651
+ # Parse org config
652
+ try:
653
+ org_config = OrganizationConfig.model_validate(org_config_data)
654
+ except Exception as e:
655
+ if not is_json_mode():
656
+ console.print(
657
+ create_warning_panel(
658
+ "Invalid Org Config",
659
+ f"Organization configuration is invalid: {e}",
660
+ "Run 'scc org update' to refresh your configuration",
661
+ )
662
+ )
663
+ return {
664
+ "team": team_name,
665
+ "valid": False,
666
+ "error": f"Invalid org config: {e}",
667
+ }
668
+
669
+ # Resolve effective config (validates team exists, trust, security)
670
+ try:
671
+ effective = resolve_effective_config(org_config, team_name)
672
+ except TeamNotFoundError as e:
673
+ if not is_json_mode():
674
+ console.print(
675
+ create_warning_panel(
676
+ "Team Not Found",
677
+ f"Team '{team_name}' not found in org config.",
678
+ f"Available teams: {', '.join(e.available_teams[:5])}",
679
+ )
680
+ )
681
+ return {
682
+ "team": team_name,
683
+ "valid": False,
684
+ "error": f"Team not found: {team_name}",
685
+ "available_teams": e.available_teams,
686
+ }
687
+ except TrustViolationError as e:
688
+ if not is_json_mode():
689
+ console.print(
690
+ create_warning_panel(
691
+ "Trust Violation",
692
+ f"Team configuration violates trust policy: {e.violation}",
693
+ "Check team config_source and trust grants in org config",
694
+ )
695
+ )
696
+ return {
697
+ "team": team_name,
698
+ "valid": False,
699
+ "error": f"Trust violation: {e.violation}",
700
+ "team_name": e.team_name,
701
+ }
702
+ except ConfigFetchError as e:
703
+ if not is_json_mode():
704
+ console.print(
705
+ create_warning_panel(
706
+ "Config Fetch Failed",
707
+ f"Failed to fetch config for team '{e.team_id}' from {e.source_type}",
708
+ str(e), # Includes remediation hint
709
+ )
710
+ )
711
+ return {
712
+ "team": team_name,
713
+ "valid": False,
714
+ "error": str(e),
715
+ "source_type": e.source_type,
716
+ "source_url": e.source_url,
717
+ }
718
+
719
+ # Determine overall validity
720
+ is_valid = not effective.has_security_violations
721
+
722
+ # Human output
723
+ if not is_json_mode():
724
+ _render_validation_result(effective, verbose)
725
+
726
+ # Build JSON response
727
+ response: dict[str, Any] = {
728
+ "team": team_name,
729
+ "valid": is_valid,
730
+ "is_federated": effective.is_federated,
731
+ "enabled_plugins_count": effective.plugin_count,
732
+ "blocked_plugins_count": len(effective.blocked_plugins),
733
+ "disabled_plugins_count": len(effective.disabled_plugins),
734
+ "not_allowed_plugins_count": len(effective.not_allowed_plugins),
735
+ }
736
+
737
+ # Add federation metadata
738
+ if effective.is_federated:
739
+ response["config_source"] = effective.source_description
740
+ if effective.config_commit_sha:
741
+ response["config_commit_sha"] = effective.config_commit_sha
742
+ if effective.config_etag:
743
+ response["config_etag"] = effective.config_etag
744
+
745
+ # Add cache status
746
+ if effective.used_cached_config:
747
+ response["used_cached_config"] = True
748
+ response["cache_is_stale"] = effective.cache_is_stale
749
+ if effective.staleness_warning:
750
+ response["staleness_warning"] = effective.staleness_warning
751
+
752
+ # Add verbose details
753
+ if verbose or json_output or pretty:
754
+ response["enabled_plugins"] = sorted(effective.enabled_plugins)
755
+ response["blocked_plugins"] = [
756
+ {"plugin_id": bp.plugin_id, "reason": bp.reason, "pattern": bp.pattern}
757
+ for bp in effective.blocked_plugins
758
+ ]
759
+ response["disabled_plugins"] = effective.disabled_plugins
760
+ response["not_allowed_plugins"] = effective.not_allowed_plugins
761
+ response["extra_marketplaces"] = effective.extra_marketplaces
762
+
763
+ return response
764
+
765
+
766
+ def _render_validation_result(effective: EffectiveConfig, verbose: bool) -> None:
767
+ """Render validation result to terminal.
768
+
769
+ Args:
770
+ effective: Resolved effective configuration
771
+ verbose: Whether to show detailed output
772
+ """
773
+ console.print()
774
+
775
+ # Header with validation status
776
+ if effective.has_security_violations:
777
+ status = "[red]FAILED[/red]"
778
+ border_style = "red"
779
+ else:
780
+ status = "[green]PASSED[/green]"
781
+ border_style = "green"
782
+
783
+ grid = Table.grid(padding=(0, 2))
784
+ grid.add_column(style="dim", no_wrap=True)
785
+ grid.add_column()
786
+
787
+ # Basic info
788
+ grid.add_row("Status:", status)
789
+ grid.add_row(
790
+ "Mode:", "[cyan]federated[/cyan]" if effective.is_federated else "[dim]inline[/dim]"
791
+ )
792
+
793
+ if effective.is_federated:
794
+ grid.add_row("Config Source:", effective.source_description)
795
+ if effective.config_commit_sha:
796
+ grid.add_row("Commit SHA:", effective.config_commit_sha[:8])
797
+
798
+ # Cache status
799
+ if effective.used_cached_config:
800
+ cache_status = (
801
+ "[yellow]stale[/yellow]" if effective.cache_is_stale else "[green]fresh[/green]"
802
+ )
803
+ grid.add_row("Cache:", cache_status)
804
+ if effective.staleness_warning:
805
+ grid.add_row("", f"[dim]{effective.staleness_warning}[/dim]")
806
+
807
+ grid.add_row("", "")
808
+
809
+ # Plugin summary
810
+ grid.add_row("Enabled Plugins:", f"[green]{effective.plugin_count}[/green]")
811
+ if effective.blocked_plugins:
812
+ grid.add_row("Blocked Plugins:", f"[red]{len(effective.blocked_plugins)}[/red]")
813
+ if effective.disabled_plugins:
814
+ grid.add_row("Disabled Plugins:", f"[yellow]{len(effective.disabled_plugins)}[/yellow]")
815
+ if effective.not_allowed_plugins:
816
+ grid.add_row("Not Allowed:", f"[yellow]{len(effective.not_allowed_plugins)}[/yellow]")
817
+
818
+ # Verbose details
819
+ if verbose:
820
+ grid.add_row("", "")
821
+ if effective.enabled_plugins:
822
+ grid.add_row("[bold]Enabled:[/bold]", "")
823
+ for plugin in sorted(effective.enabled_plugins):
824
+ grid.add_row("", f" [green]✓[/green] {plugin}")
825
+
826
+ if effective.blocked_plugins:
827
+ grid.add_row("[bold]Blocked:[/bold]", "")
828
+ for bp in effective.blocked_plugins:
829
+ grid.add_row("", f" [red]✗[/red] {bp.plugin_id}")
830
+ grid.add_row("", f" [dim]Reason: {bp.reason}[/dim]")
831
+ grid.add_row("", f" [dim]Pattern: {bp.pattern}[/dim]")
832
+
833
+ if effective.disabled_plugins:
834
+ grid.add_row("[bold]Disabled:[/bold]", "")
835
+ for plugin in effective.disabled_plugins:
836
+ grid.add_row("", f" [yellow]○[/yellow] {plugin}")
837
+
838
+ if effective.not_allowed_plugins:
839
+ grid.add_row("[bold]Not Allowed:[/bold]", "")
840
+ for plugin in effective.not_allowed_plugins:
841
+ grid.add_row("", f" [yellow]○[/yellow] {plugin}")
842
+
843
+ panel = Panel(
844
+ grid,
845
+ title=f"[bold cyan]Team Validation: {effective.team_id}[/bold cyan]",
846
+ border_style=border_style,
847
+ padding=(1, 2),
848
+ )
849
+ console.print(panel)
850
+
851
+ # Hint
852
+ if not verbose and (
853
+ effective.blocked_plugins or effective.disabled_plugins or effective.not_allowed_plugins
854
+ ):
855
+ console.print()
856
+ console.print("[dim]Use --verbose for detailed plugin information[/dim]")
857
+
858
+ console.print()