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.
- 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 +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -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 +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -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/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -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 +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -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/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -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 +383 -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 +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -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 +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- 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
|