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.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|