scc-cli 1.4.0__py3-none-any.whl

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

Potentially problematic release.


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

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