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