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