scc-cli 1.5.3__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,846 @@
1
+ """
2
+ Marketplace materialization for SCC.
3
+
4
+ This module provides marketplace source materialization:
5
+ - MaterializedMarketplace: Dataclass tracking materialized marketplace state
6
+ - load_manifest/save_manifest: Manifest management for cache tracking
7
+ - materialize_*: Handlers for different source types (github, git, directory, url)
8
+ - materialize_marketplace: Dispatcher for source-type routing
9
+
10
+ Materialization Process:
11
+ 1. Check manifest for existing cache
12
+ 2. Determine if refresh needed (TTL, force)
13
+ 3. Clone/copy/download based on source type
14
+ 4. Validate .claude-plugin/marketplace.json exists
15
+ 5. Update manifest with new state
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path, PurePosixPath, PureWindowsPath
27
+ from typing import Any
28
+
29
+ from scc_cli.marketplace.constants import (
30
+ DEFAULT_ORG_CONFIG_TTL_SECONDS,
31
+ MANIFEST_FILE,
32
+ MARKETPLACE_CACHE_DIR,
33
+ )
34
+ from scc_cli.marketplace.schema import (
35
+ MarketplaceSource,
36
+ MarketplaceSourceDirectory,
37
+ MarketplaceSourceGit,
38
+ MarketplaceSourceGitHub,
39
+ MarketplaceSourceURL,
40
+ )
41
+
42
+ # ─────────────────────────────────────────────────────────────────────────────
43
+ # Exceptions
44
+ # ─────────────────────────────────────────────────────────────────────────────
45
+
46
+
47
+ class MaterializationError(Exception):
48
+ """Base exception for materialization failures."""
49
+
50
+ def __init__(self, message: str, marketplace_name: str | None = None) -> None:
51
+ self.marketplace_name = marketplace_name
52
+ super().__init__(message)
53
+
54
+
55
+ class GitNotAvailableError(MaterializationError):
56
+ """Raised when git is not installed but required."""
57
+
58
+ def __init__(self) -> None:
59
+ super().__init__(
60
+ "git is required for cloning marketplace repositories but was not found. "
61
+ "Please install git: https://git-scm.com/downloads"
62
+ )
63
+
64
+
65
+ class InvalidMarketplaceError(MaterializationError):
66
+ """Raised when marketplace structure is invalid."""
67
+
68
+ def __init__(self, marketplace_name: str, reason: str) -> None:
69
+ super().__init__(
70
+ f"Invalid marketplace '{marketplace_name}': {reason}. "
71
+ "A valid marketplace must contain .claude-plugin/marketplace.json",
72
+ marketplace_name,
73
+ )
74
+
75
+
76
+ def _validate_marketplace_name(name: str) -> None:
77
+ """Validate marketplace name for safe filesystem usage."""
78
+ if not name or not name.strip():
79
+ raise InvalidMarketplaceError(name, "marketplace name cannot be empty")
80
+ if name in {".", ".."}:
81
+ raise InvalidMarketplaceError(name, "marketplace name cannot be '.' or '..'")
82
+ if "/" in name or "\\" in name:
83
+ raise InvalidMarketplaceError(name, "marketplace name cannot contain path separators")
84
+ if "\x00" in name:
85
+ raise InvalidMarketplaceError(name, "marketplace name cannot contain null bytes")
86
+
87
+
88
+ # ─────────────────────────────────────────────────────────────────────────────
89
+ # Dataclasses
90
+ # ─────────────────────────────────────────────────────────────────────────────
91
+
92
+
93
+ @dataclass
94
+ class MaterializedMarketplace:
95
+ """A marketplace that has been materialized to local filesystem.
96
+
97
+ Attributes:
98
+ name: Marketplace identifier (matches org config key - the "alias")
99
+ canonical_name: The actual name from marketplace.json (what Claude Code sees)
100
+ relative_path: Path relative to project root (for Docker compatibility)
101
+ source_type: Source type (github, git, directory, url)
102
+ source_url: Original source URL or path
103
+ source_ref: Git branch/tag or None for non-git sources
104
+ materialization_mode: How content was fetched (full, metadata_only, etc)
105
+ materialized_at: When the marketplace was last materialized
106
+ commit_sha: Git commit SHA (for git sources) or None
107
+ etag: HTTP ETag (for URL sources) or None
108
+ plugins_available: List of plugin names discovered in marketplace
109
+ """
110
+
111
+ name: str
112
+ canonical_name: str # Name from marketplace.json - used by Claude Code
113
+ relative_path: str
114
+ source_type: str
115
+ source_url: str
116
+ source_ref: str | None
117
+ materialization_mode: str
118
+ materialized_at: datetime
119
+ commit_sha: str | None
120
+ etag: str | None
121
+ plugins_available: list[str] = field(default_factory=list)
122
+
123
+ def to_dict(self) -> dict[str, Any]:
124
+ """Serialize to dictionary for JSON storage."""
125
+ return {
126
+ "name": self.name,
127
+ "canonical_name": self.canonical_name,
128
+ "relative_path": self.relative_path,
129
+ "source_type": self.source_type,
130
+ "source_url": self.source_url,
131
+ "source_ref": self.source_ref,
132
+ "materialization_mode": self.materialization_mode,
133
+ "materialized_at": self.materialized_at.isoformat(),
134
+ "commit_sha": self.commit_sha,
135
+ "etag": self.etag,
136
+ "plugins_available": self.plugins_available,
137
+ }
138
+
139
+ @classmethod
140
+ def from_dict(cls, data: dict[str, Any]) -> MaterializedMarketplace:
141
+ """Deserialize from dictionary loaded from JSON."""
142
+ materialized_at = data.get("materialized_at")
143
+ if isinstance(materialized_at, str):
144
+ materialized_at = datetime.fromisoformat(materialized_at)
145
+ else:
146
+ materialized_at = datetime.now(timezone.utc)
147
+
148
+ # canonical_name defaults to name for backward compatibility with old manifests
149
+ name = data["name"]
150
+ canonical_name = data.get("canonical_name", name)
151
+
152
+ return cls(
153
+ name=name,
154
+ canonical_name=canonical_name,
155
+ relative_path=data["relative_path"],
156
+ source_type=data["source_type"],
157
+ source_url=data["source_url"],
158
+ source_ref=data.get("source_ref"),
159
+ materialization_mode=data.get("materialization_mode", "full"),
160
+ materialized_at=materialized_at,
161
+ commit_sha=data.get("commit_sha"),
162
+ etag=data.get("etag"),
163
+ plugins_available=data.get("plugins_available", []),
164
+ )
165
+
166
+
167
+ @dataclass
168
+ class CloneResult:
169
+ """Result of a git clone operation."""
170
+
171
+ success: bool
172
+ commit_sha: str | None = None
173
+ plugins: list[str] | None = None
174
+ canonical_name: str | None = None # Name from marketplace.json
175
+ error: str | None = None
176
+
177
+
178
+ @dataclass
179
+ class DownloadResult:
180
+ """Result of a URL download operation."""
181
+
182
+ success: bool
183
+ etag: str | None = None
184
+ plugins: list[str] | None = None
185
+ canonical_name: str | None = None # Name from marketplace.json
186
+ error: str | None = None
187
+
188
+
189
+ @dataclass
190
+ class DiscoveryResult:
191
+ """Result of discovering plugins and metadata from a marketplace."""
192
+
193
+ plugins: list[str]
194
+ canonical_name: str # The 'name' field from marketplace.json
195
+
196
+
197
+ # ─────────────────────────────────────────────────────────────────────────────
198
+ # Manifest Management
199
+ # ─────────────────────────────────────────────────────────────────────────────
200
+
201
+
202
+ def _get_manifest_path(project_dir: Path) -> Path:
203
+ """Get path to manifest file."""
204
+ return project_dir / ".claude" / MARKETPLACE_CACHE_DIR / MANIFEST_FILE
205
+
206
+
207
+ def load_manifest(project_dir: Path) -> dict[str, MaterializedMarketplace]:
208
+ """Load manifest from project's .claude/.scc-marketplaces/.manifest.json.
209
+
210
+ Args:
211
+ project_dir: Project root directory
212
+
213
+ Returns:
214
+ Dict mapping marketplace names to MaterializedMarketplace instances
215
+ Empty dict if manifest doesn't exist
216
+ """
217
+ manifest_path = _get_manifest_path(project_dir)
218
+
219
+ if not manifest_path.exists():
220
+ return {}
221
+
222
+ try:
223
+ data = json.loads(manifest_path.read_text())
224
+ return {name: MaterializedMarketplace.from_dict(entry) for name, entry in data.items()}
225
+ except (json.JSONDecodeError, KeyError):
226
+ return {}
227
+
228
+
229
+ def save_manifest(
230
+ project_dir: Path,
231
+ marketplaces: dict[str, MaterializedMarketplace],
232
+ ) -> None:
233
+ """Save manifest to project's .claude/.scc-marketplaces/.manifest.json.
234
+
235
+ Args:
236
+ project_dir: Project root directory
237
+ marketplaces: Dict mapping marketplace names to instances
238
+ """
239
+ manifest_path = _get_manifest_path(project_dir)
240
+
241
+ # Ensure directory exists
242
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
243
+
244
+ data = {name: mp.to_dict() for name, mp in marketplaces.items()}
245
+ manifest_path.write_text(json.dumps(data, indent=2))
246
+
247
+
248
+ # ─────────────────────────────────────────────────────────────────────────────
249
+ # Cache Freshness
250
+ # ─────────────────────────────────────────────────────────────────────────────
251
+
252
+
253
+ def is_cache_fresh(
254
+ marketplace: MaterializedMarketplace,
255
+ ttl_seconds: int = DEFAULT_ORG_CONFIG_TTL_SECONDS,
256
+ ) -> bool:
257
+ """Check if cached marketplace is fresh enough to skip re-materialization.
258
+
259
+ Args:
260
+ marketplace: Existing materialized marketplace
261
+ ttl_seconds: Time-to-live in seconds
262
+
263
+ Returns:
264
+ True if cache is fresh, False if stale
265
+ """
266
+ age = datetime.now(timezone.utc) - marketplace.materialized_at
267
+ return age.total_seconds() < ttl_seconds
268
+
269
+
270
+ # ─────────────────────────────────────────────────────────────────────────────
271
+ # Git Operations
272
+ # ─────────────────────────────────────────────────────────────────────────────
273
+
274
+
275
+ def run_git_clone(
276
+ url: str,
277
+ target_dir: Path,
278
+ branch: str = "main",
279
+ depth: int = 1,
280
+ fallback_name: str = "",
281
+ ) -> CloneResult:
282
+ """Clone a git repository to target directory.
283
+
284
+ Args:
285
+ url: Git clone URL
286
+ target_dir: Directory to clone into
287
+ branch: Branch to checkout
288
+ depth: Clone depth (1 for shallow)
289
+ fallback_name: Fallback name if marketplace.json doesn't specify one
290
+
291
+ Returns:
292
+ CloneResult with success status, commit SHA, and canonical name
293
+ """
294
+ try:
295
+ # Clean target directory if exists
296
+ if target_dir.exists():
297
+ shutil.rmtree(target_dir)
298
+
299
+ # Clone with shallow depth for efficiency
300
+ cmd = [
301
+ "git",
302
+ "clone",
303
+ "--depth",
304
+ str(depth),
305
+ "--branch",
306
+ branch,
307
+ "--",
308
+ url,
309
+ str(target_dir),
310
+ ]
311
+
312
+ result = subprocess.run(
313
+ cmd,
314
+ capture_output=True,
315
+ text=True,
316
+ timeout=120,
317
+ )
318
+
319
+ if result.returncode != 0:
320
+ return CloneResult(
321
+ success=False,
322
+ error=result.stderr or "Clone failed",
323
+ )
324
+
325
+ # Get commit SHA
326
+ sha_result = subprocess.run(
327
+ ["git", "-C", str(target_dir), "rev-parse", "HEAD"],
328
+ capture_output=True,
329
+ text=True,
330
+ )
331
+ commit_sha = sha_result.stdout.strip() if sha_result.returncode == 0 else None
332
+
333
+ # Discover plugins and canonical name
334
+ discovery = _discover_plugins(target_dir, fallback_name=fallback_name)
335
+
336
+ if discovery is None:
337
+ return CloneResult(
338
+ success=False,
339
+ commit_sha=commit_sha,
340
+ error="Missing .claude-plugin/marketplace.json",
341
+ )
342
+
343
+ return CloneResult(
344
+ success=True,
345
+ commit_sha=commit_sha,
346
+ plugins=discovery.plugins,
347
+ canonical_name=discovery.canonical_name,
348
+ )
349
+
350
+ except FileNotFoundError:
351
+ raise GitNotAvailableError()
352
+ except subprocess.TimeoutExpired:
353
+ return CloneResult(
354
+ success=False,
355
+ error="Clone operation timed out",
356
+ )
357
+
358
+
359
+ def _discover_plugins(marketplace_dir: Path, fallback_name: str = "") -> DiscoveryResult | None:
360
+ """Discover plugins and canonical name from a marketplace directory.
361
+
362
+ Args:
363
+ marketplace_dir: Root of the marketplace
364
+ fallback_name: Name to use if marketplace.json doesn't specify one
365
+
366
+ Returns:
367
+ DiscoveryResult with plugins and canonical name, or None if structure is invalid
368
+ """
369
+ manifest_path = marketplace_dir / ".claude-plugin" / "marketplace.json"
370
+
371
+ if not manifest_path.exists():
372
+ return None
373
+
374
+ try:
375
+ data = json.loads(manifest_path.read_text())
376
+ plugins = data.get("plugins", [])
377
+ plugin_names = [p.get("name", "") for p in plugins if isinstance(p, dict)]
378
+
379
+ # Get canonical name from marketplace.json - this is what Claude Code uses
380
+ canonical_name = data.get("name", fallback_name)
381
+ if not canonical_name:
382
+ canonical_name = fallback_name
383
+
384
+ return DiscoveryResult(plugins=plugin_names, canonical_name=canonical_name)
385
+ except (json.JSONDecodeError, KeyError):
386
+ return DiscoveryResult(plugins=[], canonical_name=fallback_name)
387
+
388
+
389
+ # ─────────────────────────────────────────────────────────────────────────────
390
+ # URL Operations
391
+ # ─────────────────────────────────────────────────────────────────────────────
392
+
393
+
394
+ def download_and_extract(
395
+ url: str,
396
+ target_dir: Path,
397
+ headers: dict[str, str] | None = None,
398
+ fallback_name: str = "",
399
+ ) -> DownloadResult:
400
+ """Download and extract marketplace from URL.
401
+
402
+ Args:
403
+ url: HTTPS URL to download
404
+ target_dir: Directory to extract into
405
+ headers: Optional HTTP headers
406
+ fallback_name: Fallback name if marketplace.json doesn't specify one
407
+
408
+ Returns:
409
+ DownloadResult with success status, ETag, and canonical name
410
+ """
411
+ import tarfile
412
+ import tempfile
413
+
414
+ import requests
415
+
416
+ try:
417
+ # Download archive
418
+ response = requests.get(url, headers=headers, timeout=60)
419
+ response.raise_for_status()
420
+
421
+ etag = response.headers.get("ETag")
422
+
423
+ # Save to temp file
424
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") as tmp:
425
+ tmp.write(response.content)
426
+ tmp_path = Path(tmp.name)
427
+
428
+ try:
429
+ # Clean target directory if exists
430
+ if target_dir.exists():
431
+ shutil.rmtree(target_dir)
432
+ target_dir.mkdir(parents=True)
433
+
434
+ # Extract archive (path-safe)
435
+ with tarfile.open(tmp_path, "r:*") as tar:
436
+ safe_members: list[tarfile.TarInfo] = []
437
+ for member in tar.getmembers():
438
+ member_path = PurePosixPath(member.name)
439
+ windows_member_path = PureWindowsPath(member.name)
440
+ if member_path.is_absolute() or windows_member_path.is_absolute():
441
+ return DownloadResult(
442
+ success=False,
443
+ error=f"Unsafe archive member (absolute path): {member.name}",
444
+ )
445
+ if ".." in member_path.parts or ".." in windows_member_path.parts:
446
+ return DownloadResult(
447
+ success=False,
448
+ error=f"Unsafe archive member (path traversal): {member.name}",
449
+ )
450
+ if "" in member_path.parts or "" in windows_member_path.parts:
451
+ return DownloadResult(
452
+ success=False,
453
+ error=f"Unsafe archive member (empty path segment): {member.name}",
454
+ )
455
+ if "\\" in member.name or windows_member_path.drive:
456
+ return DownloadResult(
457
+ success=False,
458
+ error=f"Unsafe archive member (windows path): {member.name}",
459
+ )
460
+ if (
461
+ member.islnk()
462
+ or member.issym()
463
+ or member.ischr()
464
+ or member.isblk()
465
+ or member.isfifo()
466
+ ):
467
+ return DownloadResult(
468
+ success=False,
469
+ error=f"Unsafe archive member (link/device): {member.name}",
470
+ )
471
+ safe_members.append(member)
472
+
473
+ tar.extractall(target_dir, members=safe_members)
474
+
475
+ # Discover plugins and canonical name
476
+ discovery = _discover_plugins(target_dir, fallback_name=fallback_name)
477
+
478
+ if discovery is None:
479
+ return DownloadResult(
480
+ success=False,
481
+ error="Missing .claude-plugin/marketplace.json",
482
+ )
483
+
484
+ return DownloadResult(
485
+ success=True,
486
+ etag=etag,
487
+ plugins=discovery.plugins,
488
+ canonical_name=discovery.canonical_name,
489
+ )
490
+ finally:
491
+ tmp_path.unlink(missing_ok=True)
492
+
493
+ except requests.RequestException as e:
494
+ return DownloadResult(
495
+ success=False,
496
+ error=str(e),
497
+ )
498
+
499
+
500
+ # ─────────────────────────────────────────────────────────────────────────────
501
+ # Materialization Handlers
502
+ # ─────────────────────────────────────────────────────────────────────────────
503
+
504
+
505
+ def _get_relative_path(name: str) -> str:
506
+ """Get relative path for a marketplace."""
507
+ _validate_marketplace_name(name)
508
+ return f".claude/{MARKETPLACE_CACHE_DIR}/{name}"
509
+
510
+
511
+ def _get_absolute_path(project_dir: Path, name: str) -> Path:
512
+ """Get absolute path for a marketplace."""
513
+ _validate_marketplace_name(name)
514
+ return project_dir / ".claude" / MARKETPLACE_CACHE_DIR / name
515
+
516
+
517
+ def materialize_github(
518
+ name: str,
519
+ source: dict[str, Any] | MarketplaceSourceGitHub,
520
+ project_dir: Path,
521
+ ) -> MaterializedMarketplace:
522
+ """Materialize a GitHub marketplace source.
523
+
524
+ Args:
525
+ name: Marketplace name (key in org config) - the "alias"
526
+ source: GitHub source configuration
527
+ project_dir: Project root directory
528
+
529
+ Returns:
530
+ MaterializedMarketplace with materialization details including canonical_name
531
+
532
+ Raises:
533
+ MaterializationError: On clone failure
534
+ GitNotAvailableError: When git is not installed
535
+ InvalidMarketplaceError: When marketplace structure is invalid
536
+ """
537
+ # Normalize source to dict
538
+ if hasattr(source, "model_dump"):
539
+ source_dict = source.model_dump()
540
+ else:
541
+ source_dict = dict(source)
542
+
543
+ owner = source_dict.get("owner", "")
544
+ repo = source_dict.get("repo", "")
545
+ branch = source_dict.get("branch", "main")
546
+ # Note: path is parsed but not used yet - could be used for subdir cloning later
547
+ _ = source_dict.get("path", "/")
548
+
549
+ url = f"https://github.com/{owner}/{repo}.git"
550
+ target_dir = _get_absolute_path(project_dir, name)
551
+
552
+ try:
553
+ # Pass name as fallback in case marketplace.json doesn't specify one
554
+ result = run_git_clone(url, target_dir, branch=branch, depth=1, fallback_name=name)
555
+ except FileNotFoundError:
556
+ raise GitNotAvailableError()
557
+
558
+ if not result.success:
559
+ if result.error and "marketplace.json" in result.error:
560
+ raise InvalidMarketplaceError(name, result.error)
561
+ raise MaterializationError(result.error or "Clone failed", name)
562
+
563
+ # canonical_name comes from marketplace.json, fallback to alias name
564
+ canonical_name = result.canonical_name or name
565
+
566
+ return MaterializedMarketplace(
567
+ name=name,
568
+ canonical_name=canonical_name,
569
+ relative_path=_get_relative_path(name),
570
+ source_type="github",
571
+ source_url=url,
572
+ source_ref=branch,
573
+ materialization_mode="full",
574
+ materialized_at=datetime.now(timezone.utc),
575
+ commit_sha=result.commit_sha,
576
+ etag=None,
577
+ plugins_available=result.plugins or [],
578
+ )
579
+
580
+
581
+ def materialize_git(
582
+ name: str,
583
+ source: dict[str, Any] | MarketplaceSourceGit,
584
+ project_dir: Path,
585
+ ) -> MaterializedMarketplace:
586
+ """Materialize a generic git marketplace source.
587
+
588
+ Args:
589
+ name: Marketplace name (key in org config) - the "alias"
590
+ source: Git source configuration
591
+ project_dir: Project root directory
592
+
593
+ Returns:
594
+ MaterializedMarketplace with materialization details including canonical_name
595
+
596
+ Raises:
597
+ MaterializationError: On clone failure
598
+ GitNotAvailableError: When git is not installed
599
+ """
600
+ if hasattr(source, "model_dump"):
601
+ source_dict = source.model_dump()
602
+ else:
603
+ source_dict = dict(source)
604
+
605
+ url = source_dict.get("url", "")
606
+ branch = source_dict.get("branch", "main")
607
+
608
+ target_dir = _get_absolute_path(project_dir, name)
609
+
610
+ # Pass name as fallback in case marketplace.json doesn't specify one
611
+ result = run_git_clone(url, target_dir, branch=branch, depth=1, fallback_name=name)
612
+
613
+ if not result.success:
614
+ if result.error and "marketplace.json" in result.error:
615
+ raise InvalidMarketplaceError(name, result.error)
616
+ raise MaterializationError(result.error or "Clone failed", name)
617
+
618
+ # canonical_name comes from marketplace.json, fallback to alias name
619
+ canonical_name = result.canonical_name or name
620
+
621
+ return MaterializedMarketplace(
622
+ name=name,
623
+ canonical_name=canonical_name,
624
+ relative_path=_get_relative_path(name),
625
+ source_type="git",
626
+ source_url=url,
627
+ source_ref=branch,
628
+ materialization_mode="full",
629
+ materialized_at=datetime.now(timezone.utc),
630
+ commit_sha=result.commit_sha,
631
+ etag=None,
632
+ plugins_available=result.plugins or [],
633
+ )
634
+
635
+
636
+ def materialize_directory(
637
+ name: str,
638
+ source: dict[str, Any] | MarketplaceSourceDirectory,
639
+ project_dir: Path,
640
+ ) -> MaterializedMarketplace:
641
+ """Materialize a local directory marketplace source.
642
+
643
+ Creates a symlink to the local directory for Docker visibility.
644
+
645
+ Args:
646
+ name: Marketplace name (key in org config) - the "alias"
647
+ source: Directory source configuration
648
+ project_dir: Project root directory
649
+
650
+ Returns:
651
+ MaterializedMarketplace with materialization details including canonical_name
652
+
653
+ Raises:
654
+ InvalidMarketplaceError: When marketplace structure is invalid
655
+ """
656
+ if hasattr(source, "model_dump"):
657
+ source_dict = source.model_dump()
658
+ else:
659
+ source_dict = dict(source)
660
+
661
+ source_path = Path(source_dict.get("path", ""))
662
+
663
+ # Resolve relative paths from project_dir
664
+ if not source_path.is_absolute():
665
+ source_path = project_dir / source_path
666
+
667
+ # Validate marketplace structure and discover canonical name
668
+ discovery = _discover_plugins(source_path, fallback_name=name)
669
+ if discovery is None:
670
+ raise InvalidMarketplaceError(
671
+ name,
672
+ "Missing .claude-plugin/marketplace.json",
673
+ )
674
+
675
+ # Create symlink in cache directory
676
+ target_dir = _get_absolute_path(project_dir, name)
677
+ target_dir.parent.mkdir(parents=True, exist_ok=True)
678
+
679
+ # Remove existing symlink/directory
680
+ if target_dir.exists() or target_dir.is_symlink():
681
+ if target_dir.is_symlink():
682
+ target_dir.unlink()
683
+ else:
684
+ shutil.rmtree(target_dir)
685
+
686
+ # Create symlink
687
+ os.symlink(source_path, target_dir)
688
+
689
+ return MaterializedMarketplace(
690
+ name=name,
691
+ canonical_name=discovery.canonical_name,
692
+ relative_path=_get_relative_path(name),
693
+ source_type="directory",
694
+ source_url=str(source_path),
695
+ source_ref=None,
696
+ materialization_mode="full",
697
+ materialized_at=datetime.now(timezone.utc),
698
+ commit_sha=None,
699
+ etag=None,
700
+ plugins_available=discovery.plugins,
701
+ )
702
+
703
+
704
+ def materialize_url(
705
+ name: str,
706
+ source: dict[str, Any] | MarketplaceSourceURL,
707
+ project_dir: Path,
708
+ ) -> MaterializedMarketplace:
709
+ """Materialize a URL marketplace source.
710
+
711
+ Args:
712
+ name: Marketplace name (key in org config) - the "alias"
713
+ source: URL source configuration
714
+ project_dir: Project root directory
715
+
716
+ Returns:
717
+ MaterializedMarketplace with materialization details including canonical_name
718
+
719
+ Raises:
720
+ MaterializationError: On download failure or HTTP URL (security)
721
+ """
722
+ if hasattr(source, "model_dump"):
723
+ source_dict = source.model_dump()
724
+ else:
725
+ source_dict = dict(source)
726
+
727
+ url = source_dict.get("url", "")
728
+ headers = source_dict.get("headers")
729
+ mode = source_dict.get("materialization_mode", "self_contained")
730
+
731
+ # Security: Require HTTPS
732
+ if not url.startswith("https://"):
733
+ raise MaterializationError(
734
+ f"URL must use HTTPS for security. Got: {url}",
735
+ name,
736
+ )
737
+
738
+ target_dir = _get_absolute_path(project_dir, name)
739
+
740
+ # Expand environment variables in headers
741
+ if headers:
742
+ headers = {k: os.path.expandvars(v) for k, v in headers.items()}
743
+
744
+ # Pass name as fallback in case marketplace.json doesn't specify one
745
+ result = download_and_extract(url, target_dir, headers=headers, fallback_name=name)
746
+
747
+ if not result.success:
748
+ raise MaterializationError(result.error or "Download failed", name)
749
+
750
+ # canonical_name comes from marketplace.json, fallback to alias name
751
+ canonical_name = result.canonical_name or name
752
+
753
+ return MaterializedMarketplace(
754
+ name=name,
755
+ canonical_name=canonical_name,
756
+ relative_path=_get_relative_path(name),
757
+ source_type="url",
758
+ source_url=url,
759
+ source_ref=None,
760
+ materialization_mode=mode,
761
+ materialized_at=datetime.now(timezone.utc),
762
+ commit_sha=None,
763
+ etag=result.etag,
764
+ plugins_available=result.plugins or [],
765
+ )
766
+
767
+
768
+ # ─────────────────────────────────────────────────────────────────────────────
769
+ # Dispatcher
770
+ # ─────────────────────────────────────────────────────────────────────────────
771
+
772
+
773
+ def materialize_marketplace(
774
+ name: str,
775
+ source: MarketplaceSource,
776
+ project_dir: Path,
777
+ force_refresh: bool = False,
778
+ ) -> MaterializedMarketplace:
779
+ """Materialize a marketplace source to local filesystem.
780
+
781
+ Routes to appropriate handler based on source type. Uses cached
782
+ version if fresh and force_refresh is False.
783
+
784
+ Args:
785
+ name: Marketplace name (key in org config)
786
+ source: Marketplace source configuration (discriminated union)
787
+ project_dir: Project root directory
788
+ force_refresh: Skip cache freshness check
789
+
790
+ Returns:
791
+ MaterializedMarketplace with materialization details
792
+
793
+ Raises:
794
+ MaterializationError: On materialization failure
795
+ """
796
+ # Check cache unless force refresh
797
+ if not force_refresh:
798
+ manifest = load_manifest(project_dir)
799
+ if name in manifest:
800
+ existing = manifest[name]
801
+ target_path = _get_absolute_path(project_dir, name)
802
+
803
+ if target_path.exists() and is_cache_fresh(existing):
804
+ # CRITICAL FIX: Re-read canonical_name from marketplace.json if it's
805
+ # missing or equals the alias name (indicating an old manifest entry)
806
+ # This ensures alias→canonical translation works with cached marketplaces
807
+ if existing.canonical_name == existing.name:
808
+ discovery = _discover_plugins(target_path, fallback_name=name)
809
+ if discovery and discovery.canonical_name != existing.name:
810
+ # Update the cached entry with the correct canonical name
811
+ existing = MaterializedMarketplace(
812
+ name=existing.name,
813
+ canonical_name=discovery.canonical_name,
814
+ relative_path=existing.relative_path,
815
+ source_type=existing.source_type,
816
+ source_url=existing.source_url,
817
+ source_ref=existing.source_ref,
818
+ materialization_mode=existing.materialization_mode,
819
+ materialized_at=existing.materialized_at,
820
+ commit_sha=existing.commit_sha,
821
+ etag=existing.etag,
822
+ plugins_available=existing.plugins_available,
823
+ )
824
+ # Persist the updated canonical_name for future runs
825
+ manifest[name] = existing
826
+ save_manifest(project_dir, manifest)
827
+ return existing
828
+
829
+ # Route to appropriate handler using isinstance for proper type narrowing
830
+ if isinstance(source, MarketplaceSourceGitHub):
831
+ result = materialize_github(name, source, project_dir)
832
+ elif isinstance(source, MarketplaceSourceGit):
833
+ result = materialize_git(name, source, project_dir)
834
+ elif isinstance(source, MarketplaceSourceDirectory):
835
+ result = materialize_directory(name, source, project_dir)
836
+ elif isinstance(source, MarketplaceSourceURL):
837
+ result = materialize_url(name, source, project_dir)
838
+ else:
839
+ raise MaterializationError(f"Unknown source type: {source.source}", name)
840
+
841
+ # Update manifest
842
+ manifest = load_manifest(project_dir)
843
+ manifest[name] = result
844
+ save_manifest(project_dir, manifest)
845
+
846
+ return result