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
@@ -0,0 +1,238 @@
1
+ """
2
+ Settings rendering for Claude Code integration.
3
+
4
+ This module provides the bridge between SCC's marketplace/plugin management
5
+ and Claude Code's settings.local.json format. Key responsibilities:
6
+
7
+ 1. render_settings() - Convert effective plugins to Claude settings format
8
+ 2. merge_settings() - Non-destructive merge preserving user customizations
9
+ 3. check_conflicts() - Detect conflicts between user and team settings
10
+
11
+ Per RQ-11: All paths must be relative for Docker sandbox compatibility.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from scc_cli.marketplace.constants import MANAGED_STATE_FILE
21
+
22
+ # ─────────────────────────────────────────────────────────────────────────────
23
+ # Render Settings
24
+ # ─────────────────────────────────────────────────────────────────────────────
25
+
26
+
27
+ def render_settings(
28
+ effective_plugins: dict[str, Any],
29
+ materialized_marketplaces: dict[str, Any],
30
+ ) -> dict[str, Any]:
31
+ """Render effective plugins and marketplaces to Claude settings format.
32
+
33
+ Creates a settings.local.json compatible structure with:
34
+ - extraKnownMarketplaces: Array of marketplace configs with type and path
35
+ - enabledPlugins: Array of plugin references (name@marketplace)
36
+
37
+ Args:
38
+ effective_plugins: Result from compute_effective_plugins()
39
+ - enabled: Set of enabled plugin references
40
+ - extra_marketplaces: List of marketplace IDs to enable
41
+ materialized_marketplaces: Dict mapping name to MaterializedMarketplace-like dicts
42
+ - relative_path: Path relative to project root
43
+ - source_type: Type of source (github, git, directory, url)
44
+
45
+ Returns:
46
+ Dict with Claude Code settings structure:
47
+ {
48
+ "extraKnownMarketplaces": [...],
49
+ "enabledPlugins": [...]
50
+ }
51
+ """
52
+ settings: dict[str, Any] = {}
53
+
54
+ # Build extraKnownMarketplaces array
55
+ extra_marketplaces: list[dict[str, str]] = []
56
+ for name, marketplace_data in materialized_marketplaces.items():
57
+ # Get the relative path from the materialized data
58
+ relative_path = marketplace_data.get("relative_path", "")
59
+
60
+ # All local marketplaces use type: directory
61
+ # This is because they've been cloned/downloaded to a local path
62
+ extra_marketplaces.append(
63
+ {
64
+ "type": "directory",
65
+ "path": relative_path,
66
+ }
67
+ )
68
+
69
+ settings["extraKnownMarketplaces"] = extra_marketplaces
70
+
71
+ # Build enabledPlugins array
72
+ enabled = effective_plugins.get("enabled", set())
73
+ # Convert set to sorted list for consistent output
74
+ if isinstance(enabled, set):
75
+ settings["enabledPlugins"] = sorted(list(enabled))
76
+ else:
77
+ settings["enabledPlugins"] = list(enabled)
78
+
79
+ return settings
80
+
81
+
82
+ # ─────────────────────────────────────────────────────────────────────────────
83
+ # Merge Settings (Non-Destructive)
84
+ # ─────────────────────────────────────────────────────────────────────────────
85
+
86
+
87
+ def _load_settings(project_dir: Path) -> dict[str, Any]:
88
+ """Load existing settings.local.json if it exists."""
89
+ settings_path = project_dir / ".claude" / "settings.local.json"
90
+ if settings_path.exists():
91
+ try:
92
+ result: dict[str, Any] = json.loads(settings_path.read_text())
93
+ return result
94
+ except json.JSONDecodeError:
95
+ return {}
96
+ return {}
97
+
98
+
99
+ def _load_managed_state(project_dir: Path) -> dict[str, Any]:
100
+ """Load the SCC managed state tracking file."""
101
+ managed_path = project_dir / ".claude" / MANAGED_STATE_FILE
102
+ if managed_path.exists():
103
+ try:
104
+ result: dict[str, Any] = json.loads(managed_path.read_text())
105
+ return result
106
+ except json.JSONDecodeError:
107
+ return {}
108
+ return {}
109
+
110
+
111
+ def merge_settings(
112
+ project_dir: Path,
113
+ new_settings: dict[str, Any],
114
+ ) -> dict[str, Any]:
115
+ """Non-destructively merge new settings with existing user settings.
116
+
117
+ This function implements RQ-7 from the research document:
118
+ - Preserves user-added plugins and marketplaces
119
+ - Removes old SCC-managed entries before adding new ones
120
+ - Uses .scc-managed.json to track what SCC has added
121
+
122
+ Algorithm:
123
+ 1. Load existing settings.local.json
124
+ 2. Load .scc-managed.json to know what was previously SCC-managed
125
+ 3. Remove previously managed plugins and marketplaces
126
+ 4. Add all new plugins and marketplaces from new_settings
127
+ 5. Return merged result (caller responsible for writing)
128
+
129
+ Args:
130
+ project_dir: Project root directory
131
+ new_settings: New settings from render_settings()
132
+
133
+ Returns:
134
+ Merged settings dict ready to write to settings.local.json
135
+ """
136
+ existing = _load_settings(project_dir)
137
+ managed = _load_managed_state(project_dir)
138
+
139
+ # Get what was previously managed by SCC
140
+ managed_plugins = set(managed.get("managed_plugins", []))
141
+ managed_marketplaces = set(managed.get("managed_marketplaces", []))
142
+
143
+ # Start with existing settings
144
+ merged = dict(existing)
145
+
146
+ # ─────────────────────────────────────────────────────────────────────────
147
+ # Process enabledPlugins
148
+ # ─────────────────────────────────────────────────────────────────────────
149
+
150
+ # Get existing plugins, removing old SCC-managed ones
151
+ existing_plugins = set(existing.get("enabledPlugins", []))
152
+ # Remove old managed plugins
153
+ remaining_user_plugins = existing_plugins - managed_plugins
154
+
155
+ # Add new plugins from this render
156
+ new_plugins = set(new_settings.get("enabledPlugins", []))
157
+ merged_plugins = remaining_user_plugins | new_plugins
158
+
159
+ # Deduplicate and sort
160
+ merged["enabledPlugins"] = sorted(list(merged_plugins))
161
+
162
+ # ─────────────────────────────────────────────────────────────────────────
163
+ # Process extraKnownMarketplaces
164
+ # ─────────────────────────────────────────────────────────────────────────
165
+
166
+ existing_marketplaces = existing.get("extraKnownMarketplaces", [])
167
+
168
+ # Filter out old SCC-managed marketplaces
169
+ remaining_user_marketplaces = [
170
+ m for m in existing_marketplaces if m.get("path", "") not in managed_marketplaces
171
+ ]
172
+
173
+ # Add new marketplaces from this render
174
+ new_marketplaces = new_settings.get("extraKnownMarketplaces", [])
175
+
176
+ # Merge: user marketplaces first, then new ones
177
+ # Deduplicate by path
178
+ seen_paths: set[str] = set()
179
+ merged_marketplaces: list[dict[str, str]] = []
180
+
181
+ for m in remaining_user_marketplaces:
182
+ path = m.get("path", "")
183
+ if path not in seen_paths:
184
+ merged_marketplaces.append(m)
185
+ seen_paths.add(path)
186
+
187
+ for m in new_marketplaces:
188
+ path = m.get("path", "")
189
+ if path not in seen_paths:
190
+ merged_marketplaces.append(m)
191
+ seen_paths.add(path)
192
+
193
+ merged["extraKnownMarketplaces"] = merged_marketplaces
194
+
195
+ return merged
196
+
197
+
198
+ # ─────────────────────────────────────────────────────────────────────────────
199
+ # Conflict Detection
200
+ # ─────────────────────────────────────────────────────────────────────────────
201
+
202
+
203
+ def check_conflicts(
204
+ existing_plugins: list[str],
205
+ blocked_plugins: list[dict[str, Any]],
206
+ ) -> list[str]:
207
+ """Check for conflicts between user plugins and team security policy.
208
+
209
+ Generates human-readable warnings when a user has installed plugins
210
+ that would be blocked by the team's security policy.
211
+
212
+ Args:
213
+ existing_plugins: List of plugin references from user's current settings
214
+ blocked_plugins: List of blocked plugin dicts from EffectivePlugins.blocked
215
+ Each dict has: plugin_id, reason, pattern
216
+
217
+ Returns:
218
+ List of warning strings for display to user
219
+ """
220
+ warnings: list[str] = []
221
+
222
+ # Build a set of blocked plugin IDs for fast lookup
223
+ blocked_ids = {b.get("plugin_id", "") for b in blocked_plugins}
224
+
225
+ for plugin in existing_plugins:
226
+ if plugin in blocked_ids:
227
+ # Find the block details
228
+ for blocked in blocked_plugins:
229
+ if blocked.get("plugin_id") == plugin:
230
+ reason = blocked.get("reason", "Blocked by policy")
231
+ pattern = blocked.get("pattern", "")
232
+ warnings.append(
233
+ f"⚠️ Plugin '{plugin}' is blocked by team policy: {reason} "
234
+ f"(matched pattern: {pattern})"
235
+ )
236
+ break
237
+
238
+ return warnings
@@ -0,0 +1,459 @@
1
+ """Effective config resolution for team profiles (Phase 2: Federation).
2
+
3
+ This module provides:
4
+ - EffectiveConfig: Complete resolved configuration for a team
5
+ - ConfigFetchError: Error when fetching federated team config fails
6
+ - resolve_effective_config(): Main orchestrator (T2a-18, to be implemented)
7
+
8
+ EffectiveConfig serves as the unified result type for both inline and federated
9
+ team configurations, providing backwards compatibility via to_phase1_format().
10
+
11
+ Design Decision:
12
+ EffectiveConfig wraps the plugin computation results (from compute.py) with
13
+ additional metadata about the configuration source. This allows the CLI to
14
+ display federated vs inline status, version info, and trust state.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import TYPE_CHECKING
21
+
22
+ from scc_cli.marketplace.compute import (
23
+ BlockedPlugin,
24
+ EffectivePlugins,
25
+ compute_effective_plugins,
26
+ compute_effective_plugins_federated,
27
+ )
28
+ from scc_cli.marketplace.team_fetch import fetch_team_config_with_fallback
29
+ from scc_cli.marketplace.trust import TrustViolationError, validate_team_config_trust
30
+
31
+ if TYPE_CHECKING:
32
+ from scc_cli.marketplace.schema import (
33
+ ConfigSource,
34
+ MarketplaceSource,
35
+ OrganizationConfig,
36
+ )
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────────────────────
40
+ # Exceptions
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+
43
+
44
+ class ConfigFetchError(Exception):
45
+ """Error when fetching federated team configuration fails.
46
+
47
+ Provides structured error information with remediation hints.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ team_id: str,
53
+ source_type: str,
54
+ source_url: str,
55
+ error: str,
56
+ ) -> None:
57
+ self.team_id = team_id
58
+ self.source_type = source_type
59
+ self.source_url = source_url
60
+ self.error = error
61
+
62
+ # Build message with remediation hints
63
+ remediation = _get_fetch_remediation(source_type, error)
64
+ message = (
65
+ f"Failed to fetch config for team '{team_id}' from {source_type} "
66
+ f"source ({source_url}): {error}"
67
+ )
68
+ if remediation:
69
+ message += f" {remediation}"
70
+
71
+ super().__init__(message)
72
+
73
+
74
+ def _get_fetch_remediation(source_type: str, error: str) -> str:
75
+ """Get remediation hint based on source type and error pattern."""
76
+ error_lower = error.lower()
77
+
78
+ # Network issues
79
+ if "network" in error_lower or "connection" in error_lower:
80
+ return "Try 'scc org update --team <name>' to refresh when connected."
81
+
82
+ # Cache expired
83
+ if "max_stale_age" in error_lower or "expired" in error_lower:
84
+ return "The cached config has expired. Connect to network to refresh."
85
+
86
+ # Git/GitHub specific
87
+ if source_type in ("github", "git"):
88
+ if "clone" in error_lower or "repository" in error_lower:
89
+ return "Check repository access permissions and URL."
90
+ if "branch" in error_lower:
91
+ return "Verify the branch name exists in the repository."
92
+ if "path" in error_lower or "not found" in error_lower:
93
+ return "Check that team-config.json exists at the specified path."
94
+
95
+ # URL specific
96
+ if source_type == "url":
97
+ if "401" in error or "unauthorized" in error_lower:
98
+ return "Add authentication headers to config_source."
99
+ if "403" in error or "forbidden" in error_lower:
100
+ return "Check access permissions for the config URL."
101
+ if "404" in error or "not found" in error_lower:
102
+ return "Verify the config URL is correct."
103
+
104
+ # Generic fallback
105
+ return "Run 'scc org update --team <name>' to retry fetching."
106
+
107
+
108
+ # ─────────────────────────────────────────────────────────────────────────────
109
+ # EffectiveConfig Dataclass
110
+ # ─────────────────────────────────────────────────────────────────────────────
111
+
112
+
113
+ @dataclass
114
+ class EffectiveConfig:
115
+ """Complete resolved configuration for a team.
116
+
117
+ This is the unified result type for both inline and federated team
118
+ configurations. It contains all the information needed to:
119
+ - Launch Claude Code with the correct plugins
120
+ - Display status information to the user
121
+ - Validate security compliance
122
+
123
+ For backwards compatibility, use to_phase1_format() to get the
124
+ (EffectivePlugins, marketplaces) tuple expected by Phase 1 code.
125
+
126
+ Attributes:
127
+ team_id: Team/profile identifier
128
+ is_federated: True if config came from external source
129
+ enabled_plugins: Set of enabled plugin references (name@marketplace)
130
+ config_source: External config source (if federated)
131
+ config_commit_sha: Git commit SHA (for git/github sources)
132
+ config_etag: HTTP ETag (for URL sources)
133
+ blocked_plugins: Plugins blocked by security policy
134
+ disabled_plugins: Plugins removed by disabled_plugins patterns
135
+ not_allowed_plugins: Plugins rejected by allowed_plugins filter
136
+ marketplaces: Effective marketplace sources
137
+ extra_marketplaces: Additional marketplace IDs to enable
138
+ """
139
+
140
+ # Required fields
141
+ team_id: str
142
+ is_federated: bool
143
+ enabled_plugins: set[str]
144
+
145
+ # Optional federation metadata
146
+ config_source: ConfigSource | None = None
147
+ config_commit_sha: str | None = None
148
+ config_etag: str | None = None
149
+
150
+ # Cache status (T2b-03: cached configs validated against current org security)
151
+ used_cached_config: bool = False
152
+ cache_is_stale: bool = False
153
+ staleness_warning: str | None = None
154
+
155
+ # Plugin filtering results
156
+ blocked_plugins: list[BlockedPlugin] = field(default_factory=list)
157
+ disabled_plugins: list[str] = field(default_factory=list)
158
+ not_allowed_plugins: list[str] = field(default_factory=list)
159
+
160
+ # Marketplace configuration
161
+ marketplaces: dict[str, MarketplaceSource] = field(default_factory=dict)
162
+ extra_marketplaces: list[str] = field(default_factory=list)
163
+
164
+ # ─────────────────────────────────────────────────────────────────────────
165
+ # Computed Properties
166
+ # ─────────────────────────────────────────────────────────────────────────
167
+
168
+ @property
169
+ def has_security_violations(self) -> bool:
170
+ """Check if any plugins were blocked by security policy.
171
+
172
+ Returns:
173
+ True if blocked_plugins is non-empty
174
+ """
175
+ return len(self.blocked_plugins) > 0
176
+
177
+ @property
178
+ def plugin_count(self) -> int:
179
+ """Get total number of enabled plugins.
180
+
181
+ Returns:
182
+ Count of enabled plugins
183
+ """
184
+ return len(self.enabled_plugins)
185
+
186
+ @property
187
+ def source_description(self) -> str:
188
+ """Get human-readable description of config source.
189
+
190
+ Returns:
191
+ 'inline' for non-federated, or source URL for federated
192
+ """
193
+ if not self.is_federated or self.config_source is None:
194
+ return "inline"
195
+
196
+ # Import here to avoid circular imports
197
+ from scc_cli.marketplace.schema import (
198
+ ConfigSourceGit,
199
+ ConfigSourceGitHub,
200
+ ConfigSourceURL,
201
+ )
202
+
203
+ if isinstance(self.config_source, ConfigSourceGitHub):
204
+ return f"github.com/{self.config_source.owner}/{self.config_source.repo}"
205
+ elif isinstance(self.config_source, ConfigSourceGit):
206
+ # Normalize git URL for display
207
+ url = self.config_source.url
208
+ if url.startswith("https://"):
209
+ url = url[8:]
210
+ elif url.startswith("git@"):
211
+ url = url[4:].replace(":", "/", 1)
212
+ if url.endswith(".git"):
213
+ url = url[:-4]
214
+ return url
215
+ elif isinstance(self.config_source, ConfigSourceURL):
216
+ # Strip protocol for display
217
+ url = self.config_source.url
218
+ if url.startswith("https://"):
219
+ url = url[8:]
220
+ return url
221
+ else:
222
+ return "unknown"
223
+
224
+ # ─────────────────────────────────────────────────────────────────────────
225
+ # Phase 1 Compatibility
226
+ # ─────────────────────────────────────────────────────────────────────────
227
+
228
+ def to_phase1_format(self) -> tuple[EffectivePlugins, dict[str, MarketplaceSource]]:
229
+ """Convert to Phase 1 format for backwards compatibility.
230
+
231
+ This adapter method allows EffectiveConfig to work with existing
232
+ Phase 1 code that expects:
233
+ - EffectivePlugins for plugin computation results
234
+ - dict[str, MarketplaceSource] for marketplace configuration
235
+
236
+ Returns:
237
+ Tuple of (EffectivePlugins, marketplaces dict)
238
+ """
239
+ plugins = EffectivePlugins(
240
+ enabled=self.enabled_plugins,
241
+ blocked=self.blocked_plugins,
242
+ not_allowed=self.not_allowed_plugins,
243
+ disabled=self.disabled_plugins,
244
+ extra_marketplaces=self.extra_marketplaces,
245
+ )
246
+
247
+ return (plugins, self.marketplaces)
248
+
249
+
250
+ # ─────────────────────────────────────────────────────────────────────────────
251
+ # Resolution Orchestrator
252
+ # ─────────────────────────────────────────────────────────────────────────────
253
+
254
+
255
+ def resolve_effective_config(
256
+ config: OrganizationConfig,
257
+ team_id: str,
258
+ ) -> EffectiveConfig:
259
+ """Resolve effective configuration for a team (inline or federated).
260
+
261
+ This is the main orchestrator for Phase 2 configuration resolution.
262
+ It determines whether a team uses inline or federated configuration
263
+ and applies the appropriate resolution strategy.
264
+
265
+ For inline teams (no config_source):
266
+ - Uses compute_effective_plugins() from compute.py
267
+ - Returns EffectiveConfig with is_federated=False
268
+
269
+ For federated teams (has config_source):
270
+ - Fetches external team config via fetch_team_config()
271
+ - Uses compute_effective_plugins_federated() from compute.py
272
+ - Returns EffectiveConfig with is_federated=True and metadata
273
+
274
+ Args:
275
+ config: Organization configuration with profiles and security
276
+ team_id: The profile/team ID to resolve plugins for
277
+
278
+ Returns:
279
+ EffectiveConfig with complete resolved configuration
280
+
281
+ Raises:
282
+ TeamNotFoundError: If team_id is not in config.profiles
283
+ RuntimeError: If federated fetch fails
284
+ """
285
+ # Import here to avoid circular imports
286
+ # Validate team exists (this also raises TeamNotFoundError if missing)
287
+ from scc_cli.marketplace.compute import TeamNotFoundError
288
+ from scc_cli.marketplace.schema import TeamConfig
289
+
290
+ if team_id not in config.profiles:
291
+ raise TeamNotFoundError(
292
+ team_id=team_id,
293
+ available_teams=list(config.profiles.keys()),
294
+ )
295
+
296
+ profile = config.profiles[team_id]
297
+ org_marketplaces = config.marketplaces or {}
298
+
299
+ # Check if team has config_source (federated) or not (inline)
300
+ if profile.config_source is None:
301
+ # ─────────────────────────────────────────────────────────────────
302
+ # INLINE TEAM: No external config source
303
+ # ─────────────────────────────────────────────────────────────────
304
+ plugins = compute_effective_plugins(config, team_id)
305
+
306
+ return EffectiveConfig(
307
+ team_id=team_id,
308
+ is_federated=False,
309
+ enabled_plugins=plugins.enabled,
310
+ blocked_plugins=plugins.blocked,
311
+ disabled_plugins=plugins.disabled,
312
+ not_allowed_plugins=plugins.not_allowed,
313
+ marketplaces=org_marketplaces,
314
+ extra_marketplaces=plugins.extra_marketplaces,
315
+ )
316
+
317
+ else:
318
+ # ─────────────────────────────────────────────────────────────────
319
+ # FEDERATED TEAM: Has external config source
320
+ # ─────────────────────────────────────────────────────────────────
321
+ source = profile.config_source
322
+
323
+ # Fetch the external team config with cache fallback (T2b-01+02)
324
+ fallback_result = fetch_team_config_with_fallback(source, team_id)
325
+
326
+ if not fallback_result.success or fallback_result.result.team_config is None:
327
+ fetch_result = fallback_result.result
328
+ raise ConfigFetchError(
329
+ team_id=team_id,
330
+ source_type=fetch_result.source_type,
331
+ source_url=fetch_result.source_url,
332
+ error=fetch_result.error or "Unknown fetch error",
333
+ )
334
+
335
+ # Extract cache status for caller visibility
336
+ fetch_result = fallback_result.result
337
+ used_cached_config = fallback_result.used_cache
338
+ cache_is_stale = fallback_result.is_stale
339
+ staleness_warning = fallback_result.staleness_warning
340
+
341
+ # Parse the fetched config as TeamConfig
342
+ team_config = TeamConfig.model_validate(fetch_result.team_config)
343
+
344
+ # Validate team config against trust grants (T2a-27: marketplace collisions)
345
+ trust = profile.trust
346
+ if trust:
347
+ validate_team_config_trust(
348
+ team_config=team_config,
349
+ trust=trust,
350
+ team_name=team_id,
351
+ org_marketplaces=org_marketplaces,
352
+ )
353
+
354
+ # Use federated plugin computation
355
+ plugins = compute_effective_plugins_federated(config, team_id, team_config)
356
+
357
+ # Build effective marketplaces based on trust grants (T2a-28)
358
+ # If inherit_org_marketplaces=false, org marketplaces are NOT inherited
359
+ effective_marketplaces: dict[str, MarketplaceSource] = {}
360
+
361
+ if trust and not trust.inherit_org_marketplaces:
362
+ # T2a-28: Validate that defaults.enabled_plugins don't require org marketplaces
363
+ # when inherit_org_marketplaces=false
364
+ _validate_defaults_dont_need_org_marketplaces(
365
+ config=config,
366
+ org_marketplaces=org_marketplaces,
367
+ team_id=team_id,
368
+ )
369
+ else:
370
+ # inherit_org_marketplaces=true (default) - include org marketplaces
371
+ effective_marketplaces = dict(org_marketplaces)
372
+
373
+ # Add team marketplaces if trust allows
374
+ if team_config.marketplaces and trust and trust.allow_additional_marketplaces:
375
+ effective_marketplaces.update(team_config.marketplaces)
376
+
377
+ return EffectiveConfig(
378
+ team_id=team_id,
379
+ is_federated=True,
380
+ enabled_plugins=plugins.enabled,
381
+ config_source=source,
382
+ config_commit_sha=fetch_result.commit_sha,
383
+ config_etag=fetch_result.etag,
384
+ # Cache status - T2b-03: note that validate_team_config_trust() above
385
+ # uses CURRENT org security, so cached configs are always validated
386
+ # against the latest org policy
387
+ used_cached_config=used_cached_config,
388
+ cache_is_stale=cache_is_stale,
389
+ staleness_warning=staleness_warning,
390
+ blocked_plugins=plugins.blocked,
391
+ disabled_plugins=plugins.disabled,
392
+ not_allowed_plugins=plugins.not_allowed,
393
+ marketplaces=effective_marketplaces,
394
+ extra_marketplaces=plugins.extra_marketplaces,
395
+ )
396
+
397
+
398
+ # ─────────────────────────────────────────────────────────────────────────────
399
+ # Helper Functions
400
+ # ─────────────────────────────────────────────────────────────────────────────
401
+
402
+
403
+ def _validate_defaults_dont_need_org_marketplaces(
404
+ config: OrganizationConfig,
405
+ org_marketplaces: dict[str, MarketplaceSource],
406
+ team_id: str,
407
+ ) -> None:
408
+ """Validate that defaults.enabled_plugins don't require org marketplaces.
409
+
410
+ When a federated team sets inherit_org_marketplaces=false, they won't have
411
+ access to org-defined marketplaces. However, defaults.enabled_plugins may
412
+ reference plugins from those marketplaces, creating a conflict.
413
+
414
+ This function validates that no such conflict exists.
415
+
416
+ Args:
417
+ config: Organization configuration with defaults
418
+ org_marketplaces: Dict of org-defined marketplaces
419
+ team_id: Team name for error messages
420
+
421
+ Raises:
422
+ TrustViolationError: If defaults.enabled_plugins reference org marketplaces
423
+ """
424
+ # Import here to avoid circular imports
425
+ from scc_cli.marketplace.constants import IMPLICIT_MARKETPLACES
426
+
427
+ if not config.defaults or not config.defaults.enabled_plugins:
428
+ return
429
+
430
+ # Check each default plugin for org marketplace references
431
+ conflicting_plugins: list[str] = []
432
+ conflicting_marketplaces: set[str] = set()
433
+
434
+ for plugin_ref in config.defaults.enabled_plugins:
435
+ if "@" in plugin_ref:
436
+ # Extract marketplace from plugin@marketplace format
437
+ marketplace_name = plugin_ref.split("@")[1]
438
+
439
+ # Skip implicit marketplaces - they're always available
440
+ if marketplace_name in IMPLICIT_MARKETPLACES:
441
+ continue
442
+
443
+ # Check if this marketplace is org-defined
444
+ if marketplace_name in org_marketplaces:
445
+ conflicting_plugins.append(plugin_ref)
446
+ conflicting_marketplaces.add(marketplace_name)
447
+
448
+ if conflicting_plugins:
449
+ plugins_str = ", ".join(conflicting_plugins)
450
+ marketplaces_str = ", ".join(sorted(conflicting_marketplaces))
451
+ raise TrustViolationError(
452
+ team_name=team_id,
453
+ violation=(
454
+ f"Team has inherit_org_marketplaces=false but defaults.enabled_plugins "
455
+ f"reference org marketplaces. Conflicting plugins: [{plugins_str}]. "
456
+ f"These require org marketplaces: [{marketplaces_str}]. "
457
+ "Either set inherit_org_marketplaces=true or remove these plugins from defaults."
458
+ ),
459
+ )