scc-cli 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (113) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +259 -0
  8. scc_cli/cli_admin.py +706 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1454 -0
  16. scc_cli/cli_org.py +1428 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +892 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +604 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1074 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1521 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/adapter.py +74 -0
  46. scc_cli/marketplace/compute.py +377 -0
  47. scc_cli/marketplace/constants.py +87 -0
  48. scc_cli/marketplace/managed.py +135 -0
  49. scc_cli/marketplace/materialize.py +723 -0
  50. scc_cli/marketplace/normalize.py +548 -0
  51. scc_cli/marketplace/render.py +257 -0
  52. scc_cli/marketplace/resolve.py +459 -0
  53. scc_cli/marketplace/schema.py +506 -0
  54. scc_cli/marketplace/sync.py +260 -0
  55. scc_cli/marketplace/team_cache.py +195 -0
  56. scc_cli/marketplace/team_fetch.py +688 -0
  57. scc_cli/marketplace/trust.py +244 -0
  58. scc_cli/models/__init__.py +41 -0
  59. scc_cli/models/exceptions.py +273 -0
  60. scc_cli/models/plugin_audit.py +434 -0
  61. scc_cli/org_templates.py +269 -0
  62. scc_cli/output_mode.py +167 -0
  63. scc_cli/panels.py +113 -0
  64. scc_cli/platform.py +350 -0
  65. scc_cli/profiles.py +960 -0
  66. scc_cli/remote.py +443 -0
  67. scc_cli/schemas/__init__.py +1 -0
  68. scc_cli/schemas/org-v1.schema.json +456 -0
  69. scc_cli/schemas/team-config.v1.schema.json +163 -0
  70. scc_cli/sessions.py +425 -0
  71. scc_cli/setup.py +588 -0
  72. scc_cli/source_resolver.py +470 -0
  73. scc_cli/stats.py +378 -0
  74. scc_cli/stores/__init__.py +13 -0
  75. scc_cli/stores/exception_store.py +251 -0
  76. scc_cli/subprocess_utils.py +88 -0
  77. scc_cli/teams.py +382 -0
  78. scc_cli/templates/__init__.py +2 -0
  79. scc_cli/templates/org/__init__.py +0 -0
  80. scc_cli/templates/org/minimal.json +19 -0
  81. scc_cli/templates/org/reference.json +74 -0
  82. scc_cli/templates/org/strict.json +38 -0
  83. scc_cli/templates/org/teams.json +42 -0
  84. scc_cli/templates/statusline.sh +75 -0
  85. scc_cli/theme.py +348 -0
  86. scc_cli/ui/__init__.py +124 -0
  87. scc_cli/ui/branding.py +68 -0
  88. scc_cli/ui/chrome.py +395 -0
  89. scc_cli/ui/dashboard/__init__.py +62 -0
  90. scc_cli/ui/dashboard/_dashboard.py +677 -0
  91. scc_cli/ui/dashboard/loaders.py +395 -0
  92. scc_cli/ui/dashboard/models.py +184 -0
  93. scc_cli/ui/dashboard/orchestrator.py +390 -0
  94. scc_cli/ui/formatters.py +443 -0
  95. scc_cli/ui/gate.py +350 -0
  96. scc_cli/ui/help.py +157 -0
  97. scc_cli/ui/keys.py +538 -0
  98. scc_cli/ui/list_screen.py +431 -0
  99. scc_cli/ui/picker.py +700 -0
  100. scc_cli/ui/prompts.py +200 -0
  101. scc_cli/ui/wizard.py +675 -0
  102. scc_cli/update.py +680 -0
  103. scc_cli/utils/__init__.py +39 -0
  104. scc_cli/utils/fixit.py +264 -0
  105. scc_cli/utils/fuzzy.py +124 -0
  106. scc_cli/utils/locks.py +101 -0
  107. scc_cli/utils/ttl.py +376 -0
  108. scc_cli/validate.py +455 -0
  109. scc_cli-1.4.1.dist-info/METADATA +369 -0
  110. scc_cli-1.4.1.dist-info/RECORD +113 -0
  111. scc_cli-1.4.1.dist-info/WHEEL +4 -0
  112. scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
  113. scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
@@ -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()