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,377 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Effective plugin computation for team profiles.
|
|
3
|
+
|
|
4
|
+
This module provides the core plugin resolution logic:
|
|
5
|
+
- BlockedPlugin: Dataclass for blocked plugins with reason/pattern
|
|
6
|
+
- EffectivePlugins: Result of computation with enabled/blocked/disabled sets
|
|
7
|
+
- compute_effective_plugins(): Pure function for plugin resolution
|
|
8
|
+
|
|
9
|
+
Order of Operations:
|
|
10
|
+
1. Normalize all plugin references to canonical form
|
|
11
|
+
2. Merge defaults.enabled_plugins + profile.additional_plugins
|
|
12
|
+
3. Apply profile.disabled_plugins patterns (removes from merged)
|
|
13
|
+
4. Apply profile.allowed_plugins filter (for additional only)
|
|
14
|
+
5. Apply security.blocked_plugins (final security gate)
|
|
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.normalize import (
|
|
23
|
+
matches_pattern,
|
|
24
|
+
normalize_plugin,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from scc_cli.marketplace.schema import OrganizationConfig, TeamConfig
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
# Exceptions
|
|
33
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TeamNotFoundError(KeyError):
|
|
37
|
+
"""Raised when requested team profile is not found in config."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, team_id: str, available_teams: list[str]) -> None:
|
|
40
|
+
self.team_id = team_id
|
|
41
|
+
self.available_teams = available_teams
|
|
42
|
+
teams_str = ", ".join(sorted(available_teams)) if available_teams else "none"
|
|
43
|
+
super().__init__(
|
|
44
|
+
f"Team '{team_id}' not found in organization config. Available teams: {teams_str}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
# Dataclasses
|
|
50
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class BlockedPlugin:
|
|
55
|
+
"""A plugin blocked by security policy.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
plugin_id: The canonical plugin reference (name@marketplace)
|
|
59
|
+
reason: Human-readable explanation from security config
|
|
60
|
+
pattern: The glob pattern that matched this plugin
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
plugin_id: str
|
|
64
|
+
reason: str
|
|
65
|
+
pattern: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class EffectivePlugins:
|
|
70
|
+
"""Result of computing effective plugins for a team.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
enabled: Set of enabled plugin references (name@marketplace)
|
|
74
|
+
blocked: List of BlockedPlugin with reasons
|
|
75
|
+
not_allowed: Plugins rejected by allowed_plugins filter
|
|
76
|
+
disabled: Plugins removed by disabled_plugins patterns
|
|
77
|
+
extra_marketplaces: List of marketplace IDs to enable
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
enabled: set[str] = field(default_factory=set)
|
|
81
|
+
blocked: list[BlockedPlugin] = field(default_factory=list)
|
|
82
|
+
not_allowed: list[str] = field(default_factory=list)
|
|
83
|
+
disabled: list[str] = field(default_factory=list)
|
|
84
|
+
extra_marketplaces: list[str] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
# Computation
|
|
89
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compute_effective_plugins(
|
|
93
|
+
config: OrganizationConfig,
|
|
94
|
+
team_id: str,
|
|
95
|
+
) -> EffectivePlugins:
|
|
96
|
+
"""Compute effective plugins for a team based on organization config.
|
|
97
|
+
|
|
98
|
+
This is a pure function that determines which plugins a team member
|
|
99
|
+
can use, applying all governance rules in the correct order.
|
|
100
|
+
|
|
101
|
+
Order of operations:
|
|
102
|
+
1. Normalize all plugin references
|
|
103
|
+
2. Merge defaults + profile additional plugins
|
|
104
|
+
3. Apply disabled_plugins patterns
|
|
105
|
+
4. Apply allowed_plugins filter (additional only)
|
|
106
|
+
5. Apply security.blocked_plugins
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
config: Organization configuration with profiles and security
|
|
110
|
+
team_id: The profile/team ID to compute plugins for
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
EffectivePlugins with enabled, blocked, disabled, and marketplace info
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
TeamNotFoundError: If team_id is not in config.profiles
|
|
117
|
+
AmbiguousMarketplaceError: If bare plugin name with 2+ org marketplaces
|
|
118
|
+
"""
|
|
119
|
+
# Validate team exists
|
|
120
|
+
if team_id not in config.profiles:
|
|
121
|
+
raise TeamNotFoundError(
|
|
122
|
+
team_id=team_id,
|
|
123
|
+
available_teams=list(config.profiles.keys()),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
profile = config.profiles[team_id]
|
|
127
|
+
defaults = config.defaults
|
|
128
|
+
security = config.security
|
|
129
|
+
org_marketplaces = config.marketplaces or {}
|
|
130
|
+
|
|
131
|
+
result = EffectivePlugins()
|
|
132
|
+
|
|
133
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
134
|
+
# Step 1: Collect all plugins (normalized)
|
|
135
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
# Normalize defaults.enabled_plugins
|
|
138
|
+
default_plugins: set[str] = set()
|
|
139
|
+
if defaults and defaults.enabled_plugins:
|
|
140
|
+
for plugin_ref in defaults.enabled_plugins:
|
|
141
|
+
normalized = normalize_plugin(plugin_ref, org_marketplaces)
|
|
142
|
+
default_plugins.add(normalized)
|
|
143
|
+
|
|
144
|
+
# Normalize profile.additional_plugins
|
|
145
|
+
additional_plugins: set[str] = set()
|
|
146
|
+
if profile.additional_plugins:
|
|
147
|
+
for plugin_ref in profile.additional_plugins:
|
|
148
|
+
normalized = normalize_plugin(plugin_ref, org_marketplaces)
|
|
149
|
+
additional_plugins.add(normalized)
|
|
150
|
+
|
|
151
|
+
# Start with defaults as base
|
|
152
|
+
merged_plugins = default_plugins.copy()
|
|
153
|
+
|
|
154
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
155
|
+
# Step 2: Apply disabled_plugins patterns (remove from merged)
|
|
156
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
disabled_patterns = profile.disabled_plugins or []
|
|
159
|
+
for plugin in list(merged_plugins):
|
|
160
|
+
for pattern in disabled_patterns:
|
|
161
|
+
if matches_pattern(plugin, pattern):
|
|
162
|
+
merged_plugins.discard(plugin)
|
|
163
|
+
result.disabled.append(plugin)
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
167
|
+
# Step 3: Apply allowed_plugins filter to additional plugins
|
|
168
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
# allowed_plugins semantics:
|
|
171
|
+
# - None: allow all additional plugins
|
|
172
|
+
# - []: block all additional plugins
|
|
173
|
+
# - ["x", "y"]: only allow x and y from additional
|
|
174
|
+
allowed_plugins = profile.allowed_plugins
|
|
175
|
+
|
|
176
|
+
for plugin in additional_plugins:
|
|
177
|
+
# Check if already disabled
|
|
178
|
+
if plugin in result.disabled:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if allowed_plugins is None:
|
|
182
|
+
# Allow all
|
|
183
|
+
merged_plugins.add(plugin)
|
|
184
|
+
elif plugin in allowed_plugins:
|
|
185
|
+
# In allowlist
|
|
186
|
+
merged_plugins.add(plugin)
|
|
187
|
+
else:
|
|
188
|
+
# Not in allowlist (includes empty list case)
|
|
189
|
+
result.not_allowed.append(plugin)
|
|
190
|
+
|
|
191
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
192
|
+
# Step 4: Apply security.blocked_plugins (final security gate)
|
|
193
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
blocked_patterns = security.blocked_plugins if security else []
|
|
196
|
+
blocked_reason = security.blocked_reason if security else "Blocked by security policy"
|
|
197
|
+
|
|
198
|
+
for plugin in list(merged_plugins):
|
|
199
|
+
for pattern in blocked_patterns:
|
|
200
|
+
if matches_pattern(plugin, pattern):
|
|
201
|
+
merged_plugins.discard(plugin)
|
|
202
|
+
result.blocked.append(
|
|
203
|
+
BlockedPlugin(
|
|
204
|
+
plugin_id=plugin,
|
|
205
|
+
reason=blocked_reason,
|
|
206
|
+
pattern=pattern,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
212
|
+
# Step 5: Collect extra marketplaces
|
|
213
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
marketplace_set: set[str] = set()
|
|
216
|
+
|
|
217
|
+
if defaults and defaults.extra_marketplaces:
|
|
218
|
+
marketplace_set.update(defaults.extra_marketplaces)
|
|
219
|
+
|
|
220
|
+
if profile.extra_marketplaces:
|
|
221
|
+
marketplace_set.update(profile.extra_marketplaces)
|
|
222
|
+
|
|
223
|
+
result.extra_marketplaces = list(marketplace_set)
|
|
224
|
+
|
|
225
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
226
|
+
# Final result
|
|
227
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
result.enabled = merged_plugins
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
# Federated Computation
|
|
235
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def compute_effective_plugins_federated(
|
|
239
|
+
config: OrganizationConfig,
|
|
240
|
+
team_id: str,
|
|
241
|
+
team_config: TeamConfig,
|
|
242
|
+
) -> EffectivePlugins:
|
|
243
|
+
"""Compute effective plugins for a federated team (6-step precedence).
|
|
244
|
+
|
|
245
|
+
This handles teams with external config_source. The key difference from
|
|
246
|
+
inline teams is that federated teams:
|
|
247
|
+
- Use TeamConfig.enabled_plugins instead of profile.additional_plugins
|
|
248
|
+
- Use TeamConfig.disabled_plugins for team-level filtering
|
|
249
|
+
- Are NOT subject to allowed_plugins restrictions (that's for inline only)
|
|
250
|
+
- Are ALWAYS subject to org security.blocked_plugins
|
|
251
|
+
|
|
252
|
+
Order of operations (6-step precedence):
|
|
253
|
+
1. Start with org defaults.enabled_plugins
|
|
254
|
+
2. Add team config enabled_plugins
|
|
255
|
+
3. Apply team config disabled_plugins patterns
|
|
256
|
+
4. Apply org defaults.disabled_plugins patterns
|
|
257
|
+
5. SKIP allowed_plugins (federated teams not subject to inline restrictions)
|
|
258
|
+
6. Apply org security.blocked_plugins (ALWAYS enforced)
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
config: Organization configuration with profiles and security
|
|
262
|
+
team_id: The profile/team ID to compute plugins for
|
|
263
|
+
team_config: External team configuration fetched from config_source
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
EffectivePlugins with enabled, blocked, disabled, and marketplace info
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
TeamNotFoundError: If team_id is not in config.profiles
|
|
270
|
+
"""
|
|
271
|
+
# Validate team exists in org config
|
|
272
|
+
if team_id not in config.profiles:
|
|
273
|
+
raise TeamNotFoundError(
|
|
274
|
+
team_id=team_id,
|
|
275
|
+
available_teams=list(config.profiles.keys()),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
profile = config.profiles[team_id]
|
|
279
|
+
defaults = config.defaults
|
|
280
|
+
security = config.security
|
|
281
|
+
org_marketplaces = config.marketplaces or {}
|
|
282
|
+
|
|
283
|
+
result = EffectivePlugins()
|
|
284
|
+
|
|
285
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
286
|
+
# Step 1: Start with org defaults.enabled_plugins
|
|
287
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
merged_plugins: set[str] = set()
|
|
290
|
+
if defaults and defaults.enabled_plugins:
|
|
291
|
+
for plugin_ref in defaults.enabled_plugins:
|
|
292
|
+
normalized = normalize_plugin(plugin_ref, org_marketplaces)
|
|
293
|
+
merged_plugins.add(normalized)
|
|
294
|
+
|
|
295
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
296
|
+
# Step 2: Add team config enabled_plugins
|
|
297
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
if team_config.enabled_plugins:
|
|
300
|
+
for plugin_ref in team_config.enabled_plugins:
|
|
301
|
+
normalized = normalize_plugin(plugin_ref, org_marketplaces)
|
|
302
|
+
merged_plugins.add(normalized)
|
|
303
|
+
|
|
304
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
305
|
+
# Step 3: Apply team config disabled_plugins patterns
|
|
306
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
team_disabled_patterns = team_config.disabled_plugins or []
|
|
309
|
+
for plugin in list(merged_plugins):
|
|
310
|
+
for pattern in team_disabled_patterns:
|
|
311
|
+
if matches_pattern(plugin, pattern):
|
|
312
|
+
merged_plugins.discard(plugin)
|
|
313
|
+
result.disabled.append(plugin)
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
317
|
+
# Step 4: Apply org defaults.disabled_plugins patterns
|
|
318
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
org_disabled_patterns = (defaults.disabled_plugins or []) if defaults else []
|
|
321
|
+
for plugin in list(merged_plugins):
|
|
322
|
+
for pattern in org_disabled_patterns:
|
|
323
|
+
if matches_pattern(plugin, pattern):
|
|
324
|
+
merged_plugins.discard(plugin)
|
|
325
|
+
result.disabled.append(plugin)
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
329
|
+
# Step 5: SKIP allowed_plugins (federated teams not subject to this)
|
|
330
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
# For federated teams, we do NOT apply the allowed_plugins filter.
|
|
333
|
+
# This restriction is only for inline teams using additional_plugins.
|
|
334
|
+
# Federated teams have their own governance via trust grants.
|
|
335
|
+
|
|
336
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
337
|
+
# Step 6: Apply org security.blocked_plugins (ALWAYS enforced)
|
|
338
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
blocked_patterns = security.blocked_plugins if security else []
|
|
341
|
+
blocked_reason = security.blocked_reason if security else "Blocked by security policy"
|
|
342
|
+
|
|
343
|
+
for plugin in list(merged_plugins):
|
|
344
|
+
for pattern in blocked_patterns:
|
|
345
|
+
if matches_pattern(plugin, pattern):
|
|
346
|
+
merged_plugins.discard(plugin)
|
|
347
|
+
result.blocked.append(
|
|
348
|
+
BlockedPlugin(
|
|
349
|
+
plugin_id=plugin,
|
|
350
|
+
reason=blocked_reason,
|
|
351
|
+
pattern=pattern,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
357
|
+
# Collect extra marketplaces (from defaults and profile only)
|
|
358
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
# Note: TeamConfig doesn't have extra_marketplaces - it defines
|
|
361
|
+
# actual marketplace sources via its 'marketplaces' dict instead.
|
|
362
|
+
marketplace_set: set[str] = set()
|
|
363
|
+
|
|
364
|
+
if defaults and defaults.extra_marketplaces:
|
|
365
|
+
marketplace_set.update(defaults.extra_marketplaces)
|
|
366
|
+
|
|
367
|
+
if profile.extra_marketplaces:
|
|
368
|
+
marketplace_set.update(profile.extra_marketplaces)
|
|
369
|
+
|
|
370
|
+
result.extra_marketplaces = list(marketplace_set)
|
|
371
|
+
|
|
372
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
373
|
+
# Final result
|
|
374
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
result.enabled = merged_plugins
|
|
377
|
+
return result
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants for marketplace and plugin management.
|
|
3
|
+
|
|
4
|
+
This module defines:
|
|
5
|
+
- IMPLICIT_MARKETPLACES: Built-in marketplaces that Claude Code knows about
|
|
6
|
+
- EXIT_CODES: Semantic exit codes for marketplace operations
|
|
7
|
+
- Path constants for cache and state files
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
# Implicit Marketplaces
|
|
14
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
# Marketplaces that Claude Code supports natively without explicit configuration.
|
|
17
|
+
# These are NEVER written to settings.local.json and don't count toward ambiguity
|
|
18
|
+
# when resolving unqualified plugin names.
|
|
19
|
+
#
|
|
20
|
+
# Per research.md RQ-10:
|
|
21
|
+
# - Implicit marketplaces are always available
|
|
22
|
+
# - Don't need to be written to extraKnownMarketplaces
|
|
23
|
+
# - Unqualified plugins can resolve here when no org marketplaces exist
|
|
24
|
+
IMPLICIT_MARKETPLACES: Final[frozenset[str]] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"claude-plugins-official",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
# Exit Codes
|
|
33
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
# Semantic exit codes for marketplace operations.
|
|
36
|
+
# These align with existing SCC exit code conventions.
|
|
37
|
+
#
|
|
38
|
+
# Usage:
|
|
39
|
+
# from scc_cli.marketplace.constants import EXIT_CODES
|
|
40
|
+
# sys.exit(EXIT_CODES["validation_error"])
|
|
41
|
+
EXIT_CODES: Final[dict[str, int]] = {
|
|
42
|
+
# Success
|
|
43
|
+
"success": 0,
|
|
44
|
+
# User errors (2)
|
|
45
|
+
"validation_error": 2, # Schema validation failed
|
|
46
|
+
"invalid_plugin_ref": 2, # Malformed plugin reference
|
|
47
|
+
"ambiguous_marketplace": 2, # Plugin ref needs explicit @marketplace
|
|
48
|
+
# Prerequisite errors (3)
|
|
49
|
+
"network_error": 3, # Cannot fetch remote config
|
|
50
|
+
"git_unavailable": 3, # Git not installed for git: sources
|
|
51
|
+
# Tool errors (4)
|
|
52
|
+
"git_clone_failed": 4, # Git clone/fetch failed
|
|
53
|
+
"http_fetch_failed": 4, # HTTP download failed
|
|
54
|
+
"materialization_failed": 4, # Failed to materialize marketplace
|
|
55
|
+
# Internal errors (5)
|
|
56
|
+
"internal_error": 5, # Bug in SCC
|
|
57
|
+
# Policy violations (6)
|
|
58
|
+
"blocked_by_policy": 6, # Plugin blocked by security policy
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
# Path Constants
|
|
64
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
# Directory name for materialized marketplaces (under project's .claude/)
|
|
67
|
+
# Per research.md RQ-2: Must be project-local for Docker sandbox visibility
|
|
68
|
+
MARKETPLACE_CACHE_DIR: Final[str] = ".scc-marketplaces"
|
|
69
|
+
|
|
70
|
+
# Filename for tracking SCC-managed entries in settings
|
|
71
|
+
# Per research.md RQ-7: Enables non-destructive merge
|
|
72
|
+
MANAGED_STATE_FILE: Final[str] = ".scc-managed.json"
|
|
73
|
+
|
|
74
|
+
# Filename for marketplace manifest tracking materialization state
|
|
75
|
+
MANIFEST_FILE: Final[str] = ".manifest.json"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
# TTL and Caching
|
|
80
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
# Default TTL for org config freshness (24 hours in seconds)
|
|
83
|
+
# Per research.md RQ-6: Time-based staleness detection
|
|
84
|
+
DEFAULT_ORG_CONFIG_TTL_SECONDS: Final[int] = 24 * 60 * 60
|
|
85
|
+
|
|
86
|
+
# Minimum TTL to prevent excessive re-fetching (1 hour)
|
|
87
|
+
MIN_ORG_CONFIG_TTL_SECONDS: Final[int] = 1 * 60 * 60
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Managed state tracking for SCC marketplace integration.
|
|
3
|
+
|
|
4
|
+
This module tracks what SCC has added to settings.local.json, enabling
|
|
5
|
+
non-destructive merges that preserve user customizations.
|
|
6
|
+
|
|
7
|
+
Key responsibilities:
|
|
8
|
+
1. ManagedState - Data structure tracking SCC-managed entries
|
|
9
|
+
2. load_managed_state() - Load tracking state from .scc-managed.json
|
|
10
|
+
3. save_managed_state() - Persist tracking state to disk
|
|
11
|
+
4. clear_managed_state() - Remove tracking state (for reset operations)
|
|
12
|
+
|
|
13
|
+
Per RQ-7: Non-destructive merge preserves user-added plugins/marketplaces.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from scc_cli.marketplace.constants import MANAGED_STATE_FILE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ManagedState:
|
|
29
|
+
"""Tracks what SCC has added to settings.local.json.
|
|
30
|
+
|
|
31
|
+
This state enables non-destructive merging:
|
|
32
|
+
- On sync: Remove entries in managed_plugins/marketplaces, then add new ones
|
|
33
|
+
- User customizations: Entries NOT in managed lists are preserved
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
managed_plugins: Plugin references (name@marketplace) managed by SCC
|
|
37
|
+
managed_marketplaces: Marketplace paths managed by SCC
|
|
38
|
+
last_sync: Timestamp of last successful sync
|
|
39
|
+
org_config_url: URL of the org config that was synced
|
|
40
|
+
team_id: Team ID that was selected during sync
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
managed_plugins: list[str] = field(default_factory=list)
|
|
44
|
+
managed_marketplaces: list[str] = field(default_factory=list)
|
|
45
|
+
last_sync: datetime | None = None
|
|
46
|
+
org_config_url: str | None = None
|
|
47
|
+
team_id: str | None = None
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, Any]:
|
|
50
|
+
"""Serialize to dictionary for JSON storage."""
|
|
51
|
+
result: dict[str, Any] = {
|
|
52
|
+
"managed_plugins": self.managed_plugins,
|
|
53
|
+
"managed_marketplaces": self.managed_marketplaces,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if self.last_sync is not None:
|
|
57
|
+
result["last_sync"] = self.last_sync.isoformat()
|
|
58
|
+
|
|
59
|
+
if self.org_config_url is not None:
|
|
60
|
+
result["org_config_url"] = self.org_config_url
|
|
61
|
+
|
|
62
|
+
if self.team_id is not None:
|
|
63
|
+
result["team_id"] = self.team_id
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, data: dict[str, Any]) -> ManagedState:
|
|
69
|
+
"""Deserialize from dictionary."""
|
|
70
|
+
last_sync = None
|
|
71
|
+
if "last_sync" in data and data["last_sync"]:
|
|
72
|
+
try:
|
|
73
|
+
last_sync = datetime.fromisoformat(data["last_sync"])
|
|
74
|
+
except (ValueError, TypeError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
return cls(
|
|
78
|
+
managed_plugins=data.get("managed_plugins", []),
|
|
79
|
+
managed_marketplaces=data.get("managed_marketplaces", []),
|
|
80
|
+
last_sync=last_sync,
|
|
81
|
+
org_config_url=data.get("org_config_url"),
|
|
82
|
+
team_id=data.get("team_id"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_managed_state(project_dir: Path) -> ManagedState:
|
|
87
|
+
"""Load managed state from .scc-managed.json.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
project_dir: Project root directory
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
ManagedState with tracking data, or empty state if file doesn't exist
|
|
94
|
+
"""
|
|
95
|
+
managed_path = project_dir / ".claude" / MANAGED_STATE_FILE
|
|
96
|
+
|
|
97
|
+
if not managed_path.exists():
|
|
98
|
+
return ManagedState()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
data: dict[str, Any] = json.loads(managed_path.read_text())
|
|
102
|
+
return ManagedState.from_dict(data)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
# Corrupted file - return empty state
|
|
105
|
+
return ManagedState()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_managed_state(project_dir: Path, state: ManagedState) -> None:
|
|
109
|
+
"""Save managed state to .scc-managed.json.
|
|
110
|
+
|
|
111
|
+
Creates .claude directory if it doesn't exist.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
project_dir: Project root directory
|
|
115
|
+
state: ManagedState to persist
|
|
116
|
+
"""
|
|
117
|
+
claude_dir = project_dir / ".claude"
|
|
118
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
managed_path = claude_dir / MANAGED_STATE_FILE
|
|
121
|
+
managed_path.write_text(json.dumps(state.to_dict(), indent=2))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def clear_managed_state(project_dir: Path) -> None:
|
|
125
|
+
"""Remove managed state file.
|
|
126
|
+
|
|
127
|
+
Used for reset operations. Preserves .claude directory and other files.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
project_dir: Project root directory
|
|
131
|
+
"""
|
|
132
|
+
managed_path = project_dir / ".claude" / MANAGED_STATE_FILE
|
|
133
|
+
|
|
134
|
+
if managed_path.exists():
|
|
135
|
+
managed_path.unlink()
|