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.
- 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 +706 -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 +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -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 +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -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 +1521 -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/adapter.py +74 -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 +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -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 +960 -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 +588 -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 +382 -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 +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -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 +538 -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 +675 -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.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.1.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
|