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>
393 lines
12 KiB
Python
393 lines
12 KiB
Python
"""Plugin discovery, install, and enablement helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import shutil
|
|
from functools import partial
|
|
from pathlib import Path
|
|
|
|
from deepagents_code.plugins.manifest import (
|
|
PluginManifestError,
|
|
build_inventory,
|
|
load_manifest,
|
|
)
|
|
from deepagents_code.plugins.marketplace import (
|
|
MarketplaceError,
|
|
load_marketplace,
|
|
load_marketplace_location,
|
|
materialize_marketplace_source,
|
|
materialize_plugin_source,
|
|
parse_marketplace_source,
|
|
redact_urls_in_text,
|
|
)
|
|
from deepagents_code.plugins.models import (
|
|
MarketplacePluginEntry,
|
|
MarketplaceRecord,
|
|
PluginDiscoveryResult,
|
|
PluginInstance,
|
|
PluginMarketplace,
|
|
RepositoryMarketplaceSource,
|
|
split_plugin_id,
|
|
)
|
|
from deepagents_code.plugins.store import (
|
|
cache_and_register_plugin,
|
|
ensure_marketplace_cache_dir,
|
|
ensure_plugin_data_dir,
|
|
get_primary_install_entry,
|
|
load_enabled_plugin_ids,
|
|
load_installed_plugins,
|
|
load_marketplace_records,
|
|
plugin_data_dir,
|
|
remove_marketplace_record,
|
|
save_marketplace_record,
|
|
set_plugin_enabled,
|
|
uninstall_plugin as uninstall_plugin_record,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def add_local_marketplace(path: str | Path) -> PluginMarketplace:
|
|
"""Add a local marketplace to dcode state.
|
|
|
|
Args:
|
|
path: Marketplace root directory.
|
|
|
|
Returns:
|
|
Parsed marketplace.
|
|
"""
|
|
marketplace = load_marketplace(Path(path))
|
|
save_marketplace_record(
|
|
MarketplaceRecord(
|
|
name=marketplace.name,
|
|
source_type="directory",
|
|
source=str(marketplace.root),
|
|
install_location=str(marketplace.root),
|
|
)
|
|
)
|
|
return marketplace
|
|
|
|
|
|
def add_marketplace_source(raw: str) -> PluginMarketplace:
|
|
"""Add a marketplace from a pasted source string.
|
|
|
|
Args:
|
|
raw: GitHub shorthand, Git URL, marketplace JSON URL, file, or directory.
|
|
|
|
Returns:
|
|
Parsed marketplace.
|
|
"""
|
|
source = parse_marketplace_source(raw)
|
|
marketplace, location = materialize_marketplace_source(source)
|
|
save_marketplace_record(
|
|
MarketplaceRecord(
|
|
name=marketplace.name,
|
|
source_type=source.source_type,
|
|
source=source.value,
|
|
install_location=str(location),
|
|
ref=source.ref if isinstance(source, RepositoryMarketplaceSource) else None,
|
|
)
|
|
)
|
|
return marketplace
|
|
|
|
|
|
def remove_marketplace(name: str) -> bool:
|
|
"""Remove a marketplace and every plugin installed from it.
|
|
|
|
Local marketplace source directories are never deleted. Managed marketplace
|
|
clones and installed plugin caches are removed.
|
|
|
|
Args:
|
|
name: Marketplace name.
|
|
|
|
Returns:
|
|
`True` when a configured marketplace was removed.
|
|
"""
|
|
record = load_marketplace_records().get(name)
|
|
if record is None:
|
|
return False
|
|
|
|
installed = load_installed_plugins(strict=True)
|
|
enabled = load_enabled_plugin_ids(strict=True)
|
|
plugin_ids = set(installed) | set(enabled)
|
|
for plugin_id in plugin_ids:
|
|
try:
|
|
_plugin_name, marketplace_name = split_plugin_id(plugin_id)
|
|
except ValueError:
|
|
continue
|
|
if marketplace_name == name:
|
|
uninstall_plugin(plugin_id)
|
|
|
|
removed = remove_marketplace_record(name)
|
|
location = Path(record.install_location)
|
|
try:
|
|
resolved = location.resolve()
|
|
cache_root = ensure_marketplace_cache_dir().resolve()
|
|
except OSError:
|
|
return removed
|
|
if record.source_type in {"github", "git", "url"} and resolved.is_relative_to(
|
|
cache_root
|
|
):
|
|
if resolved.is_dir():
|
|
shutil.rmtree(resolved, ignore_errors=True)
|
|
elif resolved.is_file():
|
|
resolved.unlink(missing_ok=True)
|
|
return removed
|
|
|
|
|
|
def _require_installed_plugin(plugin_id: str) -> None:
|
|
"""Raise when `plugin_id` does not identify an installed plugin.
|
|
|
|
Raises:
|
|
MarketplaceError: If the plugin is not installed.
|
|
"""
|
|
if plugin_id not in load_installed_plugins(strict=True):
|
|
msg = f"Plugin {plugin_id!r} is not installed"
|
|
raise MarketplaceError(msg)
|
|
|
|
|
|
def set_installed_plugin_enabled(plugin_id: str, *, enabled: bool) -> None:
|
|
"""Set the enabled state of an installed plugin.
|
|
|
|
Args:
|
|
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
|
enabled: Whether to enable the plugin.
|
|
"""
|
|
_require_installed_plugin(plugin_id)
|
|
set_plugin_enabled(plugin_id, enabled)
|
|
if enabled:
|
|
ensure_plugin_data_dir(plugin_id)
|
|
|
|
|
|
def uninstall_plugin(plugin_id: str) -> None:
|
|
"""Uninstall a plugin (disable, clear records, delete orphaned cache).
|
|
|
|
Args:
|
|
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
|
"""
|
|
uninstall_plugin_record(plugin_id)
|
|
|
|
|
|
def _resolve_marketplace_and_entry(
|
|
plugin_id: str,
|
|
) -> tuple[PluginMarketplace, MarketplacePluginEntry]:
|
|
try:
|
|
plugin_name, marketplace_name = split_plugin_id(plugin_id)
|
|
except ValueError as exc:
|
|
raise MarketplaceError(str(exc)) from exc
|
|
records = load_marketplace_records()
|
|
record = records.get(marketplace_name)
|
|
if record is None:
|
|
msg = f"Marketplace {marketplace_name!r} is not configured"
|
|
raise MarketplaceError(msg)
|
|
marketplace = load_marketplace_location(Path(record.install_location))
|
|
entry = next(
|
|
(plugin for plugin in marketplace.plugins if plugin.name == plugin_name),
|
|
None,
|
|
)
|
|
if entry is None:
|
|
msg = f"Plugin {plugin_id!r} not found in marketplace {marketplace_name}"
|
|
raise MarketplaceError(msg)
|
|
return marketplace, entry
|
|
|
|
|
|
def install_plugin(plugin_id: str) -> PluginInstance:
|
|
"""Install a marketplace plugin into the versioned cache and enable it.
|
|
|
|
Copies the plugin source into `plugins/cache/{marketplace}/{plugin}/{version}/`,
|
|
writes `installed_plugins.json`, and enables the plugin.
|
|
|
|
Args:
|
|
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
|
|
|
Returns:
|
|
Discovered plugin instance loaded from the cache path.
|
|
|
|
Raises:
|
|
MarketplaceError: If the marketplace/plugin cannot be resolved, the
|
|
source is unsupported, or the cached plugin fails to load.
|
|
"""
|
|
load_installed_plugins(strict=True)
|
|
load_enabled_plugin_ids(strict=True)
|
|
marketplace, entry = _resolve_marketplace_and_entry(plugin_id)
|
|
source_root = materialize_plugin_source(marketplace, entry)
|
|
if source_root is None:
|
|
msg = (
|
|
f"Plugin {plugin_id} has unsupported source "
|
|
f"{redact_urls_in_text(repr(entry.source))}; "
|
|
"use a local path, GitHub repository, or Git repository source"
|
|
)
|
|
raise MarketplaceError(msg)
|
|
|
|
try:
|
|
manifest, _manifest_path, manifest_warnings = load_manifest(
|
|
source_root, fallback_name=entry.name
|
|
)
|
|
except PluginManifestError as exc:
|
|
msg = f"Cannot install {plugin_id}: {exc}"
|
|
raise MarketplaceError(msg) from exc
|
|
|
|
for warning in manifest_warnings:
|
|
logger.debug("Plugin install warning for %s: %s", plugin_id, warning)
|
|
|
|
version = manifest.version if manifest is not None else None
|
|
cache_path = cache_and_register_plugin(
|
|
plugin_id,
|
|
source_root,
|
|
version=version,
|
|
validate=partial(
|
|
_validate_plugin_copy,
|
|
plugin_id=plugin_id,
|
|
fallback_name=entry.name,
|
|
),
|
|
)
|
|
|
|
set_plugin_enabled(plugin_id, True)
|
|
ensure_plugin_data_dir(plugin_id)
|
|
|
|
instance, warnings = _plugin_from_install_path(
|
|
plugin_id=plugin_id,
|
|
root=cache_path,
|
|
marketplace_name=marketplace.name,
|
|
fallback_name=entry.name,
|
|
)
|
|
if instance is None:
|
|
detail = "; ".join(warnings)
|
|
uninstall_plugin_record(plugin_id)
|
|
msg = f"Installed {plugin_id} but failed to load from cache: {detail}"
|
|
raise MarketplaceError(msg)
|
|
return instance
|
|
|
|
|
|
def _validate_plugin_copy(
|
|
root: Path,
|
|
*,
|
|
plugin_id: str,
|
|
fallback_name: str,
|
|
) -> None:
|
|
try:
|
|
manifest, _manifest_path, warnings = load_manifest(
|
|
root, fallback_name=fallback_name
|
|
)
|
|
except PluginManifestError as exc:
|
|
msg = f"Cannot install {plugin_id}: {exc}"
|
|
raise MarketplaceError(msg) from exc
|
|
build_inventory(root, manifest, warnings)
|
|
|
|
|
|
def _plugin_from_install_path(
|
|
*,
|
|
plugin_id: str,
|
|
root: Path,
|
|
marketplace_name: str,
|
|
fallback_name: str,
|
|
) -> tuple[PluginInstance | None, tuple[str, ...]]:
|
|
warnings: list[str] = []
|
|
try:
|
|
manifest, _manifest_path, manifest_warnings = load_manifest(
|
|
root, fallback_name=fallback_name
|
|
)
|
|
except PluginManifestError as exc:
|
|
return None, (f"Skipping plugin {plugin_id}: {exc}",)
|
|
warnings.extend(manifest_warnings)
|
|
name = manifest.name if manifest and manifest.name else fallback_name
|
|
inventory = build_inventory(root, manifest, tuple(warnings))
|
|
try:
|
|
instance = PluginInstance(
|
|
plugin_id=plugin_id,
|
|
name=name,
|
|
marketplace=marketplace_name,
|
|
version=manifest.version if manifest is not None else None,
|
|
root=root,
|
|
data_dir=plugin_data_dir(plugin_id),
|
|
manifest=manifest,
|
|
inventory=inventory,
|
|
)
|
|
except ValueError as exc:
|
|
return None, (f"Skipping plugin {plugin_id}: {exc}",)
|
|
return instance, inventory.warnings
|
|
|
|
|
|
def discover_plugins() -> PluginDiscoveryResult:
|
|
"""Discover enabled marketplace plugins from their install cache paths.
|
|
|
|
Returns:
|
|
Discovery result. Broken marketplaces/plugins are returned as warnings and
|
|
never abort sibling plugin loading.
|
|
"""
|
|
enabled = load_enabled_plugin_ids()
|
|
plugins: list[PluginInstance] = []
|
|
warnings: list[str] = []
|
|
|
|
for plugin_id in sorted(enabled):
|
|
try:
|
|
plugin_name, marketplace_name = split_plugin_id(plugin_id)
|
|
except ValueError:
|
|
warnings.append(f"Ignoring invalid plugin id {plugin_id!r}")
|
|
continue
|
|
entry = get_primary_install_entry(plugin_id)
|
|
if entry is None:
|
|
warnings.append(
|
|
f"Plugin {plugin_id} is enabled but not installed "
|
|
"(missing installed_plugins.json entry); run install to fix this"
|
|
)
|
|
continue
|
|
root = Path(entry.install_path)
|
|
try:
|
|
root_exists = root.is_dir()
|
|
except (OSError, RuntimeError) as exc:
|
|
warnings.append(f"Plugin {plugin_id} cache could not be inspected: {exc}")
|
|
continue
|
|
if not root_exists:
|
|
warnings.append(
|
|
f"Plugin {plugin_id} cache miss at {entry.install_path}; "
|
|
"re-run install to refresh"
|
|
)
|
|
continue
|
|
try:
|
|
plugin, plugin_warnings = _plugin_from_install_path(
|
|
plugin_id=plugin_id,
|
|
root=root,
|
|
marketplace_name=marketplace_name,
|
|
fallback_name=plugin_name,
|
|
)
|
|
except (OSError, RuntimeError) as exc:
|
|
warnings.append(f"Skipping plugin {plugin_id}: {exc}")
|
|
continue
|
|
warnings.extend(plugin_warnings)
|
|
if plugin is not None:
|
|
plugins.append(plugin)
|
|
|
|
return PluginDiscoveryResult(plugins=tuple(plugins), warnings=tuple(warnings))
|
|
|
|
|
|
def list_available_plugins() -> tuple[tuple[str, str, bool], ...]:
|
|
"""List plugins from configured marketplaces.
|
|
|
|
Returns:
|
|
Tuples of `(plugin_id, description, enabled)`.
|
|
"""
|
|
records = load_marketplace_records()
|
|
enabled = load_enabled_plugin_ids()
|
|
rows: list[tuple[str, str, bool]] = []
|
|
for name, record in sorted(records.items()):
|
|
try:
|
|
marketplace = load_marketplace_location(Path(record.install_location))
|
|
except MarketplaceError as exc:
|
|
rows.append((f"<marketplace:{name}>", str(exc), False))
|
|
continue
|
|
for plugin in marketplace.plugins:
|
|
plugin_id = f"{plugin.name}@{marketplace.name}"
|
|
rows.append((plugin_id, plugin.description or "", plugin_id in enabled))
|
|
return tuple(rows)
|
|
|
|
|
|
def list_installed_plugin_ids() -> frozenset[str]:
|
|
"""Return plugin ids that have install records.
|
|
|
|
Returns:
|
|
Set of installed plugin ids.
|
|
"""
|
|
return frozenset(load_installed_plugins())
|