mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 01:05:27 -04:00
0187d14b83
`deepagents-code` now supports experimental plugin marketplaces with namespaced skills and MCP servers. Enable with `DEEPAGENTS_CODE_EXPERIMENTAL=1`. Docs: https://github.com/langchain-ai/docs/pull/4905 --- `deepagents-code` can now manage experimental plugin marketplaces and load installed plugins that contribute namespaced skills and MCP servers. The new plugin manager establishes the `tui/modals`, `tui/screens`, and `tui/widgets` UI convention, with colocated styling and focused state/content modules. - Supports local, GitHub, Git, and marketplace JSON sources. - Adds install, enable, disable, uninstall, and safe marketplace removal through `/plugins` and `dcode plugin`. - Reloads plugin skills and plugin MCP configuration with `/reload` when experimental mode is enabled. - Namespaces plugin skills as `{plugin_id}:{skill_name}` through a code-local `PluginSkillsMiddleware` adapter, so no SDK changes are required. Made by [Open SWE](https://openswe.vercel.app/agents/019f5dea-0f1f-73fe-9803-32d1b13fbd4c) --------- Signed-off-by: Johannes du Plessis <johannes@langchain.dev> Co-authored-by: Alexander Olsen <aolsenjazz@gmail.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Mason Daugherty <mason@langchain.dev> Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> Co-authored-by: Mason Daugherty <github@mdrxy.com>
806 lines
27 KiB
Python
806 lines
27 KiB
Python
"""Marketplace parsing for plugins."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess # noqa: S404 # Git is invoked with fixed argv and no shell.
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from urllib.parse import parse_qsl, unquote, urlencode, urlparse, urlunparse
|
|
|
|
from deepagents_code.plugins._json import json_object
|
|
from deepagents_code.plugins.manifest import _resolve_component_path, _validate_name
|
|
from deepagents_code.plugins.models import (
|
|
ExternalPluginRepositorySourceType,
|
|
GithubPluginSource,
|
|
GitSubdirectoryPluginSource,
|
|
JsonObject,
|
|
JsonValue,
|
|
LocalMarketplaceSource,
|
|
LocalPluginSource,
|
|
MarketplacePluginEntry,
|
|
MarketplaceSource,
|
|
PluginMarketplace,
|
|
PluginSource,
|
|
RepositoryMarketplaceSource,
|
|
UrlMarketplaceSource,
|
|
UrlPluginSource,
|
|
)
|
|
from deepagents_code.plugins.store import (
|
|
ensure_marketplace_cache_dir,
|
|
opaque_cache_key,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from http.client import HTTPMessage
|
|
from typing import IO
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MARKETPLACE_RELATIVE_PATHS = (
|
|
Path(".claude-plugin") / "marketplace.json",
|
|
Path(".agents") / "plugins" / "marketplace.json",
|
|
Path(".agents") / "plugins" / "api_marketplace.json",
|
|
)
|
|
# SCP-style Git source, optionally with a ref: `git@github.com:owner/repo.git#main`.
|
|
_SSH_GIT_RE = re.compile(r"^([A-Za-z0-9._-]+@[^:]+:.+?(?:\.git)?)(?:#(.+))?$")
|
|
# GitHub shorthand: `owner/repo`.
|
|
_GITHUB_REPO_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
|
_GIT_TIMEOUT_SECONDS = 120
|
|
_GITHUB_REPO_PART_COUNT = 2
|
|
_SENSITIVE_QUERY_TERMS = (
|
|
"credential",
|
|
"key",
|
|
"password",
|
|
"secret",
|
|
"signature",
|
|
"token",
|
|
)
|
|
_SENSITIVE_PATH_KEY_RE = re.compile(
|
|
r"^(?:access[-_.]?token|api[-_.]?key|credential|key|password|secret|signature|token)s?$",
|
|
re.IGNORECASE,
|
|
)
|
|
_HTTP_URL_RE = re.compile(r"https?://\S+")
|
|
|
|
|
|
class MarketplaceError(ValueError):
|
|
"""Raised when a marketplace cannot be loaded."""
|
|
|
|
|
|
def _redact_url_credentials(value: str) -> str:
|
|
"""Redact HTTP credentials while preserving a useful URL for logs.
|
|
|
|
For example, `https://user:pass@example.com/?token=x` becomes
|
|
`https://***@example.com/?token=%2A%2A%2A`. Non-HTTP values pass through.
|
|
A malformed HTTP URL is reduced to its scheme so error logging cannot leak it.
|
|
|
|
Returns:
|
|
The redacted URL, or a scheme-only placeholder when parsing fails.
|
|
"""
|
|
try:
|
|
parsed = urlparse(value)
|
|
except ValueError:
|
|
return "https://***" if value.startswith("https://") else "http://***"
|
|
if parsed.scheme not in {"http", "https"}:
|
|
return value
|
|
netloc = parsed.netloc
|
|
if "@" in netloc:
|
|
try:
|
|
host = parsed.hostname or ""
|
|
if parsed.port is not None:
|
|
host = f"{host}:{parsed.port}"
|
|
except ValueError:
|
|
return f"{parsed.scheme}://***"
|
|
netloc = f"***@{host}"
|
|
query = urlencode(
|
|
[
|
|
(
|
|
key,
|
|
"***"
|
|
if any(term in key.lower() for term in _SENSITIVE_QUERY_TERMS)
|
|
else item,
|
|
)
|
|
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
|
|
]
|
|
)
|
|
path_parts = parsed.path.split("/")
|
|
redact_next = False
|
|
for index, part in enumerate(path_parts):
|
|
if redact_next and part:
|
|
path_parts[index] = "***"
|
|
redact_next = False
|
|
elif part:
|
|
redact_next = _SENSITIVE_PATH_KEY_RE.fullmatch(unquote(part)) is not None
|
|
path = "/".join(path_parts)
|
|
return urlunparse(parsed._replace(netloc=netloc, path=path, query=query))
|
|
|
|
|
|
def redact_marketplace_source(value: str) -> str:
|
|
"""Return a marketplace source safe for display."""
|
|
return _redact_url_credentials(value)
|
|
|
|
|
|
def redact_urls_in_text(value: str) -> str:
|
|
"""Redact credentials from every HTTP URL embedded in text.
|
|
|
|
Returns:
|
|
Text with URL credentials replaced.
|
|
"""
|
|
return _HTTP_URL_RE.sub(
|
|
lambda match: _redact_url_credentials(match.group(0)), value
|
|
)
|
|
|
|
|
|
def parse_marketplace_source(raw: str) -> MarketplaceSource:
|
|
"""Parse a user-provided marketplace source.
|
|
|
|
Args:
|
|
raw: GitHub shorthand, Git URL, marketplace JSON URL, file, or directory.
|
|
|
|
Returns:
|
|
Parsed marketplace source.
|
|
|
|
Raises:
|
|
MarketplaceError: If the source string is empty or unsupported.
|
|
"""
|
|
value = raw.strip()
|
|
if not value:
|
|
msg = "Please enter a marketplace source"
|
|
raise MarketplaceError(msg)
|
|
|
|
ssh_match = _SSH_GIT_RE.match(value)
|
|
if ssh_match:
|
|
return RepositoryMarketplaceSource(
|
|
source_type="git", value=ssh_match.group(1), ref=ssh_match.group(2)
|
|
)
|
|
|
|
if value.startswith("http://"):
|
|
msg = "Remote marketplace sources must use https"
|
|
raise MarketplaceError(msg)
|
|
if value.startswith("https://"):
|
|
url, _, ref = value.partition("#")
|
|
try:
|
|
parsed = urlparse(url)
|
|
except ValueError as exc:
|
|
msg = "Invalid marketplace URL"
|
|
raise MarketplaceError(msg) from exc
|
|
path = parsed.path
|
|
if path.endswith(".git") or "/_git/" in path:
|
|
return RepositoryMarketplaceSource(
|
|
source_type="git", value=url, ref=ref or None
|
|
)
|
|
if parsed.hostname in {"github.com", "www.github.com"}:
|
|
parts = [part for part in path.split("/") if part]
|
|
if len(parts) == _GITHUB_REPO_PART_COUNT:
|
|
repo_path = "/".join(parts)
|
|
git_url = urlunparse(parsed._replace(path=f"/{repo_path}.git"))
|
|
return RepositoryMarketplaceSource(
|
|
source_type="git", value=git_url, ref=ref or None
|
|
)
|
|
if len(parts) > _GITHUB_REPO_PART_COUNT:
|
|
msg = "GitHub marketplace URLs must contain exactly owner/repo"
|
|
raise MarketplaceError(msg)
|
|
return UrlMarketplaceSource(source_type="url", value=url)
|
|
|
|
if value.startswith(("./", "../", "/", "~")):
|
|
return _marketplace_source_from_path(value)
|
|
|
|
# Bare relative paths such as `marketplace` (no ./ prefix) are accepted when
|
|
# they exist on disk, before GitHub-shorthand parsing.
|
|
candidate = Path(value).expanduser()
|
|
if candidate.exists():
|
|
return _marketplace_source_from_path(value)
|
|
|
|
repo, sep, ref = value.replace("#", "@", 1).partition("@")
|
|
if (
|
|
"/" in value
|
|
and ":" not in value
|
|
and not value.startswith("@")
|
|
and _GITHUB_REPO_RE.match(repo)
|
|
):
|
|
return RepositoryMarketplaceSource(
|
|
source_type="github", value=repo, ref=ref if sep else None
|
|
)
|
|
|
|
msg = "Invalid marketplace source format. Try: owner/repo, https://..., or ./path"
|
|
raise MarketplaceError(msg)
|
|
|
|
|
|
def _marketplace_source_from_path(value: str) -> MarketplaceSource:
|
|
path = Path(value).expanduser().resolve()
|
|
if not path.exists():
|
|
msg = f"Path does not exist: {path}"
|
|
raise MarketplaceError(msg)
|
|
if path.is_file():
|
|
if path.suffix != ".json":
|
|
msg = f"File path must point to a .json marketplace file: {path}"
|
|
raise MarketplaceError(msg)
|
|
return LocalMarketplaceSource(source_type="file", value=str(path))
|
|
if path.is_dir():
|
|
return LocalMarketplaceSource(source_type="directory", value=str(path))
|
|
msg = f"Path is neither a file nor a directory: {path}"
|
|
raise MarketplaceError(msg)
|
|
|
|
|
|
def _root_for_marketplace_file(path: Path) -> Path:
|
|
for relative in _MARKETPLACE_RELATIVE_PATHS:
|
|
if (
|
|
len(path.parts) >= len(relative.parts)
|
|
and path.parts[-len(relative.parts) :] == relative.parts
|
|
):
|
|
return path.parents[len(relative.parts) - 1]
|
|
return path.parent
|
|
|
|
|
|
def _load_marketplace_file(path: Path) -> PluginMarketplace:
|
|
root = _root_for_marketplace_file(path.expanduser().resolve())
|
|
return _load_marketplace_from_path(root, path.expanduser().resolve())
|
|
|
|
|
|
def _run_git(args: list[str]) -> None:
|
|
git_path = shutil.which("git")
|
|
if git_path is None:
|
|
msg = "Git is required to add repository-backed plugin marketplaces"
|
|
raise MarketplaceError(msg)
|
|
# Inherit normal Git configuration, but disable credential prompts because
|
|
# this subprocess has no interactive input.
|
|
env = {
|
|
**os.environ,
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"GIT_ASKPASS": "",
|
|
}
|
|
try:
|
|
result = subprocess.run( # noqa: S603 # Fixed git executable, no shell.
|
|
[git_path, *args],
|
|
check=False,
|
|
capture_output=True,
|
|
env=env,
|
|
text=True,
|
|
timeout=_GIT_TIMEOUT_SECONDS,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
msg = f"Failed to run git: {redact_urls_in_text(str(exc))}"
|
|
raise MarketplaceError(msg) from exc
|
|
if result.returncode != 0:
|
|
detail = result.stderr.strip() or result.stdout.strip() or "unknown git error"
|
|
msg = f"Git command failed: {redact_urls_in_text(detail)}"
|
|
raise MarketplaceError(msg)
|
|
|
|
|
|
def _clone_repository_to_cache(
|
|
source: RepositoryMarketplaceSource,
|
|
git_url: str,
|
|
*,
|
|
cache_key: str,
|
|
validate: Callable[[Path], None] | None = None,
|
|
) -> Path:
|
|
cache_path = ensure_marketplace_cache_dir() / (
|
|
f"repository-{opaque_cache_key(cache_key)}"
|
|
)
|
|
temp_path = Path(
|
|
tempfile.mkdtemp(prefix=f".{cache_path.name}.", dir=cache_path.parent)
|
|
)
|
|
args = ["clone", "--depth", "1", "--recurse-submodules", "--shallow-submodules"]
|
|
if source.ref:
|
|
args.extend(["--branch", source.ref])
|
|
args.extend([git_url, str(temp_path)])
|
|
try:
|
|
_run_git(args)
|
|
if validate is not None:
|
|
validate(temp_path)
|
|
backup_path = cache_path.with_name(f".{cache_path.name}.backup")
|
|
if backup_path.exists():
|
|
shutil.rmtree(backup_path, ignore_errors=True)
|
|
if cache_path.exists():
|
|
cache_path.replace(backup_path)
|
|
try:
|
|
temp_path.replace(cache_path)
|
|
except OSError:
|
|
if backup_path.exists() and not cache_path.exists():
|
|
backup_path.replace(cache_path)
|
|
raise
|
|
if backup_path.exists():
|
|
shutil.rmtree(backup_path, ignore_errors=True)
|
|
except Exception:
|
|
shutil.rmtree(temp_path, ignore_errors=True)
|
|
raise
|
|
return cache_path
|
|
|
|
|
|
def _materialize_marketplace_repository(
|
|
source: RepositoryMarketplaceSource, git_url: str
|
|
) -> Path:
|
|
return _clone_repository_to_cache(
|
|
source,
|
|
git_url,
|
|
cache_key=f"marketplace-{source.source_type}-{source.value}",
|
|
validate=_validate_marketplace_repository,
|
|
)
|
|
|
|
|
|
def _validate_marketplace_repository(root: Path) -> None:
|
|
load_marketplace(root)
|
|
|
|
|
|
def _materialize_plugin_repository(
|
|
source: RepositoryMarketplaceSource,
|
|
git_url: str,
|
|
*,
|
|
cache_key: str,
|
|
) -> Path:
|
|
return _clone_repository_to_cache(source, git_url, cache_key=cache_key)
|
|
|
|
|
|
class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(
|
|
self,
|
|
req: urllib.request.Request,
|
|
fp: IO[bytes],
|
|
code: int,
|
|
msg: str,
|
|
headers: HTTPMessage,
|
|
newurl: str,
|
|
) -> urllib.request.Request | None:
|
|
if urlparse(newurl).scheme != "https":
|
|
detail = _redact_url_credentials(newurl)
|
|
error = f"Marketplace redirect must use https: {detail}"
|
|
raise MarketplaceError(error)
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
def _download_marketplace(url: str) -> Path:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme != "https":
|
|
msg = f"Marketplace URL must use https: {_redact_url_credentials(url)}"
|
|
raise MarketplaceError(msg)
|
|
cache_path = (
|
|
ensure_marketplace_cache_dir() / f"marketplace-url-{opaque_cache_key(url)}.json"
|
|
)
|
|
request = urllib.request.Request( # noqa: S310 # Scheme is restricted above.
|
|
url, headers={"User-Agent": "dcode-plugin-manager"}
|
|
)
|
|
opener = urllib.request.build_opener(_HttpsOnlyRedirectHandler())
|
|
try:
|
|
with opener.open(request, timeout=10) as response:
|
|
final_url = response.geturl()
|
|
if urlparse(final_url).scheme != "https":
|
|
detail = _redact_url_credentials(final_url)
|
|
msg = f"Marketplace response must use https: {detail}"
|
|
raise MarketplaceError(msg)
|
|
data = json.load(response)
|
|
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
|
msg = (
|
|
"Failed to download marketplace from "
|
|
f"{_redact_url_credentials(url)}: {redact_urls_in_text(str(exc))}"
|
|
)
|
|
raise MarketplaceError(msg) from exc
|
|
if not isinstance(data, dict):
|
|
msg = (
|
|
f"Marketplace URL must return a JSON object: {_redact_url_credentials(url)}"
|
|
)
|
|
raise MarketplaceError(msg)
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
cache_path.write_text(
|
|
json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
)
|
|
return cache_path
|
|
|
|
|
|
def materialize_marketplace_source(
|
|
source: MarketplaceSource,
|
|
) -> tuple[PluginMarketplace, Path]:
|
|
"""Load a marketplace source and return its local install location.
|
|
|
|
Args:
|
|
source: Parsed marketplace source.
|
|
|
|
Returns:
|
|
Parsed marketplace and its local install location.
|
|
|
|
Raises:
|
|
MarketplaceError: If loading, cloning, downloading, or parsing fails.
|
|
"""
|
|
if source.source_type == "directory":
|
|
root = Path(source.value).expanduser().resolve()
|
|
return load_marketplace(root), root
|
|
if source.source_type == "file":
|
|
path = Path(source.value).expanduser().resolve()
|
|
return _load_marketplace_file(path), path
|
|
if source.source_type == "url":
|
|
path = _download_marketplace(source.value)
|
|
marketplace = _load_marketplace_file(path)
|
|
_reject_url_marketplace_with_local_plugins(marketplace, source.value)
|
|
return marketplace, path
|
|
if source.source_type == "github":
|
|
if not isinstance(source, RepositoryMarketplaceSource):
|
|
msg = "GitHub marketplace source is missing repository metadata"
|
|
raise MarketplaceError(msg)
|
|
root = _materialize_marketplace_repository(
|
|
source, f"https://github.com/{source.value}.git"
|
|
)
|
|
return load_marketplace(root), root
|
|
if source.source_type == "git":
|
|
if not isinstance(source, RepositoryMarketplaceSource):
|
|
msg = "Git marketplace source is missing repository metadata"
|
|
raise MarketplaceError(msg)
|
|
root = _materialize_marketplace_repository(source, source.value)
|
|
return load_marketplace(root), root
|
|
msg = f"Unsupported marketplace source type: {source.source_type}"
|
|
raise MarketplaceError(msg)
|
|
|
|
|
|
def _reject_url_marketplace_with_local_plugins(
|
|
marketplace: PluginMarketplace, url: str
|
|
) -> None:
|
|
"""Reject URL marketplaces whose plugins need a sibling filesystem tree.
|
|
|
|
Direct marketplace JSON URLs only cache the catalog file. Relative plugin
|
|
sources such as `./plugins/foo` cannot be resolved from that cache alone.
|
|
|
|
Raises:
|
|
MarketplaceError: If any plugin entry uses a local relative source.
|
|
"""
|
|
local_plugins = [
|
|
plugin.name
|
|
for plugin in marketplace.plugins
|
|
if _source_path(plugin.source) is not None
|
|
]
|
|
if not local_plugins:
|
|
unsupported = [
|
|
plugin.name
|
|
for plugin in marketplace.plugins
|
|
if _plugin_repository_source(plugin) is None
|
|
]
|
|
if not unsupported:
|
|
return
|
|
names = ", ".join(sorted(unsupported))
|
|
msg = (
|
|
f"Marketplace URL {_redact_url_credentials(url)} contains plugins "
|
|
f"with unsupported remote sources: [{names}]"
|
|
)
|
|
raise MarketplaceError(msg)
|
|
names = ", ".join(sorted(local_plugins))
|
|
msg = (
|
|
f"Marketplace URL {_redact_url_credentials(url)} only downloads the "
|
|
f"catalog JSON, but plugins [{names}] use local relative sources. "
|
|
"Use a git repository or local directory for this marketplace."
|
|
)
|
|
raise MarketplaceError(msg)
|
|
|
|
|
|
def load_marketplace_location(path: Path) -> PluginMarketplace:
|
|
"""Load a marketplace from either a cached directory or JSON file.
|
|
|
|
Args:
|
|
path: Directory or marketplace JSON path.
|
|
|
|
Returns:
|
|
Parsed marketplace.
|
|
"""
|
|
resolved = path.expanduser().resolve()
|
|
if resolved.is_file():
|
|
return _load_marketplace_file(resolved)
|
|
return load_marketplace(resolved)
|
|
|
|
|
|
def find_marketplace_manifest(root: Path) -> Path | None:
|
|
"""Return a marketplace manifest path under `root`, if present."""
|
|
for rel in _MARKETPLACE_RELATIVE_PATHS:
|
|
path = root / rel
|
|
try:
|
|
if path.is_file():
|
|
return path
|
|
except OSError:
|
|
logger.warning("Could not inspect marketplace manifest path %s", path)
|
|
return None
|
|
|
|
|
|
def _source_path(source: PluginSource) -> str | None:
|
|
return source.path if isinstance(source, LocalPluginSource) else None
|
|
|
|
|
|
def _external_plugin_repository_source_type(
|
|
value: JsonValue,
|
|
) -> ExternalPluginRepositorySourceType | None:
|
|
if value == "github":
|
|
return "github"
|
|
if value == "git-subdir":
|
|
return "git-subdir"
|
|
if value == "url":
|
|
return "url"
|
|
return None
|
|
|
|
|
|
def _plugin_repository_source(
|
|
plugin: MarketplacePluginEntry,
|
|
) -> tuple[RepositoryMarketplaceSource, str, str | None] | None:
|
|
"""Parse an external plugin source into clone metadata.
|
|
|
|
Supported objects use `github`, `url`, or `git-subdir`;
|
|
git-subdir identifies a plugin within a repository through its optional `path`.
|
|
|
|
Returns:
|
|
`(source, clone_url, subpath)` or `None` for unsupported metadata.
|
|
"""
|
|
if not isinstance(
|
|
plugin.source,
|
|
(GithubPluginSource, GitSubdirectoryPluginSource, UrlPluginSource),
|
|
):
|
|
return None
|
|
kind = plugin.source.source_type
|
|
ref_value = plugin.source.ref
|
|
subpath_value = plugin.source.path
|
|
if kind == "github":
|
|
if not isinstance(plugin.source, GithubPluginSource):
|
|
return None
|
|
repo = plugin.source.repo
|
|
parsed = parse_marketplace_source(f"{repo}#{ref_value}" if ref_value else repo)
|
|
if not isinstance(parsed, RepositoryMarketplaceSource):
|
|
return None
|
|
return parsed, f"https://github.com/{parsed.value}.git", subpath_value
|
|
if kind not in {"git-subdir", "url"}:
|
|
return None
|
|
if not isinstance(plugin.source, (GitSubdirectoryPluginSource, UrlPluginSource)):
|
|
return None
|
|
raw_url = plugin.source.url
|
|
parsed = parse_marketplace_source(
|
|
f"{raw_url}#{ref_value}" if ref_value else raw_url
|
|
)
|
|
if parsed.source_type == "github":
|
|
git_url = f"https://github.com/{parsed.value}.git"
|
|
elif parsed.source_type == "git":
|
|
git_url = parsed.value
|
|
else:
|
|
return None
|
|
if not isinstance(parsed, RepositoryMarketplaceSource):
|
|
return None
|
|
return parsed, git_url, subpath_value
|
|
|
|
|
|
def materialize_plugin_source(
|
|
marketplace: PluginMarketplace, plugin: MarketplacePluginEntry
|
|
) -> Path | None:
|
|
"""Resolve or materialize a marketplace plugin entry to a plugin root.
|
|
|
|
Args:
|
|
marketplace: Marketplace containing the plugin.
|
|
plugin: Plugin entry.
|
|
|
|
Returns:
|
|
Resolved plugin root, or `None` for unsupported sources.
|
|
"""
|
|
raw = _source_path(plugin.source)
|
|
if raw is not None:
|
|
metadata_root = marketplace.metadata.get("pluginRoot")
|
|
warnings: list[str] = []
|
|
base = marketplace.root
|
|
if isinstance(metadata_root, str) and raw.startswith("./"):
|
|
base_path = _resolve_component_path(
|
|
metadata_root, marketplace.root, "metadata.pluginRoot", warnings
|
|
)
|
|
if base_path is not None:
|
|
base = base_path
|
|
resolved = _resolve_component_path(
|
|
raw, base, f"plugins.{plugin.name}.source", warnings
|
|
)
|
|
for warning in warnings:
|
|
logger.warning("Marketplace %s: %s", marketplace.name, warning)
|
|
return resolved
|
|
|
|
repository = _plugin_repository_source(plugin)
|
|
if repository is None:
|
|
return None
|
|
source, git_url, subpath = repository
|
|
root = _materialize_plugin_repository(
|
|
source,
|
|
git_url,
|
|
cache_key=(f"plugin-source-{marketplace.name}-{plugin.name}-{plugin.source!r}"),
|
|
)
|
|
if subpath is None:
|
|
return root
|
|
warnings = []
|
|
resolved = _resolve_component_path(
|
|
subpath, root, f"plugins.{plugin.name}.source.path", warnings
|
|
)
|
|
for warning in warnings:
|
|
logger.warning("Marketplace %s: %s", marketplace.name, warning)
|
|
return resolved
|
|
|
|
|
|
def _optional_source_string(
|
|
source: JsonObject,
|
|
field: str,
|
|
*,
|
|
plugin_name: object,
|
|
warnings: list[str],
|
|
) -> tuple[str | None, bool]:
|
|
value = source.get(field)
|
|
if value is None:
|
|
return None, True
|
|
if isinstance(value, str):
|
|
return value, True
|
|
warnings.append(
|
|
f"Skipping marketplace plugin {plugin_name!r}: source.{field} must be a string"
|
|
)
|
|
return None, False
|
|
|
|
|
|
def _parse_plugin_source(
|
|
value: object, *, plugin_name: object, warnings: list[str]
|
|
) -> PluginSource | None:
|
|
if isinstance(value, str):
|
|
return LocalPluginSource(source_type="local", path=value)
|
|
if not isinstance(value, dict):
|
|
warnings.append(f"Skipping marketplace plugin {plugin_name!r}: missing source")
|
|
return None
|
|
source = json_object(value)
|
|
kind = source.get("source")
|
|
path, path_valid = _optional_source_string(
|
|
source, "path", plugin_name=plugin_name, warnings=warnings
|
|
)
|
|
ref, ref_valid = _optional_source_string(
|
|
source, "ref", plugin_name=plugin_name, warnings=warnings
|
|
)
|
|
if not path_valid or not ref_valid:
|
|
return None
|
|
if kind == "local":
|
|
if path is None:
|
|
warnings.append(
|
|
f"Skipping marketplace plugin {plugin_name!r}: "
|
|
"local source requires path"
|
|
)
|
|
return None
|
|
return LocalPluginSource(source_type="local", path=path)
|
|
source_type = _external_plugin_repository_source_type(kind)
|
|
if source_type is None:
|
|
warnings.append(
|
|
f"Skipping marketplace plugin {plugin_name!r}: unsupported source {kind!r}"
|
|
)
|
|
return None
|
|
repo, repo_valid = _optional_source_string(
|
|
source, "repo", plugin_name=plugin_name, warnings=warnings
|
|
)
|
|
url, url_valid = _optional_source_string(
|
|
source, "url", plugin_name=plugin_name, warnings=warnings
|
|
)
|
|
if not repo_valid or not url_valid:
|
|
return None
|
|
if source_type == "github" and repo is None:
|
|
warnings.append(
|
|
f"Skipping marketplace plugin {plugin_name!r}: github source requires repo"
|
|
)
|
|
return None
|
|
if source_type in {"git-subdir", "url"} and url is None:
|
|
warnings.append(
|
|
f"Skipping marketplace plugin {plugin_name!r}: "
|
|
f"{source_type} source requires url"
|
|
)
|
|
return None
|
|
if source_type == "github":
|
|
if repo is None:
|
|
return None
|
|
return GithubPluginSource(
|
|
source_type="github",
|
|
repo=repo,
|
|
ref=ref,
|
|
path=path,
|
|
)
|
|
if url is None:
|
|
return None
|
|
if source_type == "git-subdir":
|
|
return GitSubdirectoryPluginSource(
|
|
source_type="git-subdir",
|
|
url=url,
|
|
ref=ref,
|
|
path=path,
|
|
)
|
|
return UrlPluginSource(
|
|
source_type="url",
|
|
url=url,
|
|
ref=ref,
|
|
path=path,
|
|
)
|
|
|
|
|
|
def _parse_entry(
|
|
entry: object, *, warnings: list[str]
|
|
) -> MarketplacePluginEntry | None:
|
|
if not isinstance(entry, dict):
|
|
warnings.append(
|
|
"Skipping marketplace plugin entry: "
|
|
f"expected object, got {type(entry).__name__}"
|
|
)
|
|
return None
|
|
source = _parse_plugin_source(
|
|
entry.get("source"), plugin_name=entry.get("name"), warnings=warnings
|
|
)
|
|
if source is None:
|
|
return None
|
|
try:
|
|
name = _validate_name(entry.get("name"))
|
|
except ValueError as exc:
|
|
warnings.append(f"Skipping marketplace plugin with invalid name: {exc}")
|
|
return None
|
|
description_value = entry.get("description")
|
|
author_value = entry.get("author")
|
|
author = (
|
|
json_object(author_value)
|
|
if isinstance(author_value, dict)
|
|
else author_value
|
|
if isinstance(author_value, str)
|
|
else None
|
|
)
|
|
return MarketplacePluginEntry(
|
|
name=name,
|
|
source=source,
|
|
description=description_value if isinstance(description_value, str) else None,
|
|
author=author,
|
|
)
|
|
|
|
|
|
def _load_marketplace_from_path(root: Path, manifest_path: Path) -> PluginMarketplace:
|
|
try:
|
|
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
msg = f"Invalid JSON syntax in {manifest_path}: {exc}"
|
|
raise MarketplaceError(msg) from exc
|
|
except OSError as exc:
|
|
msg = f"Could not read marketplace manifest {manifest_path}: {exc}"
|
|
raise MarketplaceError(msg) from exc
|
|
if not isinstance(raw, dict):
|
|
msg = f"Marketplace manifest {manifest_path} must be a JSON object"
|
|
raise MarketplaceError(msg)
|
|
try:
|
|
name = _validate_name(raw.get("name"), allow_at=False)
|
|
except ValueError as exc:
|
|
raise MarketplaceError(str(exc)) from exc
|
|
plugins_raw = raw.get("plugins")
|
|
if not isinstance(plugins_raw, list):
|
|
msg = f"Marketplace {name} must contain a plugins array"
|
|
raise MarketplaceError(msg)
|
|
warnings: list[str] = []
|
|
plugins = tuple(
|
|
plugin
|
|
for entry in plugins_raw
|
|
if (plugin := _parse_entry(entry, warnings=warnings)) is not None
|
|
)
|
|
for warning in warnings:
|
|
logger.warning("%s", warning)
|
|
metadata = json_object(raw.get("metadata"))
|
|
return PluginMarketplace(
|
|
name=name,
|
|
root=root,
|
|
manifest_path=manifest_path,
|
|
metadata=metadata,
|
|
plugins=plugins,
|
|
warnings=tuple(warnings),
|
|
)
|
|
|
|
|
|
def load_marketplace(root: Path) -> PluginMarketplace:
|
|
"""Load a marketplace manifest from a root directory.
|
|
|
|
Args:
|
|
root: Marketplace root directory.
|
|
|
|
Returns:
|
|
Parsed marketplace.
|
|
|
|
Raises:
|
|
MarketplaceError: If no marketplace manifest exists or it is invalid.
|
|
"""
|
|
root = root.expanduser().resolve()
|
|
manifest_path = find_marketplace_manifest(root)
|
|
if manifest_path is None:
|
|
msg = f"No marketplace manifest found under {root}"
|
|
raise MarketplaceError(msg)
|
|
return _load_marketplace_from_path(root, manifest_path)
|