Files
deepagents/libs/code/deepagents_code/plugins/manifest.py
T
Johannes du Plessis 0187d14b83 feat(code): add plugin marketplace support (#4554)
`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>
2026-07-15 09:11:28 -04:00

273 lines
8.4 KiB
Python

"""Plugin manifest parsing for plugins."""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path, PureWindowsPath
from deepagents_code.plugins._json import json_object
from deepagents_code.plugins.models import (
ComponentInventory,
JsonObject,
PluginManifest,
)
logger = logging.getLogger(__name__)
_MANIFEST_RELATIVE_PATHS = (
Path(".claude-plugin") / "plugin.json",
Path(".codex-plugin") / "plugin.json",
)
_PATH_COMPONENT_FIELDS = {"skills", "mcpServers"}
_NAME_RE = re.compile(r"^[^\s]+$")
class PluginManifestError(ValueError):
"""Raised when a plugin manifest is malformed enough to skip the plugin."""
def find_manifest_path(root: Path) -> Path | None:
"""Return the first supported manifest path under `root`, if present.
Args:
root: Plugin root directory.
Returns:
Manifest path or `None`.
"""
for rel in _MANIFEST_RELATIVE_PATHS:
path = root / rel
try:
if path.is_file():
return path
except OSError:
logger.warning("Could not inspect plugin manifest path %s", path)
return None
def _validate_name(
name: object, *, fallback: str | None = None, allow_at: bool = True
) -> str:
"""Validate a nonempty plugin name with no whitespace.
Names such as `code-review` and `review@team` are valid; `code review` and
the empty string are not.
Returns:
The validated name or fallback.
Raises:
PluginManifestError: If neither value is a valid name.
"""
if (
isinstance(name, str)
and name
and _NAME_RE.fullmatch(name)
and (allow_at or "@" not in name)
):
return name
if fallback and _NAME_RE.fullmatch(fallback) and (allow_at or "@" not in fallback):
return fallback
msg = f"Invalid plugin name: {name!r}"
raise PluginManifestError(msg)
def _is_windows_absolute(path: str) -> bool:
return bool(PureWindowsPath(path).drive or PureWindowsPath(path).root)
def _resolve_component_path(
declaration: str,
plugin_root: Path,
field_name: str,
warnings: list[str],
) -> Path | None:
if not declaration.startswith("./"):
warnings.append(
f"ignoring {field_name}: path must start with './' relative to plugin root"
)
return None
relative = declaration[2:]
if not relative:
warnings.append(f"ignoring {field_name}: path must not be './'")
return None
path = Path(relative)
if any(part == ".." for part in path.parts):
warnings.append(f"ignoring {field_name}: path must not contain '..'")
return None
if path.is_absolute() or _is_windows_absolute(relative):
warnings.append(f"ignoring {field_name}: path must stay within the plugin root")
return None
try:
root_resolved = plugin_root.resolve()
resolved = (plugin_root / path).resolve()
except OSError as exc:
warnings.append(
f"ignoring {field_name}: could not resolve {declaration!r}: {exc}"
)
return None
if not resolved.is_relative_to(root_resolved):
warnings.append(f"ignoring {field_name}: path escapes plugin root")
return None
return resolved
def _resolve_component_paths(
declaration: object,
plugin_root: Path,
field_name: str,
warnings: list[str],
) -> tuple[Path, ...]:
"""Resolve one or more plugin-relative component paths.
For example, `"./skills"` and `["./skills", "./extra-skills"]` are
accepted. Absolute paths and paths containing `..` are rejected.
Returns:
Validated paths contained by the plugin root.
"""
raw_paths: list[str]
if isinstance(declaration, str):
raw_paths = [declaration]
elif isinstance(declaration, list):
raw_paths = [item for item in declaration if isinstance(item, str)]
warnings.extend(
f"ignoring {field_name}: expected path string, got {type(item).__name__}"
for item in declaration
if not isinstance(item, str)
)
else:
warnings.append(
f"ignoring {field_name}: expected path string or list of strings"
)
return ()
paths: list[Path] = []
for raw_path in raw_paths:
resolved = _resolve_component_path(raw_path, plugin_root, field_name, warnings)
if resolved is not None:
paths.append(resolved)
return tuple(paths)
def _inline_mcp(value: object) -> JsonObject:
if isinstance(value, dict):
return json_object(value)
if isinstance(value, list):
merged: JsonObject = {}
for item in value:
if isinstance(item, dict):
merged.update(json_object(item))
return merged
return {}
def load_manifest(
root: Path, *, fallback_name: str | None = None
) -> tuple[PluginManifest | None, Path | None, tuple[str, ...]]:
"""Load a Claude/Codex plugin manifest.
Args:
root: Plugin root directory.
fallback_name: Name to use only when deriving a manifest-less plugin.
Returns:
`(manifest, manifest_path, warnings)`.
Raises:
PluginManifestError: If the manifest exists but is invalid.
"""
manifest_path = find_manifest_path(root)
if manifest_path is None:
return None, None, ()
try:
decoded = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
msg = f"Invalid JSON syntax in {manifest_path}: {exc}"
raise PluginManifestError(msg) from exc
except OSError as exc:
msg = f"Could not read plugin manifest {manifest_path}: {exc}"
raise PluginManifestError(msg) from exc
if not isinstance(decoded, dict):
msg = f"Plugin manifest {manifest_path} must be a JSON object"
raise PluginManifestError(msg)
raw = json_object(decoded)
warnings: list[str] = []
name = _validate_name(raw.get("name"), fallback=fallback_name)
component_paths: dict[str, tuple[Path, ...]] = {}
for field_name in _PATH_COMPONENT_FIELDS:
declaration = raw.get(field_name)
if declaration is None:
continue
if field_name == "mcpServers" and isinstance(declaration, dict):
continue
paths = _resolve_component_paths(declaration, root, field_name, warnings)
if paths:
component_paths[field_name] = paths
version_value = raw.get("version")
version = version_value if isinstance(version_value, str) else None
manifest = PluginManifest(
name=name,
version=version,
component_paths=component_paths,
inline_mcp=_inline_mcp(raw.get("mcpServers")),
)
return manifest, manifest_path, tuple(warnings)
def _existing_component_path(path: Path, plugin_root: Path) -> tuple[Path, ...]:
try:
if not path.exists():
return ()
resolved = path.resolve()
if not resolved.is_relative_to(plugin_root.resolve()):
logger.warning("Ignoring plugin component outside plugin root: %s", path)
return ()
except OSError:
logger.warning("Could not inspect plugin component path %s", path)
return ()
else:
return (resolved,)
def build_inventory(
plugin_root: Path,
manifest: PluginManifest | None,
manifest_warnings: tuple[str, ...] = (),
) -> ComponentInventory:
"""Build component inventory for a plugin.
Args:
plugin_root: Plugin root directory.
manifest: Parsed manifest or `None`.
manifest_warnings: Warnings emitted during manifest parsing.
Returns:
Component inventory.
"""
plugin_root = plugin_root.resolve()
warnings = list(manifest_warnings)
metadata_paths = manifest.component_paths if manifest else {}
default_skills = _existing_component_path(plugin_root / "skills", plugin_root)
root_skill = (
()
if default_skills or (manifest and "skills" in manifest.component_paths)
else _existing_component_path(plugin_root / "SKILL.md", plugin_root)
)
skills = (*default_skills, *metadata_paths.get("skills", ()), *root_skill)
mcp_files = (
*_existing_component_path(plugin_root / ".mcp.json", plugin_root),
*metadata_paths.get("mcpServers", ()),
)
return ComponentInventory(
skills=tuple(dict.fromkeys(skills)),
mcp_files=tuple(dict.fromkeys(mcp_files)),
warnings=tuple(warnings),
)