mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-19 22:14:45 -04:00
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>
This commit is contained in:
committed by
GitHub
parent
9a6789ac19
commit
0187d14b83
@@ -8,7 +8,7 @@ Regenerate this file with `make commands-catalog` after changing command names,
|
||||
aliases, descriptions, visibility, or hidden-command metadata.
|
||||
|
||||
|
||||
## Public (33)
|
||||
## Public (34)
|
||||
|
||||
| Command | Aliases | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -30,6 +30,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
|
||||
| `/model` | | Switch models or edit model settings |
|
||||
| `/notifications` | | Configure startup warnings |
|
||||
| `/offload` | `/compact` | Summarize and offload older messages to free context |
|
||||
| `/plugins` | | Manage plugins (experimental) |
|
||||
| `/quit` | `/q` | Exit app |
|
||||
| `/reload` | | Reload environment and config |
|
||||
| `/remember` | | Save useful context to memory or skills |
|
||||
|
||||
@@ -248,6 +248,12 @@ Set to a truthy value to bring the standalone integrations screen back into the
|
||||
flow. Parsed by `is_env_truthy`: accepts `1`, `true`, `yes`, `on` as enabled.
|
||||
"""
|
||||
|
||||
PLUGIN_CACHE_DIR = "DEEPAGENTS_CODE_PLUGIN_CACHE_DIR"
|
||||
"""Override the plugin install/marketplace cache root.
|
||||
|
||||
When unset, plugins are stored under `DEFAULT_CONFIG_DIR / "plugins"`.
|
||||
"""
|
||||
|
||||
RESTARTED_AFTER_UPDATE = "DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE"
|
||||
"""Internal sentinel recording the target version immediately before the
|
||||
startup auto-update re-execs the process.
|
||||
@@ -385,3 +391,6 @@ def is_env_truthy(name: str, *, default: bool = False) -> bool:
|
||||
return default
|
||||
classified = classify_env_bool(raw)
|
||||
return default if classified is None else classified
|
||||
|
||||
|
||||
EXPERIMENTAL_HINT = f"This feature is experimental. Set {EXPERIMENTAL}=1 to enable."
|
||||
|
||||
@@ -20,21 +20,9 @@ from deepagents.middleware import (
|
||||
GRADER_SYSTEM_PROMPT,
|
||||
FilesystemMiddleware,
|
||||
MemoryMiddleware,
|
||||
SkillsMiddleware,
|
||||
SkillsMiddleware, # noqa: F401
|
||||
)
|
||||
|
||||
# Backwards-compat flag: SDKs before 0.5.4 accept only `list[str]` for
|
||||
# `SkillsMiddleware.sources`; newer SDKs expose the `SkillSource` alias
|
||||
# that permits `(path, label)` tuples. The `skills` module is already
|
||||
# loaded by the `SkillsMiddleware` import above, so the extra lookup
|
||||
# here adds no startup cost.
|
||||
try:
|
||||
from deepagents.middleware.skills import SkillSource as _SkillSource # noqa: F401
|
||||
except ImportError:
|
||||
_SUPPORTS_SKILL_SOURCE_TUPLES = False
|
||||
else:
|
||||
_SUPPORTS_SKILL_SOURCE_TUPLES = True
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
|
||||
@@ -56,6 +44,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from deepagents_code.mcp_tools import MCPServerInfo
|
||||
from deepagents_code.output import OutputFormat
|
||||
from deepagents_code.plugins.adapters.skills import CodeSkillSource
|
||||
|
||||
from langchain.agents.middleware import TodoListMiddleware
|
||||
from langchain.agents.middleware.types import AgentMiddleware
|
||||
@@ -89,6 +78,7 @@ from deepagents_code.local_context import (
|
||||
)
|
||||
from deepagents_code.offload import _offload_fallback_root
|
||||
from deepagents_code.offload_middleware import _create_cli_compaction_middleware
|
||||
from deepagents_code.plugins.adapters.skills_middleware import PluginSkillsMiddleware
|
||||
from deepagents_code.project_utils import ProjectContext, get_server_project_context
|
||||
from deepagents_code.reliable_rubric import ReliableRubricMiddleware
|
||||
from deepagents_code.subagents import list_subagents
|
||||
@@ -1748,17 +1738,35 @@ def create_cli_agent(
|
||||
# Add skills middleware
|
||||
if enable_skills:
|
||||
# Lowest to highest precedence:
|
||||
# built-in -> user .deepagents -> user .agents
|
||||
# built-in -> plugins -> user .deepagents -> user .agents
|
||||
# -> project .deepagents -> project .agents
|
||||
# -> user .claude (experimental) -> project .claude (experimental)
|
||||
# Labels disambiguate user- vs project-scoped sources that share a
|
||||
# `.../skills` leaf; the middleware would otherwise derive identical
|
||||
# labels from the parent directory name.
|
||||
sources: list[tuple[str, str]] = [
|
||||
# Plugin skills are namespaced as `{plugin_id}:{skill_name}` to avoid
|
||||
# collisions between plugins and user/project skills.
|
||||
sources: list[CodeSkillSource] = [
|
||||
(str(settings.get_built_in_skills_dir()), "Built-in"),
|
||||
(str(skills_dir), "User Deepagents"),
|
||||
(str(user_agent_skills_dir), "User Agents"),
|
||||
]
|
||||
try:
|
||||
if is_env_truthy(EXPERIMENTAL):
|
||||
from deepagents_code.plugins import discover_plugins
|
||||
from deepagents_code.plugins.adapters.skills import (
|
||||
plugin_skill_sources,
|
||||
)
|
||||
|
||||
plugin_result = discover_plugins()
|
||||
if plugin_result.warnings:
|
||||
logger.warning(
|
||||
"Plugin discovery warnings: %s", plugin_result.warnings
|
||||
)
|
||||
sources.extend(plugin_skill_sources(plugin_result.plugins))
|
||||
except Exception:
|
||||
logger.warning("Could not discover plugin skills", exc_info=True)
|
||||
sources.extend(
|
||||
[
|
||||
(str(skills_dir), "User Deepagents"),
|
||||
(str(user_agent_skills_dir), "User Agents"),
|
||||
]
|
||||
)
|
||||
if project_skills_dir:
|
||||
sources.append((str(project_skills_dir), "Project Deepagents"))
|
||||
if project_agent_skills_dir:
|
||||
@@ -1772,19 +1780,13 @@ def create_cli_agent(
|
||||
if project_claude_skills_dir:
|
||||
sources.append((str(project_claude_skills_dir), "Project Claude"))
|
||||
|
||||
# Backwards-compat: strip labels when the installed SDK is too old
|
||||
# to accept `(path, label)` tuples. Label-based disambiguation
|
||||
# regresses to the pre-alias behavior (user- and project-scoped
|
||||
# `.claude/skills` collapse to the same label), but functionality
|
||||
# is preserved.
|
||||
middleware_sources: Sequence[str | tuple[str, str]] = (
|
||||
sources if _SUPPORTS_SKILL_SOURCE_TUPLES else [path for path, _ in sources]
|
||||
)
|
||||
|
||||
# `PluginSkillsMiddleware` namespaces plugin skills before dedup while
|
||||
# behaving like the SDK middleware when no plugin namespaces are
|
||||
# present, so it is safe to use for all skill sources.
|
||||
agent_middleware.append(
|
||||
SkillsMiddleware(
|
||||
PluginSkillsMiddleware(
|
||||
backend=FilesystemBackend(virtual_mode=False),
|
||||
sources=middleware_sources,
|
||||
sources=sources,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -3383,12 +3383,12 @@ class DeepAgentsApp(App):
|
||||
# Apply any skill commands discovered before the widget was mounted
|
||||
if self._discovered_skills:
|
||||
from deepagents_code.command_registry import (
|
||||
SLASH_COMMANDS,
|
||||
build_skill_commands,
|
||||
get_slash_commands,
|
||||
)
|
||||
|
||||
cmds = build_skill_commands(self._discovered_skills)
|
||||
merged = list(SLASH_COMMANDS) + cmds
|
||||
merged = list(get_slash_commands()) + cmds
|
||||
self._chat_input.update_slash_commands(merged)
|
||||
|
||||
# Set initial auto-approve state
|
||||
@@ -4012,8 +4012,8 @@ class DeepAgentsApp(App):
|
||||
simply ignore the result.
|
||||
"""
|
||||
from deepagents_code.command_registry import (
|
||||
SLASH_COMMANDS,
|
||||
build_skill_commands,
|
||||
get_slash_commands,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -4054,7 +4054,7 @@ class DeepAgentsApp(App):
|
||||
if skills:
|
||||
skill_commands = build_skill_commands(skills)
|
||||
if self._chat_input:
|
||||
merged = list(SLASH_COMMANDS) + skill_commands
|
||||
merged = list(get_slash_commands()) + skill_commands
|
||||
self._chat_input.update_slash_commands(merged)
|
||||
else:
|
||||
logger.debug(
|
||||
@@ -4076,10 +4076,20 @@ class DeepAgentsApp(App):
|
||||
Returns:
|
||||
Tuple of `(skill metadata list, pre-resolved containment roots)`.
|
||||
"""
|
||||
from deepagents_code.plugins.adapters.skills import (
|
||||
discover_plugin_skill_sources_and_roots,
|
||||
)
|
||||
from deepagents_code.skills.invocation import discover_skills_and_roots
|
||||
|
||||
assistant_id = self._assistant_id or DEFAULT_ASSISTANT_ID
|
||||
return discover_skills_and_roots(assistant_id)
|
||||
plugin_skill_sources, plugin_skill_roots = (
|
||||
discover_plugin_skill_sources_and_roots()
|
||||
)
|
||||
return discover_skills_and_roots(
|
||||
assistant_id,
|
||||
plugin_skill_sources=plugin_skill_sources,
|
||||
plugin_skill_roots=plugin_skill_roots,
|
||||
)
|
||||
|
||||
def _discover_skills_and_roots_with_import_lock(
|
||||
self,
|
||||
@@ -10801,16 +10811,14 @@ class DeepAgentsApp(App):
|
||||
self.exit()
|
||||
elif cmd == "/help":
|
||||
await self._mount_message(UserMessage(command))
|
||||
from deepagents_code.command_registry import get_slash_commands
|
||||
|
||||
command_names = ", ".join(
|
||||
f"{entry.name} {entry.argument_hint}".rstrip()
|
||||
for entry in get_slash_commands()
|
||||
)
|
||||
help_body = (
|
||||
"Commands: /quit, /agents, /auth, /clear, /force-clear, "
|
||||
"/copy, /goal, /offload, /editor, /effort, "
|
||||
"/mcp, /model [--model-params JSON] [--default], "
|
||||
"/notifications, /reload, /restart, /rubric, "
|
||||
"/skill:<name>, /remember, "
|
||||
"/skill-creator, /theme, /scrollbar, /timestamps, /tokens, "
|
||||
"/tools, /threads, /trace, "
|
||||
"/update, /auto-update, /install, /changelog, /docs, "
|
||||
"/feedback, /help\n\n"
|
||||
f"Commands: {command_names}, /skill:<name>\n\n"
|
||||
"Interactive Features:\n"
|
||||
" Enter Submit your message\n"
|
||||
f" {newline_shortcut():<15} Insert newline\n"
|
||||
@@ -11077,6 +11085,18 @@ class DeepAgentsApp(App):
|
||||
args = command.strip()[len("/mcp ") :].strip()
|
||||
await self._mount_message(UserMessage(command))
|
||||
await self._handle_mcp_subcommand(args)
|
||||
elif cmd == "/plugins":
|
||||
await self._mount_message(UserMessage(command))
|
||||
from deepagents_code._env_vars import (
|
||||
EXPERIMENTAL,
|
||||
EXPERIMENTAL_HINT,
|
||||
is_env_truthy,
|
||||
)
|
||||
|
||||
if not is_env_truthy(EXPERIMENTAL):
|
||||
await self._mount_message(AppMessage(EXPERIMENTAL_HINT))
|
||||
return
|
||||
await self._show_plugin_manager()
|
||||
elif cmd in {"/auth", "/connect"}:
|
||||
await self._show_auth_manager()
|
||||
elif cmd == "/theme":
|
||||
@@ -11231,6 +11251,53 @@ class DeepAgentsApp(App):
|
||||
if removed_skills:
|
||||
skill_lines.append(f" - Removed: {', '.join(removed_skills)}")
|
||||
report += "\nSkills updated:\n" + "\n".join(skill_lines)
|
||||
|
||||
# Experimental plugins: rediscover and restart the owned server so
|
||||
# plugin MCP config is picked up without a separate slash command.
|
||||
from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy
|
||||
|
||||
if is_env_truthy(EXPERIMENTAL):
|
||||
from deepagents_code.plugins import discover_plugins
|
||||
from deepagents_code.plugins.adapters.mcp import plugin_mcp_configs
|
||||
|
||||
plugin_result = discover_plugins()
|
||||
plugin_count = len(plugin_result.plugins)
|
||||
mcp_configs = plugin_mcp_configs(plugin_result.plugins)
|
||||
mcp_count = sum(
|
||||
len(servers)
|
||||
for config in mcp_configs
|
||||
if isinstance((servers := config.get("mcpServers")), dict)
|
||||
)
|
||||
plugin_skill_count = sum(1 for name in new_skill_names if ":" in name)
|
||||
report += (
|
||||
f"\nPlugins: {plugin_count} plugin"
|
||||
f"{'s' if plugin_count != 1 else ''} · "
|
||||
f"{plugin_skill_count} skill"
|
||||
f"{'s' if plugin_skill_count != 1 else ''} · "
|
||||
f"{mcp_count} plugin MCP server"
|
||||
f"{'s' if mcp_count != 1 else ''}"
|
||||
)
|
||||
if plugin_result.warnings:
|
||||
report += (
|
||||
f"\n{len(plugin_result.warnings)} plugin warning(s) "
|
||||
"during load."
|
||||
)
|
||||
|
||||
restarted = False
|
||||
if self._server_proc is not None and self._server_kwargs is not None:
|
||||
if self._agent_running and self._agent_worker:
|
||||
self._cancel_worker(self._agent_worker)
|
||||
self._agent_running = False
|
||||
else:
|
||||
self._discard_queue()
|
||||
restarted = await self._restart_server_manual()
|
||||
if restarted:
|
||||
report += "\nAgent server restarted for plugin MCP."
|
||||
else:
|
||||
report += (
|
||||
"\nAgent server was not restarted; plugin MCP may be stale."
|
||||
)
|
||||
|
||||
await self._mount_message(AppMessage(report))
|
||||
await self._maybe_start_deferred_server_from_default()
|
||||
elif cmd.startswith("/skill:"):
|
||||
@@ -14740,6 +14807,7 @@ class DeepAgentsApp(App):
|
||||
web search, URL fetch) run without prompting. Updates the status
|
||||
bar indicator and session state.
|
||||
"""
|
||||
from deepagents_code.tui.modals.plugin_manager import PluginManagerScreen
|
||||
from deepagents_code.tui.widgets.agent_selector import AgentSelectorScreen
|
||||
from deepagents_code.tui.widgets.auth import AuthManagerScreen, AuthPromptScreen
|
||||
from deepagents_code.tui.widgets.mcp_viewer import MCPViewerScreen
|
||||
@@ -14780,6 +14848,9 @@ class DeepAgentsApp(App):
|
||||
if isinstance(self.screen, MCPViewerScreen):
|
||||
self.screen.action_jump_up()
|
||||
return
|
||||
if isinstance(self.screen, PluginManagerScreen):
|
||||
self.screen.action_previous_tab()
|
||||
return
|
||||
# shift+tab is reused for navigation inside modal screens (e.g.
|
||||
# ModelSelectorScreen); skip the toggle so it doesn't fire through.
|
||||
if isinstance(self.screen, ModalScreen):
|
||||
@@ -16950,6 +17021,16 @@ class DeepAgentsApp(App):
|
||||
markup=False,
|
||||
)
|
||||
|
||||
async def _show_plugin_manager(self) -> None:
|
||||
"""Open the interactive plugin manager."""
|
||||
from deepagents_code.tui.modals.plugin_manager import PluginManagerScreen
|
||||
|
||||
self.push_screen(
|
||||
PluginManagerScreen(
|
||||
mcp_server_info=self._mcp_server_info or [],
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_mcp_subcommand(self, args: str) -> None:
|
||||
"""Dispatch `/mcp <subcommand>` strings.
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ from deepagents_code.unicode_security import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
from pathlib import Path
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
@@ -1450,16 +1451,29 @@ async def run_non_interactive(
|
||||
build_skill_invocation_envelope,
|
||||
discover_skills_and_roots,
|
||||
)
|
||||
from deepagents_code.skills.load import load_skill_content
|
||||
from deepagents_code.skills.load import (
|
||||
ExtendedSkillMetadata,
|
||||
load_skill_content,
|
||||
)
|
||||
|
||||
normalized_skill = initial_skill.strip().lower()
|
||||
try:
|
||||
# Offloaded to a thread: discovery does blocking filesystem I/O
|
||||
# (a JSON trust-store read plus `Path.resolve()` calls) that must
|
||||
# not block the event loop.
|
||||
skills, allowed_roots = await asyncio.to_thread(
|
||||
discover_skills_and_roots, assistant_id
|
||||
from deepagents_code.plugins.adapters.skills import (
|
||||
discover_plugin_skill_sources_and_roots,
|
||||
)
|
||||
|
||||
def discover_all_skills() -> tuple[list[ExtendedSkillMetadata], list[Path]]:
|
||||
plugin_sources, plugin_roots = discover_plugin_skill_sources_and_roots()
|
||||
return discover_skills_and_roots(
|
||||
assistant_id,
|
||||
plugin_skill_sources=plugin_sources,
|
||||
plugin_skill_roots=plugin_roots,
|
||||
)
|
||||
|
||||
skills, allowed_roots = await asyncio.to_thread(discover_all_skills)
|
||||
skill = next((s for s in skills if s["name"] == normalized_skill), None)
|
||||
except OSError as e:
|
||||
console.print(
|
||||
|
||||
@@ -136,6 +136,12 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
||||
hidden_keywords="servers oauth authenticate reconnect disable enable",
|
||||
argument_hint="[login <server> | reconnect]",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/plugins",
|
||||
description="Manage plugins (experimental)",
|
||||
bypass_tier=BypassTier.IMMEDIATE_UI,
|
||||
hidden_keywords="plugin marketplace skills mcp enable disable install",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/model",
|
||||
description="Switch models or edit model settings",
|
||||
@@ -160,6 +166,14 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
||||
bypass_tier=BypassTier.QUEUED,
|
||||
argument_hint="[context]",
|
||||
),
|
||||
# Keep after `/remember` so equal-score prefix ties resolve as
|
||||
# `re` → `/remember`, `rel` → `/reload`.
|
||||
SlashCommand(
|
||||
name="/reload",
|
||||
description="Reload environment and config",
|
||||
bypass_tier=BypassTier.QUEUED,
|
||||
hidden_keywords="refresh plugin plugins marketplace",
|
||||
),
|
||||
SlashCommand( # Static alias; not auto-generated from skill discovery
|
||||
name="/skill-creator",
|
||||
description="Create or refine agent skills",
|
||||
@@ -190,12 +204,6 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
||||
bypass_tier=BypassTier.QUEUED,
|
||||
hidden_keywords="mcp functions capabilities builtin built-in",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/reload",
|
||||
description="Reload environment and config",
|
||||
bypass_tier=BypassTier.QUEUED,
|
||||
hidden_keywords="refresh",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/rubric",
|
||||
description="Set explicit acceptance criteria for rubric grading",
|
||||
@@ -361,7 +369,11 @@ class CommandEntry(NamedTuple):
|
||||
"""A single autocomplete entry for the slash-command controller."""
|
||||
|
||||
name: str
|
||||
"""Canonical command name (e.g. `/quit`)."""
|
||||
"""Canonical command name (e.g. `/quit`).
|
||||
|
||||
This is the machine name: it is matched against user input and inserted
|
||||
into the prompt on completion.
|
||||
"""
|
||||
|
||||
description: str
|
||||
"""Short user-facing description."""
|
||||
@@ -372,9 +384,46 @@ class CommandEntry(NamedTuple):
|
||||
argument_hint: str
|
||||
"""Placeholder text shown when the command accepts arguments (e.g. `[context]`)."""
|
||||
|
||||
display_name: str = ""
|
||||
"""Label shown in the autocomplete popup instead of `name`, when set.
|
||||
|
||||
SLASH_COMMANDS: list[CommandEntry] = [cmd.to_entry() for cmd in COMMANDS]
|
||||
"""Autocomplete entries derived from `COMMANDS` for `SlashCommandController`."""
|
||||
Lets a command present a short, friendly label (e.g. `/skill:review`) while
|
||||
still matching and inserting its full canonical `name`
|
||||
(e.g. `/skill:my-plugin:review`). Falls back to `name` when empty.
|
||||
"""
|
||||
|
||||
def label(self) -> str:
|
||||
"""Return the popup label, preferring `display_name` over `name`.
|
||||
|
||||
Returns:
|
||||
The user-facing label for the autocomplete popup.
|
||||
"""
|
||||
return self.display_name or self.name
|
||||
|
||||
|
||||
_EXPERIMENTAL_PLUGIN_COMMANDS: frozenset[str] = frozenset({"/plugins"})
|
||||
"""Slash commands gated behind `DEEPAGENTS_CODE_EXPERIMENTAL`."""
|
||||
|
||||
|
||||
def get_slash_commands() -> list[CommandEntry]:
|
||||
"""Return autocomplete entries for currently enabled slash commands.
|
||||
|
||||
This function is the public autocomplete API. It derives entries directly
|
||||
from `COMMANDS` so callers always observe the current experimental-feature
|
||||
environment instead of a stale import-time snapshot.
|
||||
|
||||
Returns:
|
||||
Autocomplete entries derived from `COMMANDS`, excluding experimental
|
||||
plugin commands unless `DEEPAGENTS_CODE_EXPERIMENTAL` is set.
|
||||
"""
|
||||
from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy
|
||||
|
||||
include_experimental = is_env_truthy(EXPERIMENTAL)
|
||||
return [
|
||||
command.to_entry()
|
||||
for command in COMMANDS
|
||||
if include_experimental or command.name not in _EXPERIMENTAL_PLUGIN_COMMANDS
|
||||
]
|
||||
|
||||
|
||||
def parse_skill_command(command: str) -> tuple[str, str]:
|
||||
@@ -409,13 +458,49 @@ appear as `/skill:model`).
|
||||
"""
|
||||
|
||||
|
||||
def _skill_command_entry(skill: ExtendedSkillMetadata) -> CommandEntry:
|
||||
"""Build a single autocomplete entry for a discovered skill.
|
||||
|
||||
Plugin skills carry a namespaced machine name (`plugin:sub:skill`). Their
|
||||
popup label is shortened to the terminal skill segment and their
|
||||
description is tagged with the plugin id, so the picker stays readable
|
||||
while completion still inserts the full canonical `/skill:` name.
|
||||
|
||||
Returns:
|
||||
The autocomplete entry for the skill.
|
||||
"""
|
||||
machine_name = f"/skill:{skill['name']}"
|
||||
is_plugin = skill.get("source") == "plugin"
|
||||
|
||||
if is_plugin:
|
||||
terminal = skill["name"].rsplit(":", 1)[-1]
|
||||
display_name = f"/skill:{terminal}"
|
||||
plugin_id = skill["name"].split(":", 1)[0]
|
||||
description = f"({plugin_id}) {skill['description']}"
|
||||
else:
|
||||
display_name = ""
|
||||
description = skill["description"]
|
||||
|
||||
return CommandEntry(
|
||||
name=machine_name,
|
||||
description=description,
|
||||
# Match on the full namespaced name and the terminal segment so both
|
||||
# `myplugin:review` and `review` surface the plugin skill.
|
||||
hidden_keywords=skill["name"].replace(":", " "),
|
||||
argument_hint="",
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
def build_skill_commands(
|
||||
skills: list[ExtendedSkillMetadata],
|
||||
) -> list[CommandEntry]:
|
||||
"""Build autocomplete entries for discovered skills.
|
||||
|
||||
Each skill becomes a `/skill:<name>` entry with its description
|
||||
and the skill name as a hidden keyword for fuzzy matching.
|
||||
and the skill name as a hidden keyword for fuzzy matching. Plugin skills
|
||||
keep their namespaced machine name for matching/insertion but present a
|
||||
short, source-tagged label in the popup.
|
||||
|
||||
Skills that already have a dedicated slash command in `COMMANDS`
|
||||
(e.g., `remember` → `/remember`) are excluded to avoid duplicate
|
||||
@@ -428,12 +513,7 @@ def build_skill_commands(
|
||||
List of `CommandEntry` instances.
|
||||
"""
|
||||
return [
|
||||
CommandEntry(
|
||||
name=f"/skill:{skill['name']}",
|
||||
description=skill["description"],
|
||||
hidden_keywords=skill["name"],
|
||||
argument_hint="",
|
||||
)
|
||||
_skill_command_entry(skill)
|
||||
for skill in skills
|
||||
if skill["name"] not in _STATIC_SKILL_ALIASES
|
||||
]
|
||||
|
||||
@@ -1393,6 +1393,8 @@ NON_OPTION_ENV_VARS: frozenset[str] = frozenset(
|
||||
# resolver, so they intentionally have no scalar `env_var` ConfigOption.
|
||||
_env_vars.ENABLED_PROJECT_MCP_SERVERS,
|
||||
_env_vars.DISABLED_PROJECT_MCP_SERVERS,
|
||||
# Plugin cache root override; read directly by plugins.store
|
||||
_env_vars.PLUGIN_CACHE_DIR,
|
||||
}
|
||||
)
|
||||
"""`_env_vars` constants intentionally excluded from the option catalog."""
|
||||
|
||||
@@ -1158,6 +1158,7 @@ async def _preload_session_mcp_server_info(
|
||||
return None
|
||||
|
||||
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
|
||||
from deepagents_code.plugins.adapters.mcp import discover_plugin_mcp_configs
|
||||
from deepagents_code.project_utils import ProjectContext
|
||||
|
||||
session_manager = None
|
||||
@@ -1167,11 +1168,17 @@ async def _preload_session_mcp_server_info(
|
||||
except OSError:
|
||||
logger.warning("Could not determine working directory for MCP preload")
|
||||
project_context = None
|
||||
project_dir = (
|
||||
project_context.project_root or project_context.user_cwd
|
||||
if project_context is not None
|
||||
else None
|
||||
)
|
||||
_tools, session_manager, server_info = await resolve_and_load_mcp_tools(
|
||||
explicit_config_path=mcp_config_path,
|
||||
no_mcp=no_mcp,
|
||||
trust_project_mcp=trust_project_mcp,
|
||||
project_context=project_context,
|
||||
additional_configs=discover_plugin_mcp_configs(project_dir=project_dir),
|
||||
)
|
||||
return server_info
|
||||
finally:
|
||||
@@ -1189,6 +1196,8 @@ _HELP_SPECS: dict[str, tuple[str | None, str]] = {
|
||||
"help": (None, "show_help"),
|
||||
"agents": ("agents_command", "show_agents_help"),
|
||||
"skills": ("skills_command", "show_skills_help"),
|
||||
"plugin": ("plugin_command", "show_plugins_help"),
|
||||
"plugins": ("plugin_command", "show_plugins_help"),
|
||||
"threads": ("threads_command", "show_threads_help"),
|
||||
"mcp": ("mcp_command", "show_mcp_help"),
|
||||
"config": ("config_command", "show_config_help"),
|
||||
@@ -1387,6 +1396,26 @@ def parse_args() -> argparse.Namespace:
|
||||
make_help_action=_make_help_action,
|
||||
)
|
||||
|
||||
from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy
|
||||
|
||||
if is_env_truthy(EXPERIMENTAL):
|
||||
from deepagents_code.plugins.commands_cli import setup_plugin_parser
|
||||
|
||||
setup_plugin_parser(
|
||||
subparsers,
|
||||
make_help_action=_make_help_action,
|
||||
add_output_args=add_json_output_arg,
|
||||
)
|
||||
else:
|
||||
plugin_parser = subparsers.add_parser(
|
||||
"plugin",
|
||||
aliases=["plugins"],
|
||||
add_help=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
plugin_parser.add_argument("plugin_command", nargs="?")
|
||||
plugin_parser.add_argument("plugin_args", nargs=argparse.REMAINDER)
|
||||
|
||||
setup_config_parser(
|
||||
subparsers,
|
||||
make_help_action=_make_help_action,
|
||||
@@ -2197,6 +2226,8 @@ async def _run_acp_cli_async(
|
||||
save_recent_model,
|
||||
touch_recent_model,
|
||||
)
|
||||
from deepagents_code.plugins.adapters.mcp import discover_plugin_mcp_configs
|
||||
from deepagents_code.project_utils import ProjectContext
|
||||
from deepagents_code.tools import fetch_url, get_current_thread_id, web_search
|
||||
|
||||
try:
|
||||
@@ -2211,6 +2242,17 @@ async def _run_acp_cli_async(
|
||||
return 1
|
||||
model_result.apply_to_settings()
|
||||
|
||||
try:
|
||||
project_context = ProjectContext.from_user_cwd(Path.cwd())
|
||||
except (OSError, RuntimeError):
|
||||
logger.warning("Could not determine working directory for ACP MCP loading")
|
||||
project_context = None
|
||||
project_dir = (
|
||||
project_context.project_root or project_context.user_cwd
|
||||
if project_context is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Persist the resolved model so [models].recent is always populated.
|
||||
resolved_spec = f"{model_result.provider}:{model_result.model_name}"
|
||||
save_recent_model(resolved_spec)
|
||||
@@ -2233,6 +2275,12 @@ async def _run_acp_cli_async(
|
||||
explicit_config_path=mcp_config_path,
|
||||
no_mcp=no_mcp,
|
||||
trust_project_mcp=trust_project_mcp,
|
||||
project_context=project_context,
|
||||
additional_configs=(
|
||||
discover_plugin_mcp_configs(project_dir=project_dir)
|
||||
if not no_mcp
|
||||
else ()
|
||||
),
|
||||
)
|
||||
tools.extend(mcp_tools)
|
||||
except FileNotFoundError as exc:
|
||||
@@ -3540,6 +3588,19 @@ def cli_main() -> None:
|
||||
from deepagents_code.skills import execute_skills_command
|
||||
|
||||
execute_skills_command(args)
|
||||
elif args.command in {"plugin", "plugins"}:
|
||||
from deepagents_code._env_vars import (
|
||||
EXPERIMENTAL,
|
||||
EXPERIMENTAL_HINT,
|
||||
is_env_truthy,
|
||||
)
|
||||
|
||||
if not is_env_truthy(EXPERIMENTAL):
|
||||
print(EXPERIMENTAL_HINT) # noqa: T201
|
||||
sys.exit(2)
|
||||
from deepagents_code.plugins.commands_cli import execute_plugin_command
|
||||
|
||||
execute_plugin_command(args)
|
||||
elif args.command == "mcp":
|
||||
from deepagents_code.client.commands.mcp import (
|
||||
run_mcp_config,
|
||||
|
||||
@@ -2168,6 +2168,7 @@ async def resolve_and_load_mcp_tools(
|
||||
no_mcp: bool = False,
|
||||
trust_project_mcp: bool | None = None,
|
||||
project_context: ProjectContext | None = None,
|
||||
additional_configs: tuple[dict[str, Any], ...] = (),
|
||||
stateless: bool = False,
|
||||
session_manager: MCPSessionManager | None = None,
|
||||
) -> tuple[list[BaseTool], MCPSessionManager | None, list[MCPServerInfo]]:
|
||||
@@ -2200,6 +2201,8 @@ async def resolve_and_load_mcp_tools(
|
||||
and explicitly denied servers are dropped even from a trusted one.
|
||||
project_context: Explicit project path context for config discovery
|
||||
and trust resolution.
|
||||
additional_configs: Config layers injected by higher-level composition,
|
||||
such as plugin-provided MCP servers.
|
||||
stateless: When `True`, do not return an owned runtime session manager.
|
||||
session_manager: Optional externally owned runtime session manager.
|
||||
|
||||
@@ -2244,6 +2247,8 @@ async def resolve_and_load_mcp_tools(
|
||||
|
||||
project_trusted: bool | None = None
|
||||
trust_lists: McpServerTrustLists | None = None
|
||||
configs.extend(additional_configs)
|
||||
|
||||
for path in project_configs:
|
||||
config, error = _load_mcp_config_top_level_with_error(path)
|
||||
if error is not None:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Plugin support for dcode."""
|
||||
|
||||
from deepagents_code.plugins.discovery import (
|
||||
add_local_marketplace,
|
||||
add_marketplace_source,
|
||||
discover_plugins,
|
||||
install_plugin,
|
||||
list_available_plugins,
|
||||
list_installed_plugin_ids,
|
||||
remove_marketplace,
|
||||
set_installed_plugin_enabled,
|
||||
uninstall_plugin,
|
||||
)
|
||||
from deepagents_code.plugins.models import PluginDiscoveryResult, PluginInstance
|
||||
|
||||
__all__ = [
|
||||
"PluginDiscoveryResult",
|
||||
"PluginInstance",
|
||||
"add_local_marketplace",
|
||||
"add_marketplace_source",
|
||||
"discover_plugins",
|
||||
"install_plugin",
|
||||
"list_available_plugins",
|
||||
"list_installed_plugin_ids",
|
||||
"remove_marketplace",
|
||||
"set_installed_plugin_enabled",
|
||||
"uninstall_plugin",
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Internal JSON normalization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepagents_code.plugins.models import JsonObject, JsonValue
|
||||
|
||||
|
||||
def json_value(value: object) -> JsonValue | None:
|
||||
"""Normalize a decoded value to the supported JSON type.
|
||||
|
||||
Returns:
|
||||
The normalized value, or `None` for an unsupported value.
|
||||
"""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
normalized: list[JsonValue] = []
|
||||
for item in value:
|
||||
converted = json_value(item)
|
||||
if converted is not None or item is None:
|
||||
normalized.append(converted)
|
||||
return normalized
|
||||
if isinstance(value, dict):
|
||||
normalized_object: JsonObject = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
converted = json_value(item)
|
||||
if converted is not None or item is None:
|
||||
normalized_object[key] = converted
|
||||
return normalized_object
|
||||
return None
|
||||
|
||||
|
||||
def json_object(value: object) -> JsonObject:
|
||||
"""Normalize a decoded value to a JSON object.
|
||||
|
||||
Returns:
|
||||
The normalized object, or an empty object for a non-object value.
|
||||
"""
|
||||
converted = json_value(value)
|
||||
return converted if isinstance(converted, dict) else {}
|
||||
@@ -0,0 +1 @@
|
||||
"""Plugin subsystem adapters."""
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Adapter from plugin MCP declarations to dcode MCP config dictionaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deepagents_code.plugins._json import json_object, json_value
|
||||
from deepagents_code.plugins.substitution import plugin_environment, substitute_json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepagents_code.plugins.models import JsonObject, JsonValue, PluginInstance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# For example, `tools@example.com` becomes `tools_example_com_<hash>`.
|
||||
_MCP_NAME_PART_RE = re.compile(r"[^A-Za-z0-9_-]+")
|
||||
_MCP_NAME_PART_LENGTH = 48
|
||||
|
||||
|
||||
def _safe_mcp_name_part(value: str) -> str:
|
||||
sanitized = _MCP_NAME_PART_RE.sub("_", value).strip("_")
|
||||
if sanitized == value and sanitized and len(sanitized) <= _MCP_NAME_PART_LENGTH:
|
||||
return sanitized
|
||||
digest = sha256(value.encode()).hexdigest()[:8]
|
||||
prefix = sanitized[:_MCP_NAME_PART_LENGTH] or "unnamed"
|
||||
return f"{prefix}_{digest}"
|
||||
|
||||
|
||||
def scoped_mcp_server_name(plugin_id: str, server_name: str) -> str:
|
||||
"""Namespace a plugin-declared MCP server's name under its plugin id.
|
||||
|
||||
Plugin identifiers may contain characters rejected by dcode's MCP loader.
|
||||
Use `__` as the namespace separator so names stay unique and valid.
|
||||
|
||||
Args:
|
||||
plugin_id: Full plugin id in `name@marketplace` form.
|
||||
server_name: Unscoped server name from the plugin config.
|
||||
|
||||
Returns:
|
||||
Scoped server name safe for `_SERVER_NAME_RE`.
|
||||
"""
|
||||
plugin_part = _safe_mcp_name_part(plugin_id)
|
||||
server_part = _safe_mcp_name_part(server_name)
|
||||
return f"plugin__{plugin_part}__{server_part}"
|
||||
|
||||
|
||||
def _server_map(raw: object) -> JsonObject:
|
||||
"""Extract the server-name to config map from a decoded MCP document.
|
||||
|
||||
Accepts Claude's `{"mcpServers": {...}}` wrapper, Codex's
|
||||
`{"mcp_servers": {...}}` wrapper, or a bare server map.
|
||||
|
||||
Returns:
|
||||
The extracted server map, or an empty map for non-object input.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
wrapped = raw.get("mcpServers")
|
||||
if isinstance(wrapped, dict):
|
||||
return json_object(wrapped)
|
||||
codex_wrapped = raw.get("mcp_servers")
|
||||
if isinstance(codex_wrapped, dict):
|
||||
return json_object(codex_wrapped)
|
||||
return json_object(raw)
|
||||
|
||||
|
||||
def _load_mcp_server_map(path: Path) -> JsonObject:
|
||||
"""Load an MCP config file and extract its server-name to config map.
|
||||
|
||||
Returns:
|
||||
The extracted server map, or an empty map when the file cannot be read.
|
||||
"""
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Skipping plugin MCP config %s: %s", path, exc)
|
||||
return {}
|
||||
return _server_map(raw)
|
||||
|
||||
|
||||
def _normalize_server(
|
||||
server: object, *, plugin: PluginInstance, project_dir: Path | None
|
||||
) -> JsonValue:
|
||||
normalized_server = json_value(server)
|
||||
substituted = substitute_json(
|
||||
normalized_server,
|
||||
plugin_root=plugin.root,
|
||||
plugin_data=plugin.data_dir,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
if isinstance(substituted, dict):
|
||||
cwd = substituted.get("cwd")
|
||||
if isinstance(cwd, str) and cwd and not Path(cwd).is_absolute():
|
||||
substituted = {**substituted, "cwd": str((plugin.root / cwd).resolve())}
|
||||
env = substituted.get("env")
|
||||
plugin_env = plugin_environment(
|
||||
plugin_root=plugin.root,
|
||||
plugin_data=plugin.data_dir,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
if isinstance(env, dict):
|
||||
substituted = {**substituted, "env": {**plugin_env, **env}}
|
||||
else:
|
||||
substituted = {**substituted, "env": plugin_env}
|
||||
return json_value(substituted)
|
||||
|
||||
|
||||
def discover_plugin_mcp_configs(
|
||||
*, project_dir: Path | None = None
|
||||
) -> tuple[JsonObject, ...]:
|
||||
"""Discover enabled plugins and compose their MCP config layers.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory for variable substitution.
|
||||
|
||||
Returns:
|
||||
Plugin MCP config layers, or an empty tuple when plugins are disabled or
|
||||
discovery fails.
|
||||
"""
|
||||
from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy
|
||||
|
||||
if not is_env_truthy(EXPERIMENTAL):
|
||||
return ()
|
||||
try:
|
||||
from deepagents_code.plugins import discover_plugins
|
||||
|
||||
result = discover_plugins()
|
||||
except (OSError, RuntimeError):
|
||||
logger.warning("Could not discover plugin MCP configs", exc_info=True)
|
||||
return ()
|
||||
if result.warnings:
|
||||
logger.warning(
|
||||
"Plugin discovery warnings while loading MCP: %s", result.warnings
|
||||
)
|
||||
return tuple(plugin_mcp_configs(result.plugins, project_dir=project_dir))
|
||||
|
||||
|
||||
def plugin_mcp_configs(
|
||||
plugins: tuple[PluginInstance, ...], *, project_dir: Path | None = None
|
||||
) -> list[JsonObject]:
|
||||
"""Build MCP config layers for enabled plugins.
|
||||
|
||||
Default `.mcp.json` files are loaded before manifest `mcpServers`, so manifest
|
||||
entries win on server-name conflicts.
|
||||
|
||||
Args:
|
||||
plugins: Enabled plugin instances.
|
||||
project_dir: Project directory for `${CLAUDE_PROJECT_DIR}` substitution.
|
||||
|
||||
Returns:
|
||||
MCP config layers ready for dcode's merge path.
|
||||
"""
|
||||
configs: list[JsonObject] = []
|
||||
for plugin in plugins:
|
||||
# Create the writable data dir when MCP configs need it. Discovery itself
|
||||
# only computes the path so it stays safe for blockbuster-guarded callers.
|
||||
try:
|
||||
plugin.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not create plugin data dir for %s: %s",
|
||||
plugin.plugin_id,
|
||||
plugin.data_dir,
|
||||
exc_info=True,
|
||||
)
|
||||
servers: JsonObject = {}
|
||||
for path in plugin.inventory.mcp_files:
|
||||
if path.suffix in {".mcpb", ".dxt"}:
|
||||
logger.warning(
|
||||
"Skipping unsupported MCP bundle for plugin %s: %s",
|
||||
plugin.plugin_id,
|
||||
path,
|
||||
)
|
||||
continue
|
||||
servers.update(_load_mcp_server_map(path))
|
||||
if plugin.manifest and plugin.manifest.inline_mcp:
|
||||
servers.update(_server_map(plugin.manifest.inline_mcp))
|
||||
scoped: JsonObject = {}
|
||||
for name, server in servers.items():
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
scoped_name = scoped_mcp_server_name(plugin.plugin_id, name)
|
||||
scoped[scoped_name] = _normalize_server(
|
||||
server, plugin=plugin, project_dir=project_dir
|
||||
)
|
||||
if scoped:
|
||||
configs.append({"mcpServers": scoped})
|
||||
return configs
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Adapter from discovered plugins to `SkillsMiddleware` sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, TypeAlias
|
||||
|
||||
from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepagents_code.plugins.models import PluginInstance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SkillPath: TypeAlias = str
|
||||
SkillLabel: TypeAlias = str
|
||||
SkillNamespace: TypeAlias = str
|
||||
DirectorySkillSource: TypeAlias = tuple[SkillPath, SkillLabel]
|
||||
PluginSkillSource: TypeAlias = tuple[SkillPath, SkillLabel, SkillNamespace]
|
||||
CodeSkillSource: TypeAlias = DirectorySkillSource | PluginSkillSource
|
||||
|
||||
|
||||
def namespaced_skill_name(
|
||||
namespace: SkillNamespace,
|
||||
name: str,
|
||||
subfolders: tuple[str, ...] = (),
|
||||
) -> str:
|
||||
"""Qualify a skill name under its plugin namespace.
|
||||
|
||||
Nested skill directories contribute intermediate `:`-joined segments
|
||||
between the plugin namespace and the skill name, matching the plugin skill
|
||||
naming convention (e.g. `plugin:sub:review`).
|
||||
|
||||
Args:
|
||||
namespace: Plugin namespace (its `plugin_id`).
|
||||
name: Skill name from the skill's frontmatter.
|
||||
subfolders: Directory names between the plugin skills root and the
|
||||
skill directory, in path order.
|
||||
|
||||
Returns:
|
||||
The qualified skill name.
|
||||
"""
|
||||
return ":".join((namespace, *subfolders, name)).lower()
|
||||
|
||||
|
||||
def plugin_skill_sources(
|
||||
plugins: tuple[PluginInstance, ...],
|
||||
) -> list[PluginSkillSource]:
|
||||
"""Return skill source tuples for plugin skills.
|
||||
|
||||
Args:
|
||||
plugins: Plugin instances.
|
||||
|
||||
Returns:
|
||||
Source tuples containing path, label, and plugin namespace.
|
||||
"""
|
||||
sources: list[PluginSkillSource] = []
|
||||
for plugin in plugins:
|
||||
for path in plugin.inventory.skills:
|
||||
source_path = path.parent if path.name == "SKILL.md" else path
|
||||
try:
|
||||
if not source_path.exists():
|
||||
continue
|
||||
except OSError:
|
||||
logger.warning("Could not inspect plugin skill path %s", source_path)
|
||||
continue
|
||||
sources.append(
|
||||
(
|
||||
str(source_path),
|
||||
f"Plugin: {plugin.plugin_id}",
|
||||
plugin.plugin_id,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
|
||||
def plugin_skill_roots(plugins: tuple[PluginInstance, ...]) -> list[Path]:
|
||||
"""Return plugin skill roots for skill-content containment checks.
|
||||
|
||||
Args:
|
||||
plugins: Discovered plugin instances.
|
||||
|
||||
Returns:
|
||||
Skill root directories.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
for plugin in plugins:
|
||||
roots.extend(
|
||||
path.parent if path.name == "SKILL.md" else path
|
||||
for path in plugin.inventory.skills
|
||||
)
|
||||
return roots
|
||||
|
||||
|
||||
def discover_plugin_skill_sources_and_roots() -> tuple[
|
||||
tuple[tuple[Path, str], ...], tuple[Path, ...]
|
||||
]:
|
||||
"""Discover plugin skill sources and containment roots.
|
||||
|
||||
Returns:
|
||||
Plugin skill sources and roots, or empty tuples when plugins are disabled
|
||||
or discovery fails.
|
||||
"""
|
||||
plugin_sources: tuple[tuple[Path, str], ...] = ()
|
||||
plugin_roots: tuple[Path, ...] = ()
|
||||
try:
|
||||
if is_env_truthy(EXPERIMENTAL):
|
||||
from deepagents_code.plugins import discover_plugins
|
||||
|
||||
plugins = discover_plugins().plugins
|
||||
plugin_sources = tuple(
|
||||
(Path(path), namespace)
|
||||
for path, _label, namespace in plugin_skill_sources(plugins)
|
||||
)
|
||||
plugin_roots = tuple(plugin_skill_roots(plugins))
|
||||
except (OSError, RuntimeError):
|
||||
logger.warning("Could not discover plugin skills", exc_info=True)
|
||||
return (), ()
|
||||
|
||||
return plugin_sources, plugin_roots
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Code-local skills middleware adapter for plugin namespaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from deepagents.backends.protocol import FileInfo, LsResult
|
||||
from deepagents.backends.utils import to_posix_path
|
||||
from deepagents.middleware import skills as sdk_skills
|
||||
from deepagents.middleware.skills import SkillsMiddleware
|
||||
|
||||
from deepagents_code.plugins.adapters.skills import (
|
||||
CodeSkillSource,
|
||||
SkillNamespace,
|
||||
namespaced_skill_name,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from deepagents.backends.protocol import BACKEND_TYPES, BackendProtocol
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLUGIN_SKILL_SOURCE_LENGTH = 3
|
||||
_SKILL_FILE = "SKILL.md"
|
||||
|
||||
|
||||
def _entries(ls_result: object) -> list[FileInfo]:
|
||||
"""Normalize a backend `ls` result to a list of entry dicts.
|
||||
|
||||
Returns:
|
||||
The listing entries, or an empty list when the result is empty or an
|
||||
unexpected shape.
|
||||
"""
|
||||
if isinstance(ls_result, LsResult):
|
||||
return list(ls_result.entries or [])
|
||||
if isinstance(ls_result, list):
|
||||
return cast("list[FileInfo]", ls_result)
|
||||
return []
|
||||
|
||||
|
||||
def _child_dirs(entries: list[FileInfo], root: str) -> list[tuple[str, str]]:
|
||||
"""Return `(name, path)` for each immediate subdirectory in `entries`.
|
||||
|
||||
Returns:
|
||||
Name/path pairs for each immediate subdirectory, excluding `root`.
|
||||
"""
|
||||
root_posix = PurePosixPath(to_posix_path(root))
|
||||
dirs: list[tuple[str, str]] = []
|
||||
for entry in entries:
|
||||
if not entry.get("is_dir"):
|
||||
continue
|
||||
path = entry["path"]
|
||||
name = PurePosixPath(to_posix_path(path)).name
|
||||
# Skip the source dir itself if a backend echoes it back.
|
||||
if PurePosixPath(to_posix_path(path)) == root_posix:
|
||||
continue
|
||||
dirs.append((name, path))
|
||||
return dirs
|
||||
|
||||
|
||||
def _has_skill_file(entries: list[FileInfo], root: str) -> bool:
|
||||
"""Return whether `entries` contains a `SKILL.md` directly under `root`."""
|
||||
root_posix = PurePosixPath(to_posix_path(root))
|
||||
for entry in entries:
|
||||
path = PurePosixPath(to_posix_path(entry["path"]))
|
||||
if path.name == _SKILL_FILE and path.parent == root_posix:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _skill_md_path(skill_dir: str) -> str:
|
||||
"""Return the `SKILL.md` path inside a skill directory."""
|
||||
return str(PurePosixPath(to_posix_path(skill_dir)) / _SKILL_FILE)
|
||||
|
||||
|
||||
def _namespace_skill(
|
||||
skill: sdk_skills.SkillMetadata,
|
||||
namespace: SkillNamespace,
|
||||
subfolders: tuple[str, ...],
|
||||
) -> sdk_skills.SkillMetadata:
|
||||
"""Return a copy of `skill` with a namespace-qualified name."""
|
||||
return cast(
|
||||
"sdk_skills.SkillMetadata",
|
||||
{
|
||||
**skill,
|
||||
"name": namespaced_skill_name(namespace, skill["name"], subfolders),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def discover_skill_dirs(
|
||||
backend: BackendProtocol,
|
||||
source_path: str,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Return `(skill_dir, subfolders)` pairs found under `source_path`.
|
||||
|
||||
Walks the source tree, treating any directory that directly contains a
|
||||
`SKILL.md` as a skill directory (a recursion leaf, like a plugin walker).
|
||||
`subfolders` holds the directory names between the source
|
||||
root and the skill directory, excluding the skill directory's own name.
|
||||
|
||||
Returns:
|
||||
Skill directories paired with their intermediate subfolder segments.
|
||||
"""
|
||||
found: list[tuple[str, tuple[str, ...]]] = []
|
||||
# `path_segments` accumulates directory names from the source root down to
|
||||
# and including `current`. A skill directory's own name is dropped when
|
||||
# naming, since the skill's terminal identifier is its frontmatter name;
|
||||
# only the directories above it form the namespace segments.
|
||||
source_root = Path(source_path).resolve()
|
||||
visited: set[Path] = set()
|
||||
stack: list[tuple[str, tuple[str, ...]]] = [(str(source_root), ())]
|
||||
while stack:
|
||||
current, path_segments = stack.pop()
|
||||
try:
|
||||
resolved = Path(current).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
logger.warning("Could not resolve plugin skill directory %s", current)
|
||||
continue
|
||||
if not resolved.is_relative_to(source_root) or resolved in visited:
|
||||
continue
|
||||
visited.add(resolved)
|
||||
resolved_path = str(resolved)
|
||||
entries = _entries(backend.ls(resolved_path))
|
||||
if _has_skill_file(entries, resolved_path):
|
||||
found.append((resolved_path, path_segments[:-1]))
|
||||
continue
|
||||
for name, path in _child_dirs(entries, resolved_path):
|
||||
stack.append((path, (*path_segments, name)))
|
||||
return found
|
||||
|
||||
|
||||
async def adiscover_skill_dirs(
|
||||
backend: BackendProtocol,
|
||||
source_path: str,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Async counterpart of `discover_skill_dirs`.
|
||||
|
||||
Returns:
|
||||
Skill directories paired with their intermediate subfolder segments.
|
||||
"""
|
||||
found: list[tuple[str, tuple[str, ...]]] = []
|
||||
source_root = await asyncio.to_thread(Path(source_path).resolve)
|
||||
visited: set[Path] = set()
|
||||
stack: list[tuple[str, tuple[str, ...]]] = [(str(source_root), ())]
|
||||
while stack:
|
||||
current, path_segments = stack.pop()
|
||||
try:
|
||||
resolved = await asyncio.to_thread(Path(current).resolve)
|
||||
except (OSError, RuntimeError):
|
||||
logger.warning("Could not resolve plugin skill directory %s", current)
|
||||
continue
|
||||
if not resolved.is_relative_to(source_root) or resolved in visited:
|
||||
continue
|
||||
visited.add(resolved)
|
||||
resolved_path = str(resolved)
|
||||
entries = _entries(await backend.als(resolved_path))
|
||||
if _has_skill_file(entries, resolved_path):
|
||||
found.append((resolved_path, path_segments[:-1]))
|
||||
continue
|
||||
for name, path in _child_dirs(entries, resolved_path):
|
||||
stack.append((path, (*path_segments, name)))
|
||||
return found
|
||||
|
||||
|
||||
def load_namespaced_skills(
|
||||
backend: BackendProtocol,
|
||||
source_path: str,
|
||||
namespace: SkillNamespace,
|
||||
) -> list[sdk_skills.SkillMetadata]:
|
||||
"""Load and namespace every skill found under a plugin source.
|
||||
|
||||
Reads each discovered skill directory's `SKILL.md` directly, since the SDK
|
||||
loader only scans one level below a source and would not read a leaf
|
||||
directory's own `SKILL.md`. Nested directories become `:`-joined namespace
|
||||
segments (e.g. `plugin:foo:bar:review`).
|
||||
|
||||
Returns:
|
||||
Namespace-qualified skill metadata for the source.
|
||||
"""
|
||||
skill_dirs = discover_skill_dirs(backend, source_path)
|
||||
if not skill_dirs:
|
||||
return []
|
||||
paths = [_skill_md_path(skill_dir) for skill_dir, _ in skill_dirs]
|
||||
responses = backend.download_files(paths)
|
||||
skills: list[sdk_skills.SkillMetadata] = []
|
||||
for (skill_dir, segments), path, response in zip(
|
||||
skill_dirs, paths, responses, strict=True
|
||||
):
|
||||
skill = sdk_skills._skill_metadata_from_response(response, skill_dir, path)
|
||||
if skill is not None:
|
||||
skills.append(_namespace_skill(skill, namespace, segments))
|
||||
return skills
|
||||
|
||||
|
||||
async def aload_namespaced_skills(
|
||||
backend: BackendProtocol,
|
||||
source_path: str,
|
||||
namespace: SkillNamespace,
|
||||
) -> list[sdk_skills.SkillMetadata]:
|
||||
"""Async counterpart of `load_namespaced_skills`.
|
||||
|
||||
Returns:
|
||||
Namespace-qualified skill metadata for the source.
|
||||
"""
|
||||
skill_dirs = await adiscover_skill_dirs(backend, source_path)
|
||||
if not skill_dirs:
|
||||
return []
|
||||
paths = [_skill_md_path(skill_dir) for skill_dir, _ in skill_dirs]
|
||||
responses = await backend.adownload_files(paths)
|
||||
skills: list[sdk_skills.SkillMetadata] = []
|
||||
for (skill_dir, segments), path, response in zip(
|
||||
skill_dirs, paths, responses, strict=True
|
||||
):
|
||||
skill = sdk_skills._skill_metadata_from_response(response, skill_dir, path)
|
||||
if skill is not None:
|
||||
skills.append(_namespace_skill(skill, namespace, segments))
|
||||
return skills
|
||||
|
||||
|
||||
class PluginSkillsMiddleware(SkillsMiddleware):
|
||||
"""Load namespaced plugin skills without extending the SDK source API.
|
||||
|
||||
Wraps the SDK `SkillsMiddleware`. Sources without a namespace load exactly
|
||||
as the SDK loads them. Sources carrying a plugin namespace are walked
|
||||
recursively so nested skill directories (`skills/foo/bar/review/SKILL.md`)
|
||||
are discovered, and each skill's name is qualified as
|
||||
`plugin_id:foo:bar:review` before the last-one-wins merge — matching
|
||||
the plugin skill naming convention.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
backend: BACKEND_TYPES,
|
||||
sources: Sequence[CodeSkillSource],
|
||||
system_prompt: str | None = sdk_skills.SKILLS_SYSTEM_PROMPT,
|
||||
) -> None:
|
||||
"""Initialize the middleware with Code-local plugin source tuples.
|
||||
|
||||
Args:
|
||||
backend: Backend used to load skill files.
|
||||
sources: Ordered Code skill sources, optionally including a plugin
|
||||
namespace as the third tuple item.
|
||||
system_prompt: Skills prompt template passed to the SDK middleware.
|
||||
"""
|
||||
sdk_sources = [(source[0], source[1]) for source in sources]
|
||||
super().__init__(
|
||||
backend=backend,
|
||||
sources=sdk_sources,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
self._namespaces = tuple(
|
||||
source[2] if len(source) == _PLUGIN_SKILL_SOURCE_LENGTH else None
|
||||
for source in sources
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _state_update(
|
||||
all_skills: dict[str, sdk_skills.SkillMetadata],
|
||||
errors: list[str],
|
||||
) -> sdk_skills.SkillsStateUpdate:
|
||||
"""Build the middleware state update, logging any load errors.
|
||||
|
||||
Returns:
|
||||
The state update carrying merged skill metadata and any errors.
|
||||
"""
|
||||
update = sdk_skills.SkillsStateUpdate(skills_metadata=list(all_skills.values()))
|
||||
if errors:
|
||||
logger.warning("Skills load errors: %s", errors)
|
||||
update["skills_load_errors"] = errors
|
||||
return update
|
||||
|
||||
def before_agent(
|
||||
self,
|
||||
state: sdk_skills.SkillsState,
|
||||
runtime: Runtime,
|
||||
config: RunnableConfig,
|
||||
) -> sdk_skills.SkillsStateUpdate | None:
|
||||
"""Load and namespace plugin skills before collision resolution.
|
||||
|
||||
Returns:
|
||||
A state update containing collision-safe skill metadata, or `None`
|
||||
when skills are already loaded.
|
||||
"""
|
||||
if "skills_metadata" in state:
|
||||
return None
|
||||
|
||||
backend = self._get_backend(state, runtime, config)
|
||||
all_skills: dict[str, sdk_skills.SkillMetadata] = {}
|
||||
errors: list[str] = []
|
||||
|
||||
for source_path, namespace in zip(self.sources, self._namespaces, strict=True):
|
||||
if namespace is None:
|
||||
source_skills, source_error = sdk_skills._list_skills_with_errors(
|
||||
backend, source_path
|
||||
)
|
||||
if source_error is not None:
|
||||
errors.append(source_error)
|
||||
else:
|
||||
source_skills = load_namespaced_skills(backend, source_path, namespace)
|
||||
for skill in source_skills:
|
||||
all_skills[skill["name"]] = skill
|
||||
|
||||
return self._state_update(all_skills, errors)
|
||||
|
||||
async def abefore_agent(
|
||||
self,
|
||||
state: sdk_skills.SkillsState,
|
||||
runtime: Runtime,
|
||||
config: RunnableConfig,
|
||||
) -> sdk_skills.SkillsStateUpdate | None:
|
||||
"""Asynchronously load and namespace skills before collision resolution.
|
||||
|
||||
Returns:
|
||||
A state update containing collision-safe skill metadata, or `None`
|
||||
when skills are already loaded.
|
||||
"""
|
||||
if "skills_metadata" in state:
|
||||
return None
|
||||
|
||||
backend = self._get_backend(state, runtime, config)
|
||||
all_skills: dict[str, sdk_skills.SkillMetadata] = {}
|
||||
errors: list[str] = []
|
||||
|
||||
for source_path, namespace in zip(self.sources, self._namespaces, strict=True):
|
||||
if namespace is None:
|
||||
(
|
||||
source_skills,
|
||||
source_error,
|
||||
) = await sdk_skills._alist_skills_with_errors(backend, source_path)
|
||||
if source_error is not None:
|
||||
errors.append(source_error)
|
||||
else:
|
||||
source_skills = await aload_namespaced_skills(
|
||||
backend, source_path, namespace
|
||||
)
|
||||
for skill in source_skills:
|
||||
all_skills[skill["name"]] = skill
|
||||
|
||||
return self._state_update(all_skills, errors)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""CLI helpers for plugin management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from deepagents_code.plugins import (
|
||||
add_marketplace_source,
|
||||
install_plugin,
|
||||
list_available_plugins,
|
||||
remove_marketplace,
|
||||
set_installed_plugin_enabled,
|
||||
uninstall_plugin,
|
||||
)
|
||||
from deepagents_code.plugins.marketplace import (
|
||||
MarketplaceError,
|
||||
redact_marketplace_source,
|
||||
redact_urls_in_text,
|
||||
)
|
||||
from deepagents_code.plugins.store import load_marketplace_records
|
||||
|
||||
|
||||
def setup_plugin_parser(
|
||||
subparsers: Any, # noqa: ANN401 # argparse subparsers uses dynamic typing
|
||||
*,
|
||||
make_help_action: Callable[[Callable[[], None]], type[argparse.Action]],
|
||||
add_output_args: Callable[[argparse.ArgumentParser], None] | None = None,
|
||||
) -> argparse.ArgumentParser:
|
||||
"""Set up the `plugin` CLI parser.
|
||||
|
||||
Args:
|
||||
subparsers: Parent argparse subparsers object.
|
||||
make_help_action: Factory for parser-specific help actions.
|
||||
add_output_args: Optional callback that adds output-format flags.
|
||||
|
||||
Returns:
|
||||
Plugin command parser.
|
||||
"""
|
||||
|
||||
def _help() -> None:
|
||||
from deepagents_code.ui import show_plugins_help
|
||||
|
||||
show_plugins_help()
|
||||
|
||||
parent = argparse.ArgumentParser(add_help=False)
|
||||
parent.add_argument("-h", "--help", action=make_help_action(_help))
|
||||
parser = subparsers.add_parser(
|
||||
"plugin",
|
||||
aliases=["plugins"],
|
||||
help="Manage plugins",
|
||||
add_help=False,
|
||||
parents=[parent],
|
||||
)
|
||||
if add_output_args is not None:
|
||||
add_output_args(parser)
|
||||
plugin_sub = parser.add_subparsers(dest="plugin_command")
|
||||
|
||||
list_parser = plugin_sub.add_parser("list", aliases=["ls"], help="List plugins")
|
||||
if add_output_args is not None:
|
||||
add_output_args(list_parser)
|
||||
|
||||
install_parser = plugin_sub.add_parser("install", help="Install a plugin")
|
||||
install_parser.add_argument("plugin_id")
|
||||
uninstall_parser = plugin_sub.add_parser("uninstall", help="Uninstall a plugin")
|
||||
uninstall_parser.add_argument("plugin_id")
|
||||
|
||||
enable_parser = plugin_sub.add_parser("enable", help="Enable a plugin")
|
||||
enable_parser.add_argument("plugin_id")
|
||||
disable_parser = plugin_sub.add_parser("disable", help="Disable a plugin")
|
||||
disable_parser.add_argument("plugin_id")
|
||||
|
||||
marketplace_parser = plugin_sub.add_parser(
|
||||
"marketplace", help="Manage plugin marketplaces"
|
||||
)
|
||||
marketplace_sub = marketplace_parser.add_subparsers(dest="marketplace_command")
|
||||
marketplace_list = marketplace_sub.add_parser(
|
||||
"list", aliases=["ls"], help="List marketplaces"
|
||||
)
|
||||
if add_output_args is not None:
|
||||
add_output_args(marketplace_list)
|
||||
marketplace_add = marketplace_sub.add_parser("add", help="Add a marketplace")
|
||||
marketplace_add.add_argument("source")
|
||||
marketplace_remove = marketplace_sub.add_parser(
|
||||
"remove", help="Remove a marketplace and uninstall its plugins"
|
||||
)
|
||||
marketplace_remove.add_argument("name")
|
||||
return parser
|
||||
|
||||
|
||||
def _plugin_list_rows() -> list[dict[str, object]]:
|
||||
return [
|
||||
{"id": plugin_id, "description": description, "enabled": enabled}
|
||||
for plugin_id, description, enabled in list_available_plugins()
|
||||
]
|
||||
|
||||
|
||||
def execute_plugin_command(args: argparse.Namespace) -> str | None:
|
||||
"""Execute a plugin management command.
|
||||
|
||||
Args:
|
||||
args: Parsed argparse namespace.
|
||||
|
||||
Returns:
|
||||
Text output for slash-command callers, or `None` when output was written.
|
||||
|
||||
Raises:
|
||||
SystemExit: With status 1 when a mutating command fails.
|
||||
"""
|
||||
output_format = getattr(args, "output_format", "text")
|
||||
command = getattr(args, "plugin_command", None)
|
||||
if command is None:
|
||||
from deepagents_code.ui import show_plugins_help
|
||||
|
||||
show_plugins_help()
|
||||
return None
|
||||
if command in {"list", "ls"}:
|
||||
rows = _plugin_list_rows()
|
||||
if output_format == "json":
|
||||
from deepagents_code.output import write_json
|
||||
|
||||
write_json("plugin list", rows)
|
||||
return None
|
||||
if not rows:
|
||||
text = "No plugin marketplaces configured."
|
||||
else:
|
||||
lines = []
|
||||
for row in rows:
|
||||
status = "enabled" if row["enabled"] else "disabled"
|
||||
lines.append(f"{status} {row['id']} {row['description']}".rstrip())
|
||||
text = "\n".join(lines)
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
if command == "install":
|
||||
try:
|
||||
instance = install_plugin(args.plugin_id)
|
||||
except (MarketplaceError, FileNotFoundError, OSError, ValueError) as exc:
|
||||
text = f"Failed to install {args.plugin_id}: {exc}"
|
||||
print(text) # noqa: T201
|
||||
raise SystemExit(1) from exc
|
||||
details = ""
|
||||
if instance.version is not None:
|
||||
details = f" (version: {instance.version})"
|
||||
text = (
|
||||
f"Installed plugin {instance.plugin_id}{details}. Run /reload to activate."
|
||||
)
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
if command == "uninstall":
|
||||
uninstall_plugin(args.plugin_id)
|
||||
text = f"Uninstalled plugin {args.plugin_id}."
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
if command in {"enable", "disable"}:
|
||||
enabled = command == "enable"
|
||||
try:
|
||||
set_installed_plugin_enabled(args.plugin_id, enabled=enabled)
|
||||
except (MarketplaceError, OSError, ValueError) as exc:
|
||||
text = f"Failed to {command} {args.plugin_id}: {exc}"
|
||||
print(text) # noqa: T201
|
||||
raise SystemExit(1) from exc
|
||||
text = f"{command.title()}d plugin {args.plugin_id}."
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
if command == "marketplace":
|
||||
marketplace_command = getattr(args, "marketplace_command", None)
|
||||
if marketplace_command in {"list", "ls"}:
|
||||
records = load_marketplace_records()
|
||||
rows = [
|
||||
{
|
||||
"name": record.name,
|
||||
"source_type": record.source_type,
|
||||
"source": redact_marketplace_source(record.source),
|
||||
"install_location": (
|
||||
record.install_location
|
||||
if record.source_type in {"directory", "file"}
|
||||
else "<managed cache>"
|
||||
),
|
||||
}
|
||||
for record in records.values()
|
||||
]
|
||||
if output_format == "json":
|
||||
from deepagents_code.output import write_json
|
||||
|
||||
write_json("plugin marketplace list", rows)
|
||||
return None
|
||||
text = (
|
||||
"No plugin marketplaces configured."
|
||||
if not rows
|
||||
else "\n".join(f"{row['name']} {row['source']}" for row in rows)
|
||||
)
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
if marketplace_command == "add":
|
||||
try:
|
||||
marketplace = add_marketplace_source(args.source)
|
||||
except (MarketplaceError, FileNotFoundError, OSError, ValueError) as exc:
|
||||
source = redact_marketplace_source(args.source)
|
||||
text = (
|
||||
f"Failed to add marketplace {source}: "
|
||||
f"{redact_urls_in_text(str(exc))}"
|
||||
)
|
||||
print(text) # noqa: T201
|
||||
raise SystemExit(1) from exc
|
||||
text = (
|
||||
f"Added marketplace {marketplace.name} "
|
||||
f"({len(marketplace.plugins)} plugin(s))."
|
||||
)
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
if marketplace_command == "remove":
|
||||
removed = remove_marketplace(args.name)
|
||||
text = (
|
||||
f"Removed marketplace {args.name} and its installed plugins."
|
||||
if removed
|
||||
else f"Marketplace {args.name} is not configured."
|
||||
)
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
text = "Usage: plugin {list,install,uninstall,enable,disable,marketplace}"
|
||||
print(text) # noqa: T201
|
||||
return text
|
||||
@@ -0,0 +1,392 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,272 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -0,0 +1,805 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Data models for plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
MarketplaceSourceType = Literal["directory", "file", "github", "git", "url"]
|
||||
ExternalPluginRepositorySourceType = Literal["github", "git-subdir", "url"]
|
||||
JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
JsonObject = dict[str, JsonValue]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class LocalMarketplaceSource:
|
||||
"""Local directory or JSON file used as a marketplace source."""
|
||||
|
||||
source_type: Literal["directory", "file"]
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RepositoryMarketplaceSource:
|
||||
"""GitHub or Git repository used as a marketplace source.
|
||||
|
||||
`ref` selects an optional branch or tag. Commit SHA checkout is not part of
|
||||
the shallow-clone flow.
|
||||
"""
|
||||
|
||||
source_type: Literal["github", "git"]
|
||||
value: str
|
||||
ref: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class UrlMarketplaceSource:
|
||||
"""Marketplace manifest downloaded from an HTTP URL."""
|
||||
|
||||
source_type: Literal["url"]
|
||||
value: str
|
||||
|
||||
|
||||
MarketplaceSource = (
|
||||
LocalMarketplaceSource | RepositoryMarketplaceSource | UrlMarketplaceSource
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PluginManifest:
|
||||
"""Parsed plugin manifest.
|
||||
|
||||
Attributes:
|
||||
name: Plugin name from the manifest, or `None` for manifest-less plugins.
|
||||
version: Version string from the plugin manifest.
|
||||
component_paths: Validated skill and MCP paths keyed by component name.
|
||||
inline_mcp: Inline MCP servers declared in the manifest.
|
||||
"""
|
||||
|
||||
name: str | None
|
||||
version: str | None
|
||||
component_paths: dict[str, tuple[Path, ...]]
|
||||
inline_mcp: JsonObject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ComponentInventory:
|
||||
"""Inventory of supported plugin components."""
|
||||
|
||||
skills: tuple[Path, ...] = ()
|
||||
mcp_files: tuple[Path, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PluginInstance:
|
||||
"""A discovered plugin ready to feed dcode adapters.
|
||||
|
||||
Attributes:
|
||||
plugin_id: Stable id in `{name}@{marketplace}` form.
|
||||
name: Plugin namespace name.
|
||||
marketplace: Parent marketplace used for identity and namespacing.
|
||||
version: Version declared by the plugin manifest, if any.
|
||||
root: Plugin root directory.
|
||||
data_dir: Writable data directory for this plugin.
|
||||
manifest: Parsed manifest, if any.
|
||||
inventory: Component inventory.
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
name: str
|
||||
marketplace: str
|
||||
version: str | None
|
||||
root: Path
|
||||
data_dir: Path
|
||||
manifest: PluginManifest | None
|
||||
inventory: ComponentInventory
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate the canonical plugin identity.
|
||||
|
||||
Raises:
|
||||
ValueError: If `plugin_id` disagrees with `name` and `marketplace`.
|
||||
"""
|
||||
expected = f"{self.name}@{self.marketplace}"
|
||||
if self.plugin_id != expected:
|
||||
msg = f"Plugin id {self.plugin_id!r} does not match {expected!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class LocalPluginSource:
|
||||
"""A plugin stored relative to its marketplace."""
|
||||
|
||||
source_type: Literal["local"]
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class GithubPluginSource:
|
||||
"""A plugin sourced from a GitHub repository."""
|
||||
|
||||
source_type: Literal["github"]
|
||||
repo: str
|
||||
ref: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class GitSubdirectoryPluginSource:
|
||||
"""A plugin sourced from a subdirectory in a Git repository."""
|
||||
|
||||
source_type: Literal["git-subdir"]
|
||||
url: str
|
||||
ref: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class UrlPluginSource:
|
||||
"""A plugin sourced from a Git repository URL."""
|
||||
|
||||
source_type: Literal["url"]
|
||||
url: str
|
||||
ref: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
PluginSource = (
|
||||
LocalPluginSource
|
||||
| GithubPluginSource
|
||||
| GitSubdirectoryPluginSource
|
||||
| UrlPluginSource
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class MarketplacePluginEntry:
|
||||
"""A catalog entry from a marketplace manifest."""
|
||||
|
||||
name: str
|
||||
source: PluginSource
|
||||
description: str | None = None
|
||||
author: str | JsonObject | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PluginMarketplace:
|
||||
"""A parsed marketplace manifest."""
|
||||
|
||||
name: str
|
||||
root: Path
|
||||
manifest_path: Path
|
||||
metadata: JsonObject
|
||||
plugins: tuple[MarketplacePluginEntry, ...]
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class MarketplaceRecord:
|
||||
"""Persisted marketplace source record."""
|
||||
|
||||
name: str
|
||||
source_type: MarketplaceSourceType
|
||||
source: str
|
||||
install_location: str
|
||||
ref: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InstalledPluginEntry:
|
||||
"""Install record for a plugin.
|
||||
|
||||
`version` is the value declared by the plugin manifest, if any.
|
||||
"""
|
||||
|
||||
install_path: str
|
||||
version: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PluginDiscoveryResult:
|
||||
"""Result from plugin discovery."""
|
||||
|
||||
plugins: tuple[PluginInstance, ...]
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def split_plugin_id(plugin_id: str) -> tuple[str, str]:
|
||||
"""Split a plugin id in `{plugin}@{marketplace}` form.
|
||||
|
||||
Returns:
|
||||
Plugin and marketplace names.
|
||||
|
||||
Raises:
|
||||
ValueError: If either part is missing.
|
||||
"""
|
||||
if "@" not in plugin_id:
|
||||
msg = f"Invalid plugin id {plugin_id!r}; expected name@marketplace"
|
||||
raise ValueError(msg)
|
||||
plugin, marketplace = plugin_id.rsplit("@", 1)
|
||||
if not plugin or not marketplace:
|
||||
msg = f"Invalid plugin id {plugin_id!r}; expected name@marketplace"
|
||||
raise ValueError(msg)
|
||||
return plugin, marketplace
|
||||
@@ -0,0 +1,555 @@
|
||||
"""State storage for dcode plugin marketplaces, installs, and enablement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Never
|
||||
|
||||
from deepagents_code.plugins.models import (
|
||||
InstalledPluginEntry,
|
||||
MarketplaceRecord,
|
||||
MarketplaceSourceType,
|
||||
split_plugin_id,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_STORAGE_VERSION = 1
|
||||
_INSTALLED_STORAGE_VERSION = 2
|
||||
_UNVERSIONED_CACHE_KEY = "unversioned"
|
||||
_CACHE_SLUG_LENGTH = 48
|
||||
_CACHE_DIGEST_LENGTH = 32
|
||||
SUPPORTED_MARKETPLACE_SOURCE_TYPES: frozenset[MarketplaceSourceType] = frozenset(
|
||||
{"directory", "file", "github", "git", "url"}
|
||||
)
|
||||
|
||||
|
||||
class PluginStateError(OSError):
|
||||
"""Raised when existing plugin state cannot be safely modified."""
|
||||
|
||||
|
||||
def plugin_storage_root() -> Path:
|
||||
"""Return the plugin storage root directory."""
|
||||
from deepagents_code._env_vars import PLUGIN_CACHE_DIR
|
||||
from deepagents_code.model_config import DEFAULT_CONFIG_DIR
|
||||
|
||||
raw = os.environ.get(PLUGIN_CACHE_DIR)
|
||||
if raw:
|
||||
return Path(raw).expanduser()
|
||||
return DEFAULT_CONFIG_DIR / "plugins"
|
||||
|
||||
|
||||
def plugin_data_dir(plugin_id: str) -> Path:
|
||||
"""Return the data directory path for a plugin id without creating it.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
||||
|
||||
Returns:
|
||||
Path under the plugin storage root's `data/` directory.
|
||||
"""
|
||||
return plugin_storage_root() / "data" / sanitize_plugin_id(plugin_id)
|
||||
|
||||
|
||||
def ensure_plugin_data_dir(plugin_id: str) -> Path:
|
||||
"""Return the lazily-created data directory for a plugin id."""
|
||||
data_dir = plugin_data_dir(plugin_id)
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
return data_dir
|
||||
|
||||
|
||||
def sanitize_plugin_id(value: str) -> str:
|
||||
"""Return a bounded, collision-resistant filesystem key.
|
||||
|
||||
Args:
|
||||
value: Identity string to encode.
|
||||
|
||||
Returns:
|
||||
Filesystem-safe plugin id.
|
||||
"""
|
||||
slug = "".join(
|
||||
ch if ch.isascii() and (ch.isalnum() or ch in {"_", "-"}) else "-"
|
||||
for ch in value
|
||||
)
|
||||
slug = slug.strip("-")[:_CACHE_SLUG_LENGTH] or "plugin"
|
||||
digest = sha256(value.encode()).hexdigest()[:_CACHE_DIGEST_LENGTH]
|
||||
return f"{slug}-{digest}"
|
||||
|
||||
|
||||
def opaque_cache_key(value: str) -> str:
|
||||
"""Return a cache key that cannot disclose source credentials."""
|
||||
return sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def ensure_marketplace_cache_dir() -> Path:
|
||||
"""Return the marketplace cache directory."""
|
||||
path = plugin_storage_root() / "marketplaces"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def ensure_plugin_install_cache_dir() -> Path:
|
||||
"""Return the versioned plugin install cache root."""
|
||||
path = plugin_storage_root() / "cache"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def versioned_cache_path(plugin_id: str, version: str | None) -> Path:
|
||||
"""Return the versioned cache path for a plugin id.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
||||
version: Plugin version string, or `None` when unversioned.
|
||||
|
||||
Returns:
|
||||
Cache directory `cache/{marketplace}/{plugin}/{version}/`, relative to
|
||||
the plugin storage root.
|
||||
|
||||
"""
|
||||
plugin_name, marketplace = split_plugin_id(plugin_id)
|
||||
safe_version = sanitize_plugin_id(version or _UNVERSIONED_CACHE_KEY)
|
||||
return (
|
||||
ensure_plugin_install_cache_dir()
|
||||
/ sanitize_plugin_id(marketplace)
|
||||
/ sanitize_plugin_id(plugin_name)
|
||||
/ safe_version
|
||||
)
|
||||
|
||||
|
||||
def _state_dir() -> Path:
|
||||
from deepagents_code.model_config import DEFAULT_STATE_DIR
|
||||
|
||||
return DEFAULT_STATE_DIR
|
||||
|
||||
|
||||
def _marketplaces_path() -> Path:
|
||||
return _state_dir() / "plugin_marketplaces.json"
|
||||
|
||||
|
||||
def _plugin_state_path() -> Path:
|
||||
return _state_dir() / "plugin_state.json"
|
||||
|
||||
|
||||
def _installed_plugins_path() -> Path:
|
||||
return _state_dir() / "installed_plugins.json"
|
||||
|
||||
|
||||
def _invalid_state(
|
||||
path: Path, detail: str, *, strict: bool, cause: Exception | None = None
|
||||
) -> dict[str, Any]:
|
||||
msg = f"Plugin state file {path} {detail}"
|
||||
if strict:
|
||||
raise PluginStateError(msg) from cause
|
||||
logger.warning("%s", msg)
|
||||
return {}
|
||||
|
||||
|
||||
def _load_json(
|
||||
path: Path,
|
||||
*,
|
||||
max_version: int = _STORAGE_VERSION,
|
||||
strict: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
return _invalid_state(
|
||||
path, f"could not be read: {exc}", strict=strict, cause=exc
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return _invalid_state(path, "is not a JSON object", strict=strict)
|
||||
version = data.get("version")
|
||||
if version is not None and (
|
||||
not isinstance(version, int)
|
||||
or isinstance(version, bool)
|
||||
or version > max_version
|
||||
):
|
||||
return _invalid_state(
|
||||
path, f"has unsupported version {version!r}", strict=strict
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _raise_state_shape(path: Path, detail: str) -> Never:
|
||||
msg = f"Plugin state file {path} {detail}"
|
||||
raise PluginStateError(msg)
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
Path(tmp_name).replace(path)
|
||||
except Exception:
|
||||
with suppress(OSError):
|
||||
Path(tmp_name).unlink()
|
||||
raise
|
||||
|
||||
|
||||
def load_marketplace_records(*, strict: bool = False) -> dict[str, MarketplaceRecord]:
|
||||
"""Load persisted marketplace records.
|
||||
|
||||
Returns:
|
||||
Marketplace records keyed by marketplace name.
|
||||
"""
|
||||
data = _load_json(_marketplaces_path(), strict=strict)
|
||||
raw_records = data.get("marketplaces", {})
|
||||
if not isinstance(raw_records, dict):
|
||||
if strict:
|
||||
_raise_state_shape(_marketplaces_path(), "has invalid marketplaces data")
|
||||
return {}
|
||||
records: dict[str, MarketplaceRecord] = {}
|
||||
for name, record in raw_records.items():
|
||||
if not isinstance(name, str) or not isinstance(record, dict):
|
||||
continue
|
||||
source_type = record.get("source_type")
|
||||
source = record.get("source")
|
||||
if (
|
||||
source_type not in SUPPORTED_MARKETPLACE_SOURCE_TYPES
|
||||
or not isinstance(source, str)
|
||||
or not isinstance(record.get("install_location", source), str)
|
||||
):
|
||||
logger.debug("Skipping unsupported marketplace record %r", name)
|
||||
continue
|
||||
ref = record.get("ref")
|
||||
records[name] = MarketplaceRecord(
|
||||
name=name,
|
||||
source_type=source_type,
|
||||
source=source,
|
||||
install_location=record.get("install_location", source),
|
||||
ref=ref if isinstance(ref, str) else None,
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def save_marketplace_record(record: MarketplaceRecord) -> None:
|
||||
"""Persist a marketplace record."""
|
||||
data = _load_json(_marketplaces_path(), strict=True)
|
||||
marketplaces = data.get("marketplaces")
|
||||
if marketplaces is None:
|
||||
marketplaces = {}
|
||||
elif not isinstance(marketplaces, dict):
|
||||
_raise_state_shape(_marketplaces_path(), "has invalid marketplaces data")
|
||||
marketplaces[record.name] = {
|
||||
"install_location": record.install_location,
|
||||
"source_type": record.source_type,
|
||||
"source": record.source,
|
||||
}
|
||||
if record.ref:
|
||||
marketplaces[record.name]["ref"] = record.ref
|
||||
_atomic_write_json(
|
||||
_marketplaces_path(),
|
||||
{"version": _STORAGE_VERSION, "marketplaces": marketplaces},
|
||||
)
|
||||
|
||||
|
||||
def remove_marketplace_record(name: str) -> bool:
|
||||
"""Remove a marketplace record.
|
||||
|
||||
Returns:
|
||||
`True` when a record was removed.
|
||||
"""
|
||||
data = _load_json(_marketplaces_path(), strict=True)
|
||||
marketplaces = data.get("marketplaces")
|
||||
if marketplaces is None:
|
||||
return False
|
||||
if not isinstance(marketplaces, dict):
|
||||
_raise_state_shape(_marketplaces_path(), "has invalid marketplaces data")
|
||||
if name not in marketplaces:
|
||||
return False
|
||||
marketplaces.pop(name, None)
|
||||
_atomic_write_json(
|
||||
_marketplaces_path(),
|
||||
{"version": _STORAGE_VERSION, "marketplaces": marketplaces},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def load_enabled_plugin_ids(*, strict: bool = False) -> frozenset[str]:
|
||||
"""Load enabled plugin ids.
|
||||
|
||||
Returns:
|
||||
Enabled plugin ids.
|
||||
"""
|
||||
data = _load_json(_plugin_state_path(), strict=strict)
|
||||
enabled = data.get("enabledPlugins", {})
|
||||
if not isinstance(enabled, dict):
|
||||
if strict:
|
||||
_raise_state_shape(_plugin_state_path(), "has invalid enabledPlugins data")
|
||||
return frozenset()
|
||||
if strict and any(
|
||||
not isinstance(key, str) or not isinstance(value, bool)
|
||||
for key, value in enabled.items()
|
||||
):
|
||||
_raise_state_shape(_plugin_state_path(), "has malformed enabledPlugins entries")
|
||||
return frozenset(
|
||||
key for key, value in enabled.items() if isinstance(key, str) and value is True
|
||||
)
|
||||
|
||||
|
||||
def _write_plugin_state(*, enabled_plugin_ids: set[str]) -> None:
|
||||
_atomic_write_json(
|
||||
_plugin_state_path(),
|
||||
{
|
||||
"version": _STORAGE_VERSION,
|
||||
"enabledPlugins": dict.fromkeys(sorted(enabled_plugin_ids), True),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def set_plugin_enabled(plugin_id: str, enabled: bool) -> None:
|
||||
"""Persist a plugin enablement value."""
|
||||
enabled_plugin_ids = set(load_enabled_plugin_ids(strict=True))
|
||||
if enabled:
|
||||
enabled_plugin_ids.add(plugin_id)
|
||||
else:
|
||||
enabled_plugin_ids.discard(plugin_id)
|
||||
_write_plugin_state(enabled_plugin_ids=enabled_plugin_ids)
|
||||
|
||||
|
||||
def _parse_installed_plugin_json_entry(
|
||||
persisted_entry: object,
|
||||
) -> InstalledPluginEntry | None:
|
||||
if not isinstance(persisted_entry, dict):
|
||||
return None
|
||||
install_path = persisted_entry.get("installPath") or persisted_entry.get(
|
||||
"install_path"
|
||||
)
|
||||
version = persisted_entry.get("version")
|
||||
if (
|
||||
not isinstance(install_path, str)
|
||||
or not install_path
|
||||
or (version is not None and (not isinstance(version, str) or not version))
|
||||
):
|
||||
return None
|
||||
return InstalledPluginEntry(
|
||||
install_path=install_path,
|
||||
version=version if isinstance(version, str) else None,
|
||||
)
|
||||
|
||||
|
||||
def load_installed_plugins(*, strict: bool = False) -> dict[str, InstalledPluginEntry]:
|
||||
"""Load installed plugin records.
|
||||
|
||||
Returns:
|
||||
Map of plugin id to its install entry.
|
||||
"""
|
||||
data = _load_json(
|
||||
_installed_plugins_path(),
|
||||
max_version=_INSTALLED_STORAGE_VERSION,
|
||||
strict=strict,
|
||||
)
|
||||
raw_plugins = data.get("plugins", {})
|
||||
if not isinstance(raw_plugins, dict):
|
||||
if strict:
|
||||
_raise_state_shape(_installed_plugins_path(), "has invalid plugins data")
|
||||
return {}
|
||||
result: dict[str, InstalledPluginEntry] = {}
|
||||
for plugin_id, entries in raw_plugins.items():
|
||||
if not isinstance(plugin_id, str) or not isinstance(entries, list):
|
||||
if strict:
|
||||
_raise_state_shape(
|
||||
_installed_plugins_path(), "has malformed plugin entries"
|
||||
)
|
||||
continue
|
||||
parsed = next(
|
||||
(
|
||||
entry
|
||||
for item in entries
|
||||
if (entry := _parse_installed_plugin_json_entry(item))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if parsed is not None:
|
||||
result[plugin_id] = parsed
|
||||
elif strict:
|
||||
_raise_state_shape(
|
||||
_installed_plugins_path(), f"has malformed entry for {plugin_id!r}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _entry_to_json(entry: InstalledPluginEntry) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"installPath": entry.install_path,
|
||||
}
|
||||
if entry.version is not None:
|
||||
payload["version"] = entry.version
|
||||
return payload
|
||||
|
||||
|
||||
def _write_installed_plugins(
|
||||
plugins: dict[str, InstalledPluginEntry],
|
||||
) -> None:
|
||||
_atomic_write_json(
|
||||
_installed_plugins_path(),
|
||||
{
|
||||
"version": _INSTALLED_STORAGE_VERSION,
|
||||
"plugins": {
|
||||
plugin_id: [_entry_to_json(entry)]
|
||||
for plugin_id, entry in sorted(plugins.items())
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def add_installed_plugin(
|
||||
plugin_id: str,
|
||||
*,
|
||||
install_path: str,
|
||||
version: str | None,
|
||||
) -> InstalledPluginEntry:
|
||||
"""Add or replace the record for `plugin_id` in `installed_plugins.json`.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
||||
install_path: Absolute path to the cached plugin root.
|
||||
version: Version declared by the plugin manifest, if any.
|
||||
|
||||
Returns:
|
||||
The written install entry.
|
||||
|
||||
"""
|
||||
plugins = dict(load_installed_plugins(strict=True))
|
||||
entry = InstalledPluginEntry(
|
||||
install_path=install_path,
|
||||
version=version,
|
||||
)
|
||||
plugins[plugin_id] = entry
|
||||
_write_installed_plugins(plugins)
|
||||
return entry
|
||||
|
||||
|
||||
def get_primary_install_entry(plugin_id: str) -> InstalledPluginEntry | None:
|
||||
"""Return the install entry for a plugin id."""
|
||||
return load_installed_plugins().get(plugin_id)
|
||||
|
||||
|
||||
def remove_installed_plugin(
|
||||
plugin_id: str,
|
||||
) -> InstalledPluginEntry | None:
|
||||
"""Remove the install record for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin id.
|
||||
|
||||
Returns:
|
||||
Removed install entry, if present.
|
||||
"""
|
||||
plugins = dict(load_installed_plugins(strict=True))
|
||||
removed = plugins.pop(plugin_id, None)
|
||||
_write_installed_plugins(plugins)
|
||||
return removed
|
||||
|
||||
|
||||
def cache_and_register_plugin(
|
||||
plugin_id: str,
|
||||
source_dir: Path,
|
||||
*,
|
||||
version: str | None,
|
||||
validate: Callable[[Path], None] | None = None,
|
||||
) -> Path:
|
||||
"""Copy a plugin into the versioned cache and register the install.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
||||
source_dir: Source plugin root to copy from.
|
||||
version: Version declared by the plugin manifest, if any.
|
||||
validate: Optional validation to run against the temporary copy before
|
||||
replacing an existing cache.
|
||||
|
||||
Returns:
|
||||
Absolute path to the cached plugin root.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If `source_dir` is not an existing directory.
|
||||
OSError: If the cache cannot be copied or atomically replaced.
|
||||
"""
|
||||
source = source_dir.resolve()
|
||||
if not source.is_dir():
|
||||
msg = f"Plugin source directory not found: {source}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
cache_path = versioned_cache_path(plugin_id, version)
|
||||
if cache_path.exists() and version is not None:
|
||||
try:
|
||||
if any(cache_path.iterdir()):
|
||||
add_installed_plugin(
|
||||
plugin_id,
|
||||
install_path=str(cache_path),
|
||||
version=version,
|
||||
)
|
||||
return cache_path
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir = cache_path.parent / f".{cache_path.name}.tmp-{os.getpid()}"
|
||||
backup_dir = cache_path.parent / f".{cache_path.name}.backup-{os.getpid()}"
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
try:
|
||||
shutil.copytree(source, temp_dir, symlinks=True, dirs_exist_ok=False)
|
||||
git_dir = temp_dir / ".git"
|
||||
if git_dir.exists():
|
||||
shutil.rmtree(git_dir, ignore_errors=True)
|
||||
if validate is not None:
|
||||
validate(temp_dir)
|
||||
if cache_path.exists():
|
||||
cache_path.replace(backup_dir)
|
||||
try:
|
||||
temp_dir.replace(cache_path)
|
||||
except OSError:
|
||||
if backup_dir.exists() and not cache_path.exists():
|
||||
backup_dir.replace(cache_path)
|
||||
raise
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
add_installed_plugin(
|
||||
plugin_id,
|
||||
install_path=str(cache_path.resolve()),
|
||||
version=version,
|
||||
)
|
||||
return cache_path.resolve()
|
||||
|
||||
|
||||
def uninstall_plugin(
|
||||
plugin_id: str,
|
||||
) -> None:
|
||||
"""Disable a plugin, remove install records, and delete orphaned cache dirs.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin id in `{name}@{marketplace}` form.
|
||||
"""
|
||||
load_installed_plugins(strict=True)
|
||||
load_enabled_plugin_ids(strict=True)
|
||||
removed = remove_installed_plugin(plugin_id)
|
||||
|
||||
set_plugin_enabled(plugin_id, False)
|
||||
|
||||
if removed is not None:
|
||||
path = Path(removed.install_path)
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Variable substitution for plugin-provided configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from deepagents_code.plugins.models import JsonValue
|
||||
|
||||
|
||||
def plugin_environment(
|
||||
*, plugin_root: Path, plugin_data: Path, project_dir: Path | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Build environment variables exposed to plugin subprocesses.
|
||||
|
||||
Args:
|
||||
plugin_root: Plugin root directory.
|
||||
plugin_data: Plugin data directory.
|
||||
project_dir: Optional project directory.
|
||||
|
||||
Returns:
|
||||
Environment variables for plugin subprocesses.
|
||||
"""
|
||||
root = str(plugin_root)
|
||||
data = str(plugin_data)
|
||||
env = {
|
||||
"CLAUDE_PLUGIN_ROOT": root,
|
||||
"CLAUDE_PLUGIN_DATA": data,
|
||||
"PLUGIN_ROOT": root,
|
||||
"PLUGIN_DATA": data,
|
||||
}
|
||||
if project_dir is not None:
|
||||
env["CLAUDE_PROJECT_DIR"] = str(project_dir)
|
||||
return env
|
||||
|
||||
|
||||
def substitute_string(
|
||||
value: str, *, plugin_root: Path, plugin_data: Path, project_dir: Path | None = None
|
||||
) -> str:
|
||||
"""Substitute plugin path variables in a string.
|
||||
|
||||
Args:
|
||||
value: String to transform.
|
||||
plugin_root: Plugin root directory.
|
||||
plugin_data: Plugin data directory.
|
||||
project_dir: Optional project directory.
|
||||
|
||||
Returns:
|
||||
String with supported plugin variables substituted.
|
||||
"""
|
||||
env = plugin_environment(
|
||||
plugin_root=plugin_root, plugin_data=plugin_data, project_dir=project_dir
|
||||
)
|
||||
result = value
|
||||
for key, replacement in env.items():
|
||||
result = result.replace(f"${{{key}}}", replacement)
|
||||
return result
|
||||
|
||||
|
||||
def substitute_json(
|
||||
value: JsonValue,
|
||||
*,
|
||||
plugin_root: Path,
|
||||
plugin_data: Path,
|
||||
project_dir: Path | None = None,
|
||||
) -> JsonValue:
|
||||
"""Substitute plugin variables throughout a JSON-compatible value.
|
||||
|
||||
Args:
|
||||
value: JSON-compatible value to transform.
|
||||
plugin_root: Plugin root directory.
|
||||
plugin_data: Plugin data directory.
|
||||
project_dir: Optional project directory.
|
||||
|
||||
Returns:
|
||||
Value with strings recursively substituted.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return substitute_string(
|
||||
value,
|
||||
plugin_root=plugin_root,
|
||||
plugin_data=plugin_data,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
substitute_json(
|
||||
item,
|
||||
plugin_root=plugin_root,
|
||||
plugin_data=plugin_data,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: substitute_json(
|
||||
item,
|
||||
plugin_root=plugin_root,
|
||||
plugin_data=plugin_data,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
@@ -105,13 +105,26 @@ async def _build_tools(
|
||||
mcp_server_info: list[Any] | None = None
|
||||
if not config.no_mcp:
|
||||
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
|
||||
from deepagents_code.plugins.adapters.mcp import discover_plugin_mcp_configs
|
||||
|
||||
project_dir = (
|
||||
project_context.project_root or project_context.user_cwd
|
||||
if project_context is not None
|
||||
else None
|
||||
)
|
||||
# Offload plugin discovery: it does blocking disk IO (`os.mkdir` for
|
||||
# per-plugin data dirs, plus state/manifest reads) that `blockbuster`
|
||||
# rejects on the server event loop.
|
||||
plugin_mcp_configs = await asyncio.to_thread(
|
||||
discover_plugin_mcp_configs, project_dir=project_dir
|
||||
)
|
||||
try:
|
||||
mcp_tools, _, mcp_server_info = await resolve_and_load_mcp_tools(
|
||||
explicit_config_path=config.mcp_config_path,
|
||||
no_mcp=config.no_mcp,
|
||||
trust_project_mcp=config.trust_project_mcp,
|
||||
project_context=project_context,
|
||||
additional_configs=plugin_mcp_configs,
|
||||
stateless=True,
|
||||
session_manager=_get_mcp_session_manager(),
|
||||
)
|
||||
|
||||
@@ -27,11 +27,17 @@ class SkillInvocationEnvelope:
|
||||
|
||||
def discover_skills_and_roots(
|
||||
assistant_id: str,
|
||||
*,
|
||||
plugin_skill_sources: tuple[tuple[Path, str], ...] = (),
|
||||
plugin_skill_roots: tuple[Path, ...] = (),
|
||||
) -> tuple[list[ExtendedSkillMetadata], list[Path]]:
|
||||
"""Discover skills and build pre-resolved containment roots.
|
||||
|
||||
Args:
|
||||
assistant_id: Agent identifier used to resolve user skill directories.
|
||||
plugin_skill_sources: Plugin-owned skill directories and namespaces,
|
||||
supplied by the plugin composition layer.
|
||||
plugin_skill_roots: Plugin-owned roots allowed for content loading.
|
||||
|
||||
Returns:
|
||||
Tuple of `(skill metadata list, pre-resolved containment roots)`.
|
||||
@@ -42,6 +48,7 @@ def discover_skills_and_roots(
|
||||
|
||||
skills = list_skills(
|
||||
built_in_skills_dir=settings.get_built_in_skills_dir(),
|
||||
plugin_skill_sources=plugin_skill_sources,
|
||||
user_skills_dir=settings.get_user_skills_dir(assistant_id),
|
||||
project_skills_dir=settings.get_project_skills_dir(),
|
||||
user_agent_skills_dir=settings.get_user_agent_skills_dir(),
|
||||
@@ -53,6 +60,7 @@ def discover_skills_and_roots(
|
||||
path.resolve()
|
||||
for path in (
|
||||
settings.get_built_in_skills_dir(),
|
||||
*plugin_skill_roots,
|
||||
settings.get_user_skills_dir(assistant_id),
|
||||
settings.get_project_skills_dir(),
|
||||
settings.get_user_agent_skills_dir(),
|
||||
|
||||
@@ -37,7 +37,7 @@ class ExtendedSkillMetadata(SkillMetadata):
|
||||
or `'claude (experimental)'`.
|
||||
"""
|
||||
|
||||
source: Literal["built-in", "user", "project", "claude (experimental)"]
|
||||
source: Literal["built-in", "plugin", "user", "project", "claude (experimental)"]
|
||||
|
||||
|
||||
# Re-export for CLI commands
|
||||
@@ -47,6 +47,7 @@ __all__ = ["SkillMetadata", "list_skills", "load_skill_content"]
|
||||
def list_skills(
|
||||
*,
|
||||
built_in_skills_dir: Path | None = None,
|
||||
plugin_skill_sources: Sequence[tuple[Path, str]] = (),
|
||||
user_skills_dir: Path | None = None,
|
||||
project_skills_dir: Path | None = None,
|
||||
user_agent_skills_dir: Path | None = None,
|
||||
@@ -61,17 +62,19 @@ def list_skills(
|
||||
|
||||
Precedence order (lowest to highest):
|
||||
0. `built_in_skills_dir` (`<package>/built_in_skills/`)
|
||||
1. `user_skills_dir` (`~/.deepagents/{agent}/skills/`)
|
||||
2. `user_agent_skills_dir` (`~/.agents/skills/`)
|
||||
3. `project_skills_dir` (`.deepagents/skills/`)
|
||||
4. `project_agent_skills_dir` (`.agents/skills/`)
|
||||
5. `user_claude_skills_dir` (`~/.claude/skills/`, experimental)
|
||||
6. `project_claude_skills_dir` (`.claude/skills/`, experimental)
|
||||
1. `plugin_skill_sources`
|
||||
2. `user_skills_dir` (`~/.deepagents/{agent}/skills/`)
|
||||
3. `user_agent_skills_dir` (`~/.agents/skills/`)
|
||||
4. `project_skills_dir` (`.deepagents/skills/`)
|
||||
5. `project_agent_skills_dir` (`.agents/skills/`)
|
||||
6. `user_claude_skills_dir` (`~/.claude/skills/`, experimental)
|
||||
7. `project_claude_skills_dir` (`.claude/skills/`, experimental)
|
||||
|
||||
Skills from higher-precedence directories override those with the same name.
|
||||
|
||||
Args:
|
||||
built_in_skills_dir: Path to built-in skills shipped with the package.
|
||||
plugin_skill_sources: Plugin skill source directories with namespaces.
|
||||
user_skills_dir: Path to `~/.deepagents/{agent}/skills/`.
|
||||
project_skills_dir: Path to `.deepagents/skills/`.
|
||||
user_agent_skills_dir: Path to `~/.agents/skills/` (alias).
|
||||
@@ -85,29 +88,45 @@ def list_skills(
|
||||
"""
|
||||
all_skills: dict[str, ExtendedSkillMetadata] = {}
|
||||
|
||||
sources: list[tuple[Path | None, str, bool]] = [
|
||||
(built_in_skills_dir, "built-in", False),
|
||||
(user_skills_dir, "user", False),
|
||||
(user_agent_skills_dir, "user", False),
|
||||
(project_skills_dir, "project", False),
|
||||
(project_agent_skills_dir, "project", False),
|
||||
(user_claude_skills_dir, "claude (experimental)", True),
|
||||
(project_claude_skills_dir, "claude (experimental)", True),
|
||||
sources: list[tuple[Path | None, str, bool, str]] = [
|
||||
(built_in_skills_dir, "built-in", False, ""),
|
||||
*[
|
||||
(path, "plugin", False, namespace)
|
||||
for path, namespace in plugin_skill_sources
|
||||
],
|
||||
(user_skills_dir, "user", False, ""),
|
||||
(user_agent_skills_dir, "user", False, ""),
|
||||
(project_skills_dir, "project", False, ""),
|
||||
(project_agent_skills_dir, "project", False, ""),
|
||||
(user_claude_skills_dir, "claude (experimental)", True, ""),
|
||||
(project_claude_skills_dir, "claude (experimental)", True, ""),
|
||||
]
|
||||
"""Sources in precedence order (lowest to highest).
|
||||
|
||||
Each tuple: `(directory, source label, is_experimental)`.
|
||||
Each tuple: `(directory, source label, is_experimental, namespace)`.
|
||||
|
||||
Each source is individually try/except-guarded so a single inaccessible
|
||||
directory doesn't block the rest.
|
||||
"""
|
||||
|
||||
for skill_dir, source_label, experimental in sources:
|
||||
for skill_dir, source_label, experimental, namespace in sources:
|
||||
if not skill_dir or not skill_dir.exists():
|
||||
continue
|
||||
try:
|
||||
backend = FilesystemBackend(root_dir=str(skill_dir), virtual_mode=False)
|
||||
skills = list_skills_from_backend(backend=backend, source_path=".")
|
||||
if namespace:
|
||||
# Plugin sources are walked recursively so nested skill
|
||||
# directories are namespaced as `plugin:sub:skill`, matching
|
||||
# both the runtime middleware and plugin conventions.
|
||||
from deepagents_code.plugins.adapters.skills_middleware import (
|
||||
load_namespaced_skills,
|
||||
)
|
||||
|
||||
skills = load_namespaced_skills(
|
||||
backend, str(skill_dir.resolve()), namespace
|
||||
)
|
||||
else:
|
||||
skills = list_skills_from_backend(backend=backend, source_path=".")
|
||||
if experimental and skills:
|
||||
logger.info(
|
||||
"Discovered %d skill(s) from experimental Claude path: %s",
|
||||
|
||||
@@ -428,6 +428,7 @@ async def _load_mcp_server_info(
|
||||
Discovered MCP server metadata, or an empty list when none load.
|
||||
"""
|
||||
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
|
||||
from deepagents_code.plugins.adapters.mcp import discover_plugin_mcp_configs
|
||||
from deepagents_code.project_utils import ProjectContext
|
||||
|
||||
try:
|
||||
@@ -438,6 +439,11 @@ async def _load_mcp_server_info(
|
||||
# convention in `project_utils` and fall back to no project context.
|
||||
logger.warning("Could not determine working directory for MCP discovery")
|
||||
project_context = None
|
||||
project_dir = (
|
||||
project_context.project_root or project_context.user_cwd
|
||||
if project_context is not None
|
||||
else None
|
||||
)
|
||||
|
||||
session_manager = None
|
||||
try:
|
||||
@@ -446,6 +452,7 @@ async def _load_mcp_server_info(
|
||||
no_mcp=False,
|
||||
trust_project_mcp=trust_project_mcp,
|
||||
project_context=project_context,
|
||||
additional_configs=discover_plugin_mcp_configs(project_dir=project_dir),
|
||||
)
|
||||
return server_info or []
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Modal UI components."""
|
||||
@@ -0,0 +1,649 @@
|
||||
"""Interactive plugin manager screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Vertical
|
||||
from textual.content import Content
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, OptionList, Rule, Static
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from textual.app import ComposeResult
|
||||
|
||||
from deepagents_code.mcp_tools import MCPServerInfo
|
||||
from deepagents_code.tui.modals.plugin_manager.models import (
|
||||
PluginManagerView,
|
||||
PluginTab,
|
||||
_MarketplaceRow,
|
||||
_PluginRow,
|
||||
)
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_code.plugins import (
|
||||
add_marketplace_source,
|
||||
install_plugin,
|
||||
remove_marketplace,
|
||||
set_installed_plugin_enabled,
|
||||
uninstall_plugin,
|
||||
)
|
||||
from deepagents_code.plugins.marketplace import MarketplaceError
|
||||
from deepagents_code.tui.modals.plugin_manager.content import (
|
||||
_confirm_marketplace_removal_options,
|
||||
_install_details_options,
|
||||
_installed_details_options,
|
||||
_installed_plugin_details_content,
|
||||
_marketplace_details_content,
|
||||
_marketplace_details_options,
|
||||
_marketplace_label,
|
||||
_marketplace_removal_content,
|
||||
_plugin_details_content,
|
||||
_plugin_options,
|
||||
)
|
||||
from deepagents_code.tui.modals.plugin_manager.models import _ManagerState
|
||||
from deepagents_code.tui.modals.plugin_manager.state import _load_manager_state
|
||||
|
||||
|
||||
class PluginManagerScreen(ModalScreen[None]): # noqa: RUF067
|
||||
"""Arrow-key navigable plugin manager for `/plugins`."""
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "cancel", "Close", show=False, priority=True),
|
||||
Binding("left", "previous_tab", "Previous tab", show=False, priority=True),
|
||||
Binding("right", "next_tab", "Next tab", show=False, priority=True),
|
||||
Binding("tab", "next_tab", "Next tab", show=False, priority=True),
|
||||
Binding("shift+tab", "previous_tab", "Previous tab", show=False, priority=True),
|
||||
Binding("up", "cursor_up", "Up", show=False, priority=True),
|
||||
Binding("down", "cursor_down", "Down", show=False, priority=True),
|
||||
]
|
||||
|
||||
CSS_PATH = "plugin_manager.tcss"
|
||||
|
||||
_tabs: ClassVar[tuple[PluginTab, ...]] = (
|
||||
"discover",
|
||||
"installed",
|
||||
"marketplaces",
|
||||
"errors",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
mcp_server_info: Sequence[MCPServerInfo] = (),
|
||||
) -> None:
|
||||
"""Initialize the plugin manager.
|
||||
|
||||
Args:
|
||||
mcp_server_info: Live MCP server metadata from the running session,
|
||||
used to show connection status for plugins that declare MCP
|
||||
servers.
|
||||
"""
|
||||
super().__init__()
|
||||
self._tab: PluginTab = "discover"
|
||||
self._mode: PluginManagerView = "list"
|
||||
self._mcp_server_info = mcp_server_info
|
||||
self._state = _ManagerState((), (), (), ())
|
||||
self._status: str | None = None
|
||||
self._error: str | None = None
|
||||
self._selected_plugin: _PluginRow | None = None
|
||||
self._selected_marketplace: _MarketplaceRow | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the manager screen.
|
||||
|
||||
Yields:
|
||||
Widgets for the plugin manager UI.
|
||||
"""
|
||||
with Vertical():
|
||||
yield Static(
|
||||
"Plugins", id="plugin-manager-title", classes="plugin-manager-title"
|
||||
)
|
||||
yield Static(self._tabs_text(), id="plugin-manager-tabs")
|
||||
yield Rule(
|
||||
line_style="heavy" if not is_ascii_mode() else "ascii",
|
||||
id="plugin-manager-divider",
|
||||
classes="plugin-manager-divider",
|
||||
)
|
||||
yield Static(
|
||||
"",
|
||||
id="plugin-manager-status",
|
||||
classes="plugin-manager-status",
|
||||
markup=False,
|
||||
)
|
||||
yield Static(
|
||||
"",
|
||||
id="plugin-manager-error",
|
||||
classes="plugin-manager-error",
|
||||
markup=False,
|
||||
)
|
||||
yield OptionList(id="plugin-manager-options")
|
||||
yield Input(
|
||||
placeholder="",
|
||||
id="plugin-marketplace-source",
|
||||
)
|
||||
yield Static("", id="plugin-manager-help", classes="plugin-manager-help")
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Apply initial render, then load plugin state off the UI thread."""
|
||||
if is_ascii_mode():
|
||||
container = self.query_one(Vertical)
|
||||
colors = theme.get_theme_colors(self)
|
||||
container.styles.border = ("ascii", colors.success)
|
||||
self._status = "Loading plugins..."
|
||||
self._refresh_view()
|
||||
await self._refresh_state()
|
||||
if self._status == "Loading plugins...":
|
||||
self._status = None
|
||||
self._refresh_view()
|
||||
|
||||
def _tabs_text(self) -> str:
|
||||
labels = {
|
||||
"discover": "Plugins",
|
||||
"installed": "Installed",
|
||||
"marketplaces": "Marketplaces",
|
||||
"errors": "Errors",
|
||||
}
|
||||
parts = []
|
||||
for tab in self._tabs:
|
||||
label = labels[tab]
|
||||
parts.append(f"> {label} <" if tab == self._tab else f" {label} ")
|
||||
return " ".join(parts)
|
||||
|
||||
def _current_options(self) -> list[Option]:
|
||||
glyphs = get_glyphs()
|
||||
if self._tab == "discover":
|
||||
if not self._state.marketplaces:
|
||||
return [
|
||||
Option(
|
||||
"No plugins available. Add a marketplace first.",
|
||||
id="empty",
|
||||
)
|
||||
]
|
||||
if not self._state.available_plugins:
|
||||
return [Option("All available plugins are installed.", id="empty")]
|
||||
return _plugin_options(
|
||||
self._state.available_plugins,
|
||||
action="detail",
|
||||
status=None,
|
||||
)
|
||||
if self._tab == "installed":
|
||||
if not self._state.installed_plugins:
|
||||
return [Option("No plugins installed.", id="empty")]
|
||||
return _plugin_options(
|
||||
self._state.installed_plugins,
|
||||
action="installed",
|
||||
status=None,
|
||||
)
|
||||
if self._tab == "marketplaces":
|
||||
options = [Option("+ Add Marketplace", id="add-marketplace")]
|
||||
options.extend(
|
||||
Option(
|
||||
Content(_marketplace_label(row, glyphs.bullet)),
|
||||
id=f"marketplace:{row.name}",
|
||||
)
|
||||
for row in self._state.marketplaces
|
||||
)
|
||||
return options
|
||||
if not self._state.errors:
|
||||
return [Option("No plugin errors.", id="empty")]
|
||||
return [Option(Content(error), id="empty") for error in self._state.errors]
|
||||
|
||||
@staticmethod
|
||||
def _nearest_enabled_index(options: OptionList, candidate: int) -> int | None:
|
||||
"""Return the nearest selectable option index to `candidate`.
|
||||
|
||||
Scope-group header rows (and spacers) are disabled options, so a
|
||||
highlighted index carried over from a previous tab/refresh can land
|
||||
on one after the option count or grouping changes. Scans forward
|
||||
first, then backward, so the cursor always rests on a real row.
|
||||
|
||||
Args:
|
||||
options: Option list to scan.
|
||||
candidate: Preferred index (already clamped to bounds).
|
||||
|
||||
Returns:
|
||||
`candidate` if selectable, the nearest selectable index otherwise,
|
||||
or `None` if every option (or the list itself) is disabled/empty.
|
||||
"""
|
||||
if not options.option_count:
|
||||
return None
|
||||
if not options.get_option_at_index(candidate).disabled:
|
||||
return candidate
|
||||
for index in range(candidate + 1, options.option_count):
|
||||
if not options.get_option_at_index(index).disabled:
|
||||
return index
|
||||
for index in range(candidate - 1, -1, -1):
|
||||
if not options.get_option_at_index(index).disabled:
|
||||
return index
|
||||
return None
|
||||
|
||||
def _details_mode_active(self) -> bool:
|
||||
return self._mode in {
|
||||
"plugin_details",
|
||||
"installed_details",
|
||||
"marketplace_details",
|
||||
"confirm_remove_marketplace",
|
||||
}
|
||||
|
||||
def _refresh_view(self) -> None:
|
||||
title = self.query_one("#plugin-manager-title", Static)
|
||||
tabs = self.query_one("#plugin-manager-tabs", Static)
|
||||
divider = self.query_one("#plugin-manager-divider", Rule)
|
||||
tabs.update(self._tabs_text())
|
||||
status_widget = self.query_one("#plugin-manager-status", Static)
|
||||
if self._mode == "plugin_details" and self._selected_plugin is not None:
|
||||
status_widget.update(_plugin_details_content(self._selected_plugin))
|
||||
elif self._mode == "installed_details" and self._selected_plugin is not None:
|
||||
status_widget.update(
|
||||
_installed_plugin_details_content(self._selected_plugin)
|
||||
)
|
||||
elif (
|
||||
self._mode == "marketplace_details"
|
||||
and self._selected_marketplace is not None
|
||||
):
|
||||
status_widget.update(
|
||||
_marketplace_details_content(self._selected_marketplace)
|
||||
)
|
||||
elif (
|
||||
self._mode == "confirm_remove_marketplace"
|
||||
and self._selected_marketplace is not None
|
||||
):
|
||||
status_widget.update(
|
||||
_marketplace_removal_content(self._selected_marketplace)
|
||||
)
|
||||
else:
|
||||
status_widget.update(self._status or "")
|
||||
error = self._error or ""
|
||||
self.query_one("#plugin-manager-error", Static).update(error)
|
||||
|
||||
options = self.query_one("#plugin-manager-options", OptionList)
|
||||
source_input = self.query_one("#plugin-marketplace-source", Input)
|
||||
help_text = self.query_one("#plugin-manager-help", Static)
|
||||
glyphs = get_glyphs()
|
||||
|
||||
if self._mode == "add_marketplace":
|
||||
title.update("Add Marketplace")
|
||||
tabs.display = False
|
||||
divider.display = False
|
||||
if self._status is None:
|
||||
status_widget.update(
|
||||
"Enter marketplace source:\n"
|
||||
"\n"
|
||||
"Examples:\n"
|
||||
f" {glyphs.bullet} owner/repo (GitHub)\n"
|
||||
f" {glyphs.bullet} git@github.com:owner/repo.git (SSH)\n"
|
||||
f" {glyphs.bullet} https://example.com/marketplace.json\n"
|
||||
f" {glyphs.bullet} ./path/to/marketplace"
|
||||
)
|
||||
options.display = False
|
||||
source_input.display = True
|
||||
source_input.focus()
|
||||
help_text.update(f"Enter to add {glyphs.bullet} Esc to cancel")
|
||||
return
|
||||
|
||||
title.update("Plugins")
|
||||
tabs.display = True
|
||||
divider.display = True
|
||||
|
||||
if self._details_mode_active():
|
||||
source_input.display = False
|
||||
options.display = True
|
||||
options.clear_options()
|
||||
detail_options = self._active_details_options()
|
||||
for option in detail_options:
|
||||
options.add_option(option)
|
||||
if options.option_count:
|
||||
options.highlighted = self._nearest_enabled_index(options, 0)
|
||||
options.focus()
|
||||
help_text.update(
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} "
|
||||
f"Enter choose {glyphs.bullet} Esc back"
|
||||
)
|
||||
return
|
||||
|
||||
source_input.display = False
|
||||
options.display = True
|
||||
highlighted = options.highlighted
|
||||
options.clear_options()
|
||||
for option in self._current_options():
|
||||
options.add_option(option)
|
||||
if options.option_count:
|
||||
candidate = (
|
||||
0 if highlighted is None else min(highlighted, options.option_count - 1)
|
||||
)
|
||||
options.highlighted = self._nearest_enabled_index(options, candidate)
|
||||
options.focus()
|
||||
|
||||
if self._tab == "marketplaces":
|
||||
help_text.update(
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} "
|
||||
f"Enter add/view {glyphs.bullet} "
|
||||
f"Left/Right tabs {glyphs.bullet} Esc close"
|
||||
)
|
||||
elif self._tab in {"discover", "installed"}:
|
||||
action = "view" if self._tab == "installed" else "install"
|
||||
help_text.update(
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} "
|
||||
f"Enter {action} {glyphs.bullet} Left/Right tabs "
|
||||
f"{glyphs.bullet} Esc close"
|
||||
)
|
||||
else:
|
||||
help_text.update(f"Left/Right tabs {glyphs.bullet} Esc close")
|
||||
|
||||
def _active_details_options(self) -> list[Option]:
|
||||
if self._mode == "plugin_details":
|
||||
return _install_details_options()
|
||||
if self._mode == "installed_details" and self._selected_plugin is not None:
|
||||
return _installed_details_options(self._selected_plugin)
|
||||
if (
|
||||
self._mode == "marketplace_details"
|
||||
and self._selected_marketplace is not None
|
||||
):
|
||||
return _marketplace_details_options()
|
||||
if (
|
||||
self._mode == "confirm_remove_marketplace"
|
||||
and self._selected_marketplace is not None
|
||||
):
|
||||
return _confirm_marketplace_removal_options(self._selected_marketplace)
|
||||
return [Option("Back to plugin list", id="details-back")]
|
||||
|
||||
async def _refresh_state(self) -> None:
|
||||
self._state = await asyncio.to_thread(
|
||||
_load_manager_state, self._mcp_server_info
|
||||
)
|
||||
if self._selected_plugin is not None:
|
||||
refreshed = self._find_installed_plugin(self._selected_plugin.plugin_id)
|
||||
if refreshed is None:
|
||||
refreshed = self._find_available_plugin(self._selected_plugin.plugin_id)
|
||||
self._selected_plugin = refreshed
|
||||
if self._selected_marketplace is not None:
|
||||
self._selected_marketplace = self._find_marketplace(
|
||||
self._selected_marketplace.name
|
||||
)
|
||||
self._refresh_view()
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Close or leave the add-marketplace / details prompt."""
|
||||
if self._mode == "add_marketplace":
|
||||
self._mode = "list"
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
return
|
||||
if self._details_mode_active():
|
||||
if self._mode == "confirm_remove_marketplace":
|
||||
self._mode = "marketplace_details"
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
return
|
||||
self._mode = "list"
|
||||
self._selected_plugin = None
|
||||
self._selected_marketplace = None
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
return
|
||||
self.dismiss(None)
|
||||
|
||||
def action_next_tab(self) -> None:
|
||||
"""Switch to the next tab."""
|
||||
if self._mode != "list":
|
||||
return
|
||||
index = self._tabs.index(self._tab)
|
||||
self._tab = self._tabs[(index + 1) % len(self._tabs)]
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
|
||||
def action_previous_tab(self) -> None:
|
||||
"""Switch to the previous tab."""
|
||||
if self._mode != "list":
|
||||
return
|
||||
index = self._tabs.index(self._tab)
|
||||
self._tab = self._tabs[(index - 1) % len(self._tabs)]
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
|
||||
def action_cursor_down(self) -> None:
|
||||
"""Move the option-list cursor down."""
|
||||
if self._mode in {
|
||||
"list",
|
||||
"plugin_details",
|
||||
"installed_details",
|
||||
"marketplace_details",
|
||||
"confirm_remove_marketplace",
|
||||
}:
|
||||
self.query_one("#plugin-manager-options", OptionList).action_cursor_down()
|
||||
|
||||
def action_cursor_up(self) -> None:
|
||||
"""Move the option-list cursor up."""
|
||||
if self._mode in {
|
||||
"list",
|
||||
"plugin_details",
|
||||
"installed_details",
|
||||
"marketplace_details",
|
||||
"confirm_remove_marketplace",
|
||||
}:
|
||||
self.query_one("#plugin-manager-options", OptionList).action_cursor_up()
|
||||
|
||||
async def on_option_list_option_selected(
|
||||
self, event: OptionList.OptionSelected
|
||||
) -> None:
|
||||
"""Handle row activation."""
|
||||
option_id = event.option.id
|
||||
if option_id is None or option_id == "empty":
|
||||
return
|
||||
if option_id == "add-marketplace":
|
||||
self._mode = "add_marketplace"
|
||||
self._status = None
|
||||
self._error = None
|
||||
self.query_one("#plugin-marketplace-source", Input).value = ""
|
||||
self._refresh_view()
|
||||
return
|
||||
if option_id.startswith("marketplace:"):
|
||||
name = option_id.removeprefix("marketplace:")
|
||||
row = self._find_marketplace(name)
|
||||
if row is None:
|
||||
return
|
||||
self._selected_marketplace = row
|
||||
self._selected_plugin = None
|
||||
self._mode = "marketplace_details"
|
||||
self._status = None
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
return
|
||||
if option_id.startswith("detail:"):
|
||||
plugin_id = option_id.removeprefix("detail:")
|
||||
row = self._find_available_plugin(plugin_id)
|
||||
if row is None:
|
||||
return
|
||||
self._selected_plugin = row
|
||||
self._mode = "plugin_details"
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
return
|
||||
if option_id.startswith("installed:"):
|
||||
plugin_id = option_id.removeprefix("installed:")
|
||||
row = self._find_installed_plugin(plugin_id)
|
||||
if row is None:
|
||||
return
|
||||
self._selected_plugin = row
|
||||
self._mode = "installed_details"
|
||||
self._error = None
|
||||
self._status = None
|
||||
self._refresh_view()
|
||||
return
|
||||
if option_id == "action:install":
|
||||
await self._install_selected_plugin()
|
||||
return
|
||||
if option_id == "action:toggle-enabled":
|
||||
await self._toggle_selected_plugin_enabled()
|
||||
return
|
||||
if option_id == "action:uninstall":
|
||||
await self._uninstall_selected_plugin()
|
||||
return
|
||||
if option_id == "action:remove-marketplace":
|
||||
self._mode = "confirm_remove_marketplace"
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
return
|
||||
if option_id == "action:confirm-remove-marketplace":
|
||||
await self._remove_selected_marketplace()
|
||||
return
|
||||
if option_id == "details-back":
|
||||
self._mode = (
|
||||
"marketplace_details"
|
||||
if self._mode == "confirm_remove_marketplace"
|
||||
else "list"
|
||||
)
|
||||
self._selected_plugin = None
|
||||
if self._mode == "list":
|
||||
self._selected_marketplace = None
|
||||
self._refresh_view()
|
||||
|
||||
def _find_available_plugin(self, plugin_id: str) -> _PluginRow | None:
|
||||
return next(
|
||||
(
|
||||
row
|
||||
for row in self._state.available_plugins
|
||||
if row.plugin_id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _find_installed_plugin(self, plugin_id: str) -> _PluginRow | None:
|
||||
return next(
|
||||
(
|
||||
row
|
||||
for row in self._state.installed_plugins
|
||||
if row.plugin_id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _find_marketplace(self, name: str) -> _MarketplaceRow | None:
|
||||
return next(
|
||||
(row for row in self._state.marketplaces if row.name == name),
|
||||
None,
|
||||
)
|
||||
|
||||
async def _install_selected_plugin(self) -> None:
|
||||
row = self._selected_plugin
|
||||
if row is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(install_plugin, row.plugin_id)
|
||||
except (MarketplaceError, FileNotFoundError, OSError, ValueError) as exc:
|
||||
self._error = str(exc)
|
||||
self._status = None
|
||||
self._refresh_view()
|
||||
return
|
||||
self._mode = "list"
|
||||
self._tab = "installed"
|
||||
self._selected_plugin = None
|
||||
self._status = f"Installed {row.plugin_id}. Run /reload to activate."
|
||||
self._error = None
|
||||
await self._refresh_state()
|
||||
|
||||
async def _toggle_selected_plugin_enabled(self) -> None:
|
||||
row = self._selected_plugin
|
||||
if row is None:
|
||||
return
|
||||
try:
|
||||
if row.enabled:
|
||||
set_installed_plugin_enabled(row.plugin_id, enabled=False)
|
||||
self._status = f"Disabled {row.plugin_id}. Run /reload to unload."
|
||||
self._mode = "list"
|
||||
self._selected_plugin = None
|
||||
else:
|
||||
set_installed_plugin_enabled(row.plugin_id, enabled=True)
|
||||
self._status = f"Enabled {row.plugin_id}. Run /reload to activate."
|
||||
self._mode = "list"
|
||||
self._tab = "installed"
|
||||
self._selected_plugin = None
|
||||
except OSError as exc:
|
||||
self._error = f"Could not update plugin state: {exc}"
|
||||
self._status = None
|
||||
self._refresh_view()
|
||||
return
|
||||
self._error = None
|
||||
await self._refresh_state()
|
||||
|
||||
async def _uninstall_selected_plugin(self) -> None:
|
||||
row = self._selected_plugin
|
||||
if row is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(uninstall_plugin, row.plugin_id)
|
||||
except OSError as exc:
|
||||
self._error = f"Could not uninstall plugin: {exc}"
|
||||
self._status = None
|
||||
self._refresh_view()
|
||||
return
|
||||
self._mode = "list"
|
||||
self._selected_plugin = None
|
||||
reload_hint = " Run /reload to unload." if row.enabled else ""
|
||||
self._status = f"Uninstalled {row.plugin_id}.{reload_hint}"
|
||||
self._error = None
|
||||
await self._refresh_state()
|
||||
|
||||
async def _remove_selected_marketplace(self) -> None:
|
||||
row = self._selected_marketplace
|
||||
if row is None:
|
||||
return
|
||||
self._status = f"Removing marketplace {row.name}..."
|
||||
self._error = None
|
||||
try:
|
||||
removed = await asyncio.to_thread(remove_marketplace, row.name)
|
||||
except OSError as exc:
|
||||
self._status = None
|
||||
self._error = f"Could not remove marketplace: {exc}"
|
||||
self._refresh_view()
|
||||
return
|
||||
if not removed:
|
||||
self._status = None
|
||||
self._error = f"Marketplace {row.name} is no longer configured."
|
||||
await self._refresh_state()
|
||||
return
|
||||
plugin_label = "plugin" if row.installed_count == 1 else "plugins"
|
||||
self._mode = "list"
|
||||
self._tab = "marketplaces"
|
||||
self._selected_marketplace = None
|
||||
self._status = (
|
||||
f"Removed marketplace {row.name} and uninstalled "
|
||||
f"{row.installed_count} {plugin_label}."
|
||||
)
|
||||
self._error = None
|
||||
await self._refresh_state()
|
||||
|
||||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Add a marketplace from the source input."""
|
||||
if event.input.id != "plugin-marketplace-source":
|
||||
return
|
||||
source = event.value.strip()
|
||||
if not source:
|
||||
self._error = "Please enter a marketplace source."
|
||||
self._refresh_view()
|
||||
return
|
||||
self._status = "Adding marketplace..."
|
||||
self._error = None
|
||||
self._refresh_view()
|
||||
try:
|
||||
marketplace = await asyncio.to_thread(add_marketplace_source, source)
|
||||
except (MarketplaceError, OSError, RuntimeError) as exc:
|
||||
self._status = None
|
||||
self._error = f"Could not add marketplace: {exc}"
|
||||
self._refresh_view()
|
||||
return
|
||||
self._mode = "list"
|
||||
self._tab = "discover"
|
||||
self._status = (
|
||||
f"Added marketplace {marketplace.name} "
|
||||
f"({len(marketplace.plugins)} plugin(s))."
|
||||
)
|
||||
self._error = None
|
||||
await self._refresh_state()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Pure plugin manager content builders."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from textual.content import Content
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from deepagents_code.config import get_glyphs
|
||||
from deepagents_code.tui.modals.plugin_manager.models import _MarketplaceRow, _PluginRow
|
||||
|
||||
|
||||
def _plugin_options(
|
||||
rows: tuple[_PluginRow, ...],
|
||||
*,
|
||||
action: Literal["detail", "installed"],
|
||||
status: str | None,
|
||||
) -> list[Option]:
|
||||
options: list[Option] = []
|
||||
for index, row in enumerate(rows):
|
||||
if index > 0:
|
||||
options.append(Option(" ", id=f"spacer:{index}", disabled=True))
|
||||
options.append(
|
||||
Option(_plugin_prompt(row, status=status), id=f"{action}:{row.plugin_id}")
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _plugin_prompt(row: _PluginRow, *, status: str | None) -> Content:
|
||||
glyphs = get_glyphs()
|
||||
plugin_name, _, marketplace = row.plugin_id.partition("@")
|
||||
meta_parts = ["Plugin", marketplace]
|
||||
if row.enabled:
|
||||
meta_parts.append(f"{glyphs.checkmark} enabled")
|
||||
if row.skill_count:
|
||||
meta_parts.append(
|
||||
f"{row.skill_count} {'skill' if row.skill_count == 1 else 'skills'}"
|
||||
)
|
||||
if row.mcp_connected is True:
|
||||
meta_parts.append(f"{glyphs.checkmark} connected")
|
||||
elif row.mcp_connected is False:
|
||||
meta_parts.append("restart to connect")
|
||||
if status:
|
||||
meta_parts.append(status)
|
||||
return Content.assemble(
|
||||
plugin_name,
|
||||
Content.styled(f" · {' · '.join(meta_parts)}", "dim"),
|
||||
"\n ",
|
||||
Content.styled(row.description or "No description provided.", "dim"),
|
||||
)
|
||||
|
||||
|
||||
def _install_details_options() -> list[Option]:
|
||||
return [
|
||||
Option("Install", id="action:install"),
|
||||
Option("Back to plugin list", id="details-back"),
|
||||
]
|
||||
|
||||
|
||||
def _installed_details_options(row: _PluginRow) -> list[Option]:
|
||||
return [
|
||||
Option(
|
||||
"Disable plugin" if row.enabled else "Enable plugin",
|
||||
id="action:toggle-enabled",
|
||||
),
|
||||
Option(Content.styled("Uninstall", "bold"), id="action:uninstall"),
|
||||
Option("Back to plugin list", id="details-back"),
|
||||
]
|
||||
|
||||
|
||||
def _plugin_details_content(row: _PluginRow) -> Content:
|
||||
plugin_name, _, marketplace = row.plugin_id.partition("@")
|
||||
parts: list[Content | str] = [
|
||||
Content.styled("Plugin details", "bold"),
|
||||
"\n\n",
|
||||
Content.styled(plugin_name, "bold"),
|
||||
"\n",
|
||||
Content.styled(f"from {marketplace}", "dim"),
|
||||
]
|
||||
if row.version:
|
||||
parts.extend(["\n", Content.styled(f"Version: {row.version}", "dim")])
|
||||
if row.description:
|
||||
parts.extend(["\n\n", row.description])
|
||||
if row.author:
|
||||
parts.extend(["\n\n", Content.styled(f"By: {row.author}", "dim")])
|
||||
parts.extend(
|
||||
[
|
||||
"\n\n",
|
||||
Content.styled("Will install:", "bold"),
|
||||
"\n ",
|
||||
Content.styled("Components will be discovered at installation.", "dim"),
|
||||
"\n\n",
|
||||
Content.styled(
|
||||
"Make sure you trust a plugin before installing, updating, "
|
||||
"or using it.",
|
||||
"dim",
|
||||
),
|
||||
]
|
||||
)
|
||||
return Content.assemble(*parts)
|
||||
|
||||
|
||||
def _installed_plugin_details_content(row: _PluginRow) -> Content:
|
||||
glyphs = get_glyphs()
|
||||
plugin_name, _, marketplace = row.plugin_id.partition("@")
|
||||
parts: list[Content | str] = [
|
||||
Content.styled(f"{plugin_name} @ {marketplace}", "bold")
|
||||
]
|
||||
if row.version:
|
||||
parts.extend(["\n", Content.styled(f"Version: {row.version}", "dim")])
|
||||
if row.description:
|
||||
parts.extend(["\n\n", row.description])
|
||||
if row.author:
|
||||
parts.extend(["\n\n", Content.styled(f"Author: {row.author}", "dim")])
|
||||
status = f"{glyphs.checkmark} Enabled" if row.enabled else "Disabled"
|
||||
parts.extend(
|
||||
[
|
||||
"\n\n",
|
||||
Content.styled(f"Status: {status}", "dim"),
|
||||
"\n\n",
|
||||
Content.styled("Installed components:", "bold"),
|
||||
]
|
||||
)
|
||||
lines = (
|
||||
[f"Skills: {', '.join(row.skill_names)}"]
|
||||
if row.skill_names
|
||||
else ([f"Skills: {row.skill_count}"] if row.skill_count else [])
|
||||
)
|
||||
if row.mcp_server_names:
|
||||
lines.append(f"MCP: {', '.join(row.mcp_server_names)}")
|
||||
for line in lines or ["No components discovered."]:
|
||||
parts.extend(["\n ", Content.styled(line, "dim")])
|
||||
return Content.assemble(*parts)
|
||||
|
||||
|
||||
def _marketplace_label(row: _MarketplaceRow, bullet: str) -> str:
|
||||
count = "failed" if row.plugin_count is None else f"{row.plugin_count} available"
|
||||
return f"{row.name} {bullet} {row.source} {bullet} {count}"
|
||||
|
||||
|
||||
def _marketplace_details_options() -> list[Option]:
|
||||
return [
|
||||
Option(
|
||||
Content.styled("Remove marketplace", "bold"), id="action:remove-marketplace"
|
||||
),
|
||||
Option("Back to marketplace list", id="details-back"),
|
||||
]
|
||||
|
||||
|
||||
def _confirm_marketplace_removal_options(row: _MarketplaceRow) -> list[Option]:
|
||||
label = "installed plugin" if row.installed_count == 1 else "installed plugins"
|
||||
return [
|
||||
Option(
|
||||
Content.styled(
|
||||
f"Remove marketplace and {row.installed_count} {label}", "bold"
|
||||
),
|
||||
id="action:confirm-remove-marketplace",
|
||||
),
|
||||
Option("Cancel", id="details-back"),
|
||||
]
|
||||
|
||||
|
||||
def _marketplace_details_content(row: _MarketplaceRow) -> Content:
|
||||
available = (
|
||||
"Unavailable" if row.plugin_count is None else f"{row.plugin_count} available"
|
||||
)
|
||||
return Content.assemble(
|
||||
Content.styled(row.name, "bold"),
|
||||
"\n",
|
||||
Content.styled(f"Source: {row.source}", "dim"),
|
||||
"\n",
|
||||
Content.styled(f"Plugins: {available}", "dim"),
|
||||
"\n",
|
||||
Content.styled(f"Installed: {row.installed_count}", "dim"),
|
||||
)
|
||||
|
||||
|
||||
def _marketplace_removal_content(row: _MarketplaceRow) -> Content:
|
||||
suffix = "s" if row.installed_count != 1 else ""
|
||||
warning = (
|
||||
f"This also uninstalls {row.installed_count} plugin{suffix} from this "
|
||||
"marketplace. "
|
||||
if row.installed_count
|
||||
else ""
|
||||
)
|
||||
return Content.assemble(
|
||||
Content.styled(f"Remove marketplace {row.name}?", "bold"),
|
||||
"\n\n",
|
||||
Content.styled(
|
||||
f"{warning}The marketplace record and managed caches will be removed. "
|
||||
"Local source directories are not deleted.",
|
||||
"dim",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Plugin manager view models."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
PluginTab = Literal["discover", "installed", "marketplaces", "errors"]
|
||||
PluginManagerView = Literal[
|
||||
"list",
|
||||
"add_marketplace",
|
||||
"plugin_details",
|
||||
"installed_details",
|
||||
"marketplace_details",
|
||||
"confirm_remove_marketplace",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PluginRow:
|
||||
plugin_id: str
|
||||
description: str
|
||||
enabled: bool
|
||||
version: str | None
|
||||
author: str | None
|
||||
skill_count: int | None = None
|
||||
skill_names: tuple[str, ...] = ()
|
||||
mcp_connected: bool | None = None
|
||||
mcp_server_names: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _MarketplaceRow:
|
||||
name: str
|
||||
source: str
|
||||
plugin_count: int | None
|
||||
installed_count: int
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ManagerState:
|
||||
available_plugins: tuple[_PluginRow, ...]
|
||||
installed_plugins: tuple[_PluginRow, ...]
|
||||
marketplaces: tuple[_MarketplaceRow, ...]
|
||||
errors: tuple[str, ...]
|
||||
@@ -0,0 +1,67 @@
|
||||
PluginManagerScreen {
|
||||
align: center middle;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
PluginManagerScreen > Vertical {
|
||||
width: 88;
|
||||
max-width: 94%;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: solid $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
PluginManagerScreen .plugin-manager-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
PluginManagerScreen .plugin-manager-tabs {
|
||||
color: $text-muted;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
PluginManagerScreen .plugin-manager-divider {
|
||||
color: $text-muted;
|
||||
margin: 0 0 1 0;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
PluginManagerScreen .plugin-manager-status {
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
PluginManagerScreen .plugin-manager-error {
|
||||
height: auto;
|
||||
color: $error;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
PluginManagerScreen #plugin-manager-options {
|
||||
height: 1fr;
|
||||
min-height: 5;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
PluginManagerScreen #plugin-marketplace-source {
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
border: solid $primary-lighten-2;
|
||||
}
|
||||
|
||||
PluginManagerScreen #plugin-marketplace-source:focus {
|
||||
border: solid $primary;
|
||||
}
|
||||
|
||||
PluginManagerScreen .plugin-manager-help {
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Plugin manager state loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from deepagents_code.mcp_tools import MCPServerInfo
|
||||
from deepagents_code.plugins.models import PluginInstance
|
||||
|
||||
from deepagents_code.plugins import discover_plugins
|
||||
from deepagents_code.plugins.marketplace import (
|
||||
MarketplaceError,
|
||||
load_marketplace_location,
|
||||
redact_marketplace_source,
|
||||
redact_urls_in_text,
|
||||
)
|
||||
from deepagents_code.plugins.models import split_plugin_id
|
||||
from deepagents_code.plugins.store import (
|
||||
get_primary_install_entry,
|
||||
load_enabled_plugin_ids,
|
||||
load_installed_plugins,
|
||||
load_marketplace_records,
|
||||
)
|
||||
from deepagents_code.tui.modals.plugin_manager.models import (
|
||||
_ManagerState,
|
||||
_MarketplaceRow,
|
||||
_PluginRow,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_name(value: object) -> str | None:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
name = value.get("name")
|
||||
if isinstance(name, str):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _list_plugin_skill_names(instance: PluginInstance) -> tuple[str, ...]:
|
||||
from deepagents.backends.filesystem import FilesystemBackend
|
||||
|
||||
from deepagents_code.plugins.adapters.skills import plugin_skill_sources
|
||||
from deepagents_code.plugins.adapters.skills_middleware import (
|
||||
load_namespaced_skills,
|
||||
)
|
||||
|
||||
names: list[str] = []
|
||||
for path, _label, namespace in plugin_skill_sources((instance,)):
|
||||
try:
|
||||
source = Path(path).resolve()
|
||||
backend = FilesystemBackend(root_dir=str(source), virtual_mode=False)
|
||||
names.extend(
|
||||
skill["name"]
|
||||
for skill in load_namespaced_skills(backend, str(source), namespace)
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
logger.warning(
|
||||
"Could not list skills for plugin %s", instance.plugin_id, exc_info=True
|
||||
)
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _plugin_mcp_server_names(instance: PluginInstance) -> tuple[str, ...]:
|
||||
from deepagents_code.plugins.adapters.mcp import plugin_mcp_configs
|
||||
|
||||
names: list[str] = []
|
||||
for config in plugin_mcp_configs((instance,)):
|
||||
servers = config.get("mcpServers")
|
||||
if isinstance(servers, dict):
|
||||
names.extend(key for key in servers if isinstance(key, str))
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _plugin_mcp_connected(
|
||||
instance: PluginInstance, mcp_server_info: Sequence[MCPServerInfo]
|
||||
) -> bool | None:
|
||||
expected = frozenset(_plugin_mcp_server_names(instance))
|
||||
if not expected:
|
||||
return None
|
||||
connected = {info.name for info in mcp_server_info if info.status == "ok"}
|
||||
return expected <= connected
|
||||
|
||||
|
||||
def _instance_for_manager_row(
|
||||
plugin_id: str,
|
||||
*,
|
||||
discovered: dict[str, PluginInstance],
|
||||
is_installed: bool,
|
||||
errors: list[str],
|
||||
) -> PluginInstance | None:
|
||||
instance = discovered.get(plugin_id)
|
||||
if instance is not None:
|
||||
return instance
|
||||
if not is_installed:
|
||||
return None
|
||||
entry = get_primary_install_entry(plugin_id)
|
||||
if entry is None:
|
||||
return None
|
||||
root = Path(entry.install_path)
|
||||
try:
|
||||
installed = root.is_dir()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
errors.append(f"{plugin_id}: could not inspect install path: {exc}")
|
||||
return None
|
||||
if not installed:
|
||||
return None
|
||||
from deepagents_code.plugins.discovery import _plugin_from_install_path
|
||||
|
||||
try:
|
||||
plugin_name, marketplace_name = split_plugin_id(plugin_id)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
loaded, warnings = _plugin_from_install_path(
|
||||
plugin_id=plugin_id,
|
||||
root=root,
|
||||
marketplace_name=marketplace_name,
|
||||
fallback_name=plugin_name,
|
||||
)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
errors.append(f"{plugin_id}: {exc}")
|
||||
return None
|
||||
errors.extend(warnings)
|
||||
return loaded
|
||||
|
||||
|
||||
def _load_manager_state(mcp_server_info: Sequence[MCPServerInfo] = ()) -> _ManagerState:
|
||||
records = load_marketplace_records()
|
||||
enabled = load_enabled_plugin_ids()
|
||||
installed = load_installed_plugins()
|
||||
errors: list[str] = []
|
||||
plugin_result = discover_plugins()
|
||||
errors.extend(plugin_result.warnings)
|
||||
discovered = {instance.plugin_id: instance for instance in plugin_result.plugins}
|
||||
available_plugins: list[_PluginRow] = []
|
||||
installed_plugins: list[_PluginRow] = []
|
||||
marketplaces: list[_MarketplaceRow] = []
|
||||
for name, record in sorted(records.items()):
|
||||
try:
|
||||
marketplace = load_marketplace_location(Path(record.install_location))
|
||||
except MarketplaceError as exc:
|
||||
detail = redact_urls_in_text(str(exc))
|
||||
if record.source_type not in {"directory", "file"}:
|
||||
detail = detail.replace(record.install_location, "<managed cache>")
|
||||
errors.append(f"{name}: {detail}")
|
||||
marketplaces.append(
|
||||
_MarketplaceRow(
|
||||
name,
|
||||
redact_marketplace_source(record.source),
|
||||
None,
|
||||
sum(plugin_id.endswith(f"@{name}") for plugin_id in installed),
|
||||
detail,
|
||||
)
|
||||
)
|
||||
continue
|
||||
marketplaces.append(
|
||||
_MarketplaceRow(
|
||||
marketplace.name,
|
||||
redact_marketplace_source(record.source),
|
||||
len(marketplace.plugins),
|
||||
sum(
|
||||
plugin_id.endswith(f"@{marketplace.name}")
|
||||
for plugin_id in installed
|
||||
),
|
||||
)
|
||||
)
|
||||
errors.extend(
|
||||
f"{marketplace.name}: {warning}" for warning in marketplace.warnings
|
||||
)
|
||||
for plugin in marketplace.plugins:
|
||||
plugin_id = f"{plugin.name}@{marketplace.name}"
|
||||
is_enabled = plugin_id in enabled
|
||||
is_installed = plugin_id in installed
|
||||
instance = _instance_for_manager_row(
|
||||
plugin_id,
|
||||
discovered=discovered,
|
||||
is_installed=is_installed,
|
||||
errors=errors,
|
||||
)
|
||||
skill_names = _list_plugin_skill_names(instance) if instance else ()
|
||||
mcp_names = _plugin_mcp_server_names(instance) if instance else ()
|
||||
row = _PluginRow(
|
||||
plugin_id,
|
||||
plugin.description or "",
|
||||
is_enabled,
|
||||
instance.version if instance else None,
|
||||
_extract_name(plugin.author),
|
||||
len(skill_names) if instance else None,
|
||||
skill_names,
|
||||
_plugin_mcp_connected(instance, mcp_server_info)
|
||||
if instance and is_enabled
|
||||
else None,
|
||||
mcp_names,
|
||||
)
|
||||
(installed_plugins if is_installed else available_plugins).append(row)
|
||||
return _ManagerState(
|
||||
tuple(available_plugins),
|
||||
tuple(installed_plugins),
|
||||
tuple(marketplaces),
|
||||
tuple(dict.fromkeys(errors)),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Screen UI components."""
|
||||
@@ -130,6 +130,11 @@ class SlashCommandController:
|
||||
self._commands = commands
|
||||
self._view = view
|
||||
self._suggestions: list[tuple[str, str]] = []
|
||||
# Machine names aligned by index with `_suggestions`. The popup shows
|
||||
# each suggestion's label, but completion inserts the machine name so a
|
||||
# plugin skill shown as `/skill:review` still inserts its full
|
||||
# `/skill:my-plugin:review`.
|
||||
self._suggestion_names: list[str] = []
|
||||
self._selected_index = 0
|
||||
|
||||
def update_commands(self, commands: list[CommandEntry]) -> None:
|
||||
@@ -157,6 +162,7 @@ class SlashCommandController:
|
||||
"""Clear suggestions."""
|
||||
if self._suggestions:
|
||||
self._suggestions.clear()
|
||||
self._suggestion_names.clear()
|
||||
self._selected_index = 0
|
||||
self._view.clear_completion_suggestions()
|
||||
|
||||
@@ -237,14 +243,14 @@ class SlashCommandController:
|
||||
return
|
||||
|
||||
if not search:
|
||||
# No search text — show all commands (display only cmd + desc)
|
||||
suggestions = [(entry.name, entry.description) for entry in self._commands][
|
||||
:MAX_SUGGESTIONS
|
||||
]
|
||||
# No search text — show all commands. Display the label, but keep
|
||||
# the machine name aligned for insertion.
|
||||
selected = list(self._commands)[:MAX_SUGGESTIONS]
|
||||
else:
|
||||
# Score and filter commands using fuzzy matching
|
||||
# Score and filter commands using fuzzy matching. Matching runs on
|
||||
# the machine name so the full namespaced name is always reachable.
|
||||
scored = [
|
||||
(score, entry.name, entry.description)
|
||||
(score, entry)
|
||||
for entry in self._commands
|
||||
if (
|
||||
score := self._score_command(
|
||||
@@ -254,10 +260,13 @@ class SlashCommandController:
|
||||
> 0
|
||||
]
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
suggestions = [(cmd, desc) for _, cmd, desc in scored[:MAX_SUGGESTIONS]]
|
||||
selected = [entry for _, entry in scored[:MAX_SUGGESTIONS]]
|
||||
|
||||
if suggestions:
|
||||
self._suggestions = suggestions
|
||||
if selected:
|
||||
self._suggestions = [
|
||||
(entry.label(), entry.description) for entry in selected
|
||||
]
|
||||
self._suggestion_names = [entry.name for entry in selected]
|
||||
self._selected_index = 0
|
||||
self._view.render_completion_suggestions(
|
||||
self._suggestions, self._selected_index
|
||||
@@ -316,7 +325,8 @@ class SlashCommandController:
|
||||
if not self._suggestions:
|
||||
return False
|
||||
|
||||
command, _ = self._suggestions[self._selected_index]
|
||||
# Insert the machine name (aligned by index), not the displayed label.
|
||||
command = self._suggestion_names[self._selected_index]
|
||||
# Replace from start to cursor with the command
|
||||
self._view.replace_completion_range(0, cursor_index, command)
|
||||
self.reset()
|
||||
|
||||
@@ -22,7 +22,7 @@ from textual.strip import Strip
|
||||
from textual.widgets import Static, TextArea
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.command_registry import SLASH_COMMANDS, CommandEntry
|
||||
from deepagents_code.command_registry import CommandEntry, get_slash_commands
|
||||
from deepagents_code.config import (
|
||||
MODE_DISPLAY_GLYPHS,
|
||||
MODE_PREFIXES,
|
||||
@@ -1655,7 +1655,7 @@ class ChatInput(Vertical):
|
||||
self._completion_view, cwd=self._cwd
|
||||
)
|
||||
self._slash_controller = SlashCommandController(
|
||||
SLASH_COMMANDS, self._completion_view
|
||||
get_slash_commands(), self._completion_view
|
||||
)
|
||||
self._completion_manager = MultiCompletionManager(
|
||||
[
|
||||
@@ -1664,7 +1664,7 @@ class ChatInput(Vertical):
|
||||
] # ty: ignore[invalid-argument-type] # Controller types are compatible at runtime
|
||||
)
|
||||
|
||||
self._rebuild_argument_hints(SLASH_COMMANDS)
|
||||
self._rebuild_argument_hints(get_slash_commands())
|
||||
|
||||
self._warm_file_cache()
|
||||
self.set_interval(
|
||||
|
||||
@@ -382,6 +382,30 @@ def show_skills_help() -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
def show_plugins_help() -> None:
|
||||
"""Show help information for the `plugin` / `plugins` subcommand."""
|
||||
console.print()
|
||||
console.print("[bold]Usage:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" dcode plugin <command> [options]")
|
||||
console.print()
|
||||
console.print("[bold]Commands:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" list|ls List available plugins")
|
||||
console.print(" install <id> Install a marketplace plugin")
|
||||
console.print(" uninstall <id> Uninstall a plugin")
|
||||
console.print(" enable <id> Enable an installed plugin")
|
||||
console.print(" disable <id> Disable a plugin")
|
||||
console.print(" marketplace list|ls List configured marketplaces")
|
||||
console.print(" marketplace add <source> Add a marketplace source")
|
||||
console.print(" marketplace remove <name> Remove a marketplace and its plugins")
|
||||
console.print()
|
||||
console.print("[bold]Examples:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" dcode plugin list")
|
||||
console.print(" dcode plugin marketplace add ./marketplace")
|
||||
console.print(" dcode plugin install quality-review-plugin@company-tools")
|
||||
console.print(" dcode plugin enable quality-review-plugin@company-tools")
|
||||
console.print()
|
||||
|
||||
|
||||
def show_skills_list_help() -> None:
|
||||
"""Show help information for the `skills list` subcommand."""
|
||||
console.print()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Plugin unit tests."""
|
||||
@@ -0,0 +1,351 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from http.client import HTTPMessage
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deepagents_code.plugins._json import json_object, json_value
|
||||
from deepagents_code.plugins.marketplace import (
|
||||
MarketplaceError,
|
||||
_download_marketplace,
|
||||
_HttpsOnlyRedirectHandler,
|
||||
_redact_url_credentials,
|
||||
_root_for_marketplace_file,
|
||||
_run_git,
|
||||
parse_marketplace_source,
|
||||
)
|
||||
from deepagents_code.plugins.models import (
|
||||
LocalMarketplaceSource,
|
||||
RepositoryMarketplaceSource,
|
||||
UrlMarketplaceSource,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("https://[invalid", "https://***"),
|
||||
("git@github.com:owner/repo.git", "git@github.com:owner/repo.git"),
|
||||
(
|
||||
"https://user:pass@example.com:8443/repo",
|
||||
"https://***@example.com:8443/repo",
|
||||
),
|
||||
(
|
||||
"https://user:pass@example.com:invalid/repo",
|
||||
"https://***",
|
||||
),
|
||||
(
|
||||
"https://example.com/catalog?token=secret&channel=stable",
|
||||
"https://example.com/catalog?token=%2A%2A%2A&channel=stable",
|
||||
),
|
||||
(
|
||||
"https://example.com/api-key/secret/plugins",
|
||||
"https://example.com/api-key/***/plugins",
|
||||
),
|
||||
("https://example.com/token", "https://example.com/token"),
|
||||
(
|
||||
"https://example.com/catalog?channel=stable",
|
||||
"https://example.com/catalog?channel=stable",
|
||||
),
|
||||
("https://example.com/catalog", "https://example.com/catalog"),
|
||||
],
|
||||
)
|
||||
def test_redact_url_credentials(value: str, expected: str) -> None:
|
||||
assert _redact_url_credentials(value) == expected
|
||||
|
||||
|
||||
def test_parse_marketplace_source_repositories() -> None:
|
||||
ssh = parse_marketplace_source("git@github.com:owner/repo.git")
|
||||
ssh_ref = parse_marketplace_source("git@github.com:owner/repo.git#main")
|
||||
generic = parse_marketplace_source("https://git.example.com/team/repo.git#v1")
|
||||
azure = parse_marketplace_source(
|
||||
"https://dev.azure.com/org/project/_git/plugins#release"
|
||||
)
|
||||
github = parse_marketplace_source("https://github.com/owner/repo#main")
|
||||
|
||||
assert ssh == RepositoryMarketplaceSource(
|
||||
source_type="git", value="git@github.com:owner/repo.git", ref=None
|
||||
)
|
||||
assert ssh_ref == RepositoryMarketplaceSource(
|
||||
source_type="git", value="git@github.com:owner/repo.git", ref="main"
|
||||
)
|
||||
assert generic == RepositoryMarketplaceSource(
|
||||
source_type="git",
|
||||
value="https://git.example.com/team/repo.git",
|
||||
ref="v1",
|
||||
)
|
||||
assert azure == RepositoryMarketplaceSource(
|
||||
source_type="git",
|
||||
value="https://dev.azure.com/org/project/_git/plugins",
|
||||
ref="release",
|
||||
)
|
||||
assert github == RepositoryMarketplaceSource(
|
||||
source_type="git",
|
||||
value="https://github.com/owner/repo.git",
|
||||
ref="main",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_marketplace_source_http_json() -> None:
|
||||
source = parse_marketplace_source("https://example.com/marketplace.json")
|
||||
assert source == UrlMarketplaceSource(
|
||||
source_type="url", value="https://example.com/marketplace.json"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_marketplace_source_preserves_credentials() -> None:
|
||||
source = parse_marketplace_source("https://user:pass@example.com/marketplace.json")
|
||||
assert source == UrlMarketplaceSource(
|
||||
source_type="url",
|
||||
value="https://user:pass@example.com/marketplace.json",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator", ["#", "@"])
|
||||
def test_parse_marketplace_source_github_shorthand(separator: str) -> None:
|
||||
source = parse_marketplace_source(f"owner/repo{separator}main")
|
||||
assert source == RepositoryMarketplaceSource(
|
||||
source_type="github", value="owner/repo", ref="main"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_marketplace_source_local_paths(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
directory = tmp_path / "catalog"
|
||||
directory.mkdir()
|
||||
json_file = directory / "marketplace.json"
|
||||
json_file.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
explicit = parse_marketplace_source("./catalog")
|
||||
bare = parse_marketplace_source("catalog")
|
||||
local_file = parse_marketplace_source("./catalog/marketplace.json")
|
||||
|
||||
assert explicit == LocalMarketplaceSource(
|
||||
source_type="directory", value=str(directory.resolve())
|
||||
)
|
||||
assert bare == explicit
|
||||
assert local_file == LocalMarketplaceSource(
|
||||
source_type="file", value=str(json_file.resolve())
|
||||
)
|
||||
|
||||
|
||||
def test_parse_marketplace_source_rejects_non_json_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = tmp_path / "marketplace.txt"
|
||||
path.write_text("catalog", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with pytest.raises(MarketplaceError, match=r"must point to a \.json"):
|
||||
parse_marketplace_source("./marketplace.txt")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "message"),
|
||||
[
|
||||
("", "Please enter"),
|
||||
("https://github.com/owner/repo/tree/main", "exactly owner/repo"),
|
||||
("https://[invalid", "Invalid marketplace URL"),
|
||||
("http://example.com/marketplace.json", "must use https"),
|
||||
("http://example.com/plugins.git", "must use https"),
|
||||
("./missing", "Path does not exist"),
|
||||
("not a source", "Invalid marketplace source format"),
|
||||
("owner/repo/extra", "Invalid marketplace source format"),
|
||||
("@owner/repo", "Invalid marketplace source format"),
|
||||
],
|
||||
)
|
||||
def test_parse_marketplace_source_rejects_invalid_formats(
|
||||
value: str, message: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with pytest.raises(MarketplaceError, match=message):
|
||||
parse_marketplace_source(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative",
|
||||
[
|
||||
Path(".claude-plugin/marketplace.json"),
|
||||
Path(".agents/plugins/marketplace.json"),
|
||||
Path(".agents/plugins/api_marketplace.json"),
|
||||
],
|
||||
)
|
||||
def test_root_for_marketplace_file(relative: Path, tmp_path: Path) -> None:
|
||||
assert _root_for_marketplace_file(tmp_path / relative) == tmp_path
|
||||
|
||||
|
||||
def test_root_for_unconventional_marketplace_file(tmp_path: Path) -> None:
|
||||
path = tmp_path / "catalog.json"
|
||||
assert _root_for_marketplace_file(path) == tmp_path
|
||||
|
||||
|
||||
def test_download_marketplace_rejects_plain_http() -> None:
|
||||
with pytest.raises(MarketplaceError, match="must use https"):
|
||||
_download_marketplace("http://example.com/marketplace.json")
|
||||
|
||||
|
||||
def test_download_marketplace_rejects_http_redirect() -> None:
|
||||
handler = _HttpsOnlyRedirectHandler()
|
||||
request = urllib.request.Request("https://example.com/marketplace.json")
|
||||
|
||||
with pytest.raises(MarketplaceError, match="redirect must use https"):
|
||||
handler.redirect_request(
|
||||
request,
|
||||
io.BytesIO(),
|
||||
302,
|
||||
"Found",
|
||||
HTTPMessage(),
|
||||
"http://example.com/marketplace.json",
|
||||
)
|
||||
|
||||
|
||||
def test_download_marketplace_rejects_non_https_final_url(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class Response(io.BytesIO):
|
||||
def geturl(self) -> str:
|
||||
return "http://example.com/marketplace.json"
|
||||
|
||||
class Opener:
|
||||
def open(self, *_args: object, **_kwargs: object) -> Response:
|
||||
return Response(json.dumps({"name": "x", "plugins": []}).encode())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_DIR", tmp_path / "config"
|
||||
)
|
||||
monkeypatch.setattr("urllib.request.build_opener", lambda *_handlers: Opener())
|
||||
|
||||
with pytest.raises(MarketplaceError, match="response must use https"):
|
||||
_download_marketplace("https://example.com/marketplace.json")
|
||||
|
||||
|
||||
def test_download_marketplace_cache_path_is_opaque(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class Response(io.BytesIO):
|
||||
def geturl(self) -> str:
|
||||
return "https://example.com/marketplace.json"
|
||||
|
||||
class Opener:
|
||||
def open(self, *_args: object, **_kwargs: object) -> Response:
|
||||
return Response(json.dumps({"name": "x", "plugins": []}).encode())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_DIR", tmp_path / "config"
|
||||
)
|
||||
monkeypatch.setattr("urllib.request.build_opener", lambda *_handlers: Opener())
|
||||
|
||||
path = _download_marketplace(
|
||||
"https://user:secret@example.com/marketplace.json?token=hidden"
|
||||
)
|
||||
|
||||
assert "secret" not in str(path)
|
||||
assert "hidden" not in str(path)
|
||||
|
||||
|
||||
def test_run_git_passes_fixed_argv_and_noninteractive_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("INHERITED_SETTING", "yes")
|
||||
monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/git")
|
||||
received: dict[str, object] = {}
|
||||
|
||||
def run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
received["argv"] = argv
|
||||
received.update(kwargs)
|
||||
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", run)
|
||||
_run_git(["clone", "https://example.com/repo.git", "/tmp/repo"])
|
||||
|
||||
assert received["argv"] == [
|
||||
"/usr/bin/git",
|
||||
"clone",
|
||||
"https://example.com/repo.git",
|
||||
"/tmp/repo",
|
||||
]
|
||||
env = received["env"]
|
||||
assert env == {
|
||||
**os.environ,
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_ASKPASS": "",
|
||||
}
|
||||
assert received["check"] is False
|
||||
assert received["capture_output"] is True
|
||||
assert received["text"] is True
|
||||
assert received["timeout"] == 120
|
||||
|
||||
|
||||
def test_run_git_requires_git(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("shutil.which", lambda _name: None)
|
||||
with pytest.raises(MarketplaceError, match="Git is required"):
|
||||
_run_git(["clone"])
|
||||
|
||||
|
||||
def test_run_git_redacts_nonzero_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/git")
|
||||
result = subprocess.CompletedProcess(
|
||||
["git"], 1, "", "failed https://example.com/?token=secret"
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: result)
|
||||
|
||||
with pytest.raises(MarketplaceError) as exc_info:
|
||||
_run_git(["clone"])
|
||||
|
||||
assert "secret" not in str(exc_info.value)
|
||||
assert "token=%2A%2A%2A" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
OSError("cannot execute"),
|
||||
subprocess.TimeoutExpired(["git", "clone"], 120),
|
||||
],
|
||||
)
|
||||
def test_run_git_wraps_execution_errors(
|
||||
error: OSError | subprocess.TimeoutExpired,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/git")
|
||||
|
||||
def fail(*_args: object, **_kwargs: object) -> None:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fail)
|
||||
with pytest.raises(MarketplaceError, match="Failed to run git"):
|
||||
_run_git(["clone"])
|
||||
|
||||
|
||||
def test_run_git_redacts_credentials_from_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/git")
|
||||
error = subprocess.TimeoutExpired(
|
||||
["git", "clone", "https://user:secret@example.com/repo.git"], 120
|
||||
)
|
||||
|
||||
def fail(*_args: object, **_kwargs: object) -> None:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fail)
|
||||
with pytest.raises(MarketplaceError) as exc_info:
|
||||
_run_git(["clone"])
|
||||
|
||||
assert "secret" not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_json_normalization_is_recursive_and_precisely_typed() -> None:
|
||||
marker = object()
|
||||
value = {"valid": [1, None, {"nested": True}], "invalid": marker, 1: "ignored"}
|
||||
|
||||
assert json_value(value) == {"valid": [1, None, {"nested": True}]}
|
||||
assert json_object(value) == {"valid": [1, None, {"nested": True}]}
|
||||
assert json_object(["not", "an", "object"]) == {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from deepagents_code.plugins.models import MarketplaceRecord
|
||||
from deepagents_code.plugins.store import (
|
||||
PluginStateError,
|
||||
add_installed_plugin,
|
||||
save_marketplace_record,
|
||||
set_plugin_enabled,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
path = tmp_path / "state"
|
||||
path.mkdir()
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_STATE_DIR", path)
|
||||
return path
|
||||
|
||||
|
||||
def test_marketplace_mutation_preserves_corrupt_state(state_dir: Path) -> None:
|
||||
path = state_dir / "plugin_marketplaces.json"
|
||||
original = "{not valid json"
|
||||
path.write_text(original, encoding="utf-8")
|
||||
|
||||
with pytest.raises(PluginStateError, match="could not be read"):
|
||||
save_marketplace_record(
|
||||
MarketplaceRecord(
|
||||
name="tools",
|
||||
source_type="github",
|
||||
source="owner/repo",
|
||||
install_location="/cache/tools",
|
||||
)
|
||||
)
|
||||
|
||||
assert path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_enablement_mutation_preserves_future_state(state_dir: Path) -> None:
|
||||
path = state_dir / "plugin_state.json"
|
||||
original = '{"version": 999, "enabledPlugins": {"existing@tools": true}}'
|
||||
path.write_text(original, encoding="utf-8")
|
||||
|
||||
with pytest.raises(PluginStateError, match="unsupported version"):
|
||||
set_plugin_enabled("new@tools", True)
|
||||
|
||||
assert path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_install_mutation_preserves_non_object_state(state_dir: Path) -> None:
|
||||
path = state_dir / "installed_plugins.json"
|
||||
original = '["existing"]'
|
||||
path.write_text(original, encoding="utf-8")
|
||||
|
||||
with pytest.raises(PluginStateError, match="not a JSON object"):
|
||||
add_installed_plugin(
|
||||
"new@tools",
|
||||
install_path="/cache/new",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
assert path.read_text(encoding="utf-8") == original
|
||||
@@ -854,3 +854,49 @@ class TestListSkillsClaudeDirectories:
|
||||
assert len(skills) == 2
|
||||
names = {s["name"] for s in skills}
|
||||
assert names == {"user-skill", "claude-skill"}
|
||||
|
||||
|
||||
class TestListSkillsPluginNamespacing:
|
||||
"""Plugin sources namespace names, including nested subfolders."""
|
||||
|
||||
def test_plugin_skills_namespaced_with_nesting(self, tmp_path: Path) -> None:
|
||||
"""The TUI loader namespaces plugin skills like the middleware does."""
|
||||
plugin_skills = tmp_path / "plugin" / "skills"
|
||||
_create_skill(plugin_skills / "review", "review", "Top-level skill")
|
||||
_create_skill(plugin_skills / "foo" / "lookup", "lookup", "Nested skill")
|
||||
|
||||
skills = list_skills(
|
||||
plugin_skill_sources=[(plugin_skills, "my-plugin")],
|
||||
)
|
||||
|
||||
namespaced = {s["name"] for s in skills}
|
||||
assert namespaced == {"my-plugin:review", "my-plugin:foo:lookup"}
|
||||
assert all(s["source"] == "plugin" for s in skills)
|
||||
|
||||
def test_root_plugin_skill_is_discoverable(self, tmp_path: Path) -> None:
|
||||
plugin_root = tmp_path / "root-plugin"
|
||||
_create_skill(plugin_root, "root-plugin", "Root skill")
|
||||
|
||||
skills = list_skills(
|
||||
plugin_skill_sources=[(plugin_root, "root-plugin@tools")],
|
||||
)
|
||||
|
||||
assert [skill["name"] for skill in skills] == ["root-plugin@tools:root-plugin"]
|
||||
|
||||
def test_plugin_namespace_and_subfolders_are_normalized(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
plugin_skills = tmp_path / "plugin" / "skills"
|
||||
_create_skill(
|
||||
plugin_skills / "Policies" / "review",
|
||||
"review",
|
||||
"Policy review",
|
||||
)
|
||||
|
||||
skills = list_skills(
|
||||
plugin_skill_sources=[(plugin_skills, "Quality@Company")],
|
||||
)
|
||||
|
||||
assert [skill["name"] for skill in skills] == [
|
||||
"quality@company:policies:review"
|
||||
]
|
||||
|
||||
@@ -1133,7 +1133,7 @@ class TestCreateCliAgentInteractiveForwarding:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent", return_value=mock_agent
|
||||
@@ -1197,7 +1197,7 @@ class TestCreateCliAgentInteractiveForwarding:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent", return_value=mock_agent
|
||||
@@ -1523,7 +1523,7 @@ class TestCreateCliAgentSkillsSources:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware", FakeSkillsMiddleware),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware", FakeSkillsMiddleware),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch("deepagents_code.agent.create_deep_agent", return_value=mock_agent),
|
||||
patch(
|
||||
@@ -1577,82 +1577,6 @@ class TestCreateCliAgentSkillsSources:
|
||||
assert expected in rendered, f"missing {expected!r} in:\n{rendered}"
|
||||
assert rendered.rstrip().endswith("(higher priority)")
|
||||
|
||||
def test_skills_sources_fallback_to_bare_paths_on_old_sdk(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""If the installed SDK lacks `SkillSource`, CLI passes bare paths.
|
||||
|
||||
Backwards-compat: SDKs < 0.5.4 only accept `list[str]`. The CLI
|
||||
detects the missing alias at import time and strips labels
|
||||
before handing sources to `SkillsMiddleware`, so the middleware
|
||||
never receives an unsupported tuple.
|
||||
"""
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
user_agent_skills_dir = tmp_path / "user-agent-skills"
|
||||
user_agent_skills_dir.mkdir()
|
||||
built_in_dir = Settings.get_built_in_skills_dir()
|
||||
|
||||
mock_settings = Mock()
|
||||
mock_settings.ensure_agent_dir.return_value = agent_dir
|
||||
mock_settings.ensure_user_skills_dir.return_value = skills_dir
|
||||
mock_settings.get_user_agent_skills_dir.return_value = user_agent_skills_dir
|
||||
mock_settings.get_project_skills_dir.return_value = None
|
||||
mock_settings.get_project_agent_skills_dir.return_value = None
|
||||
mock_settings.get_built_in_skills_dir.return_value = built_in_dir
|
||||
mock_settings.get_user_claude_skills_dir.return_value = tmp_path / "nonexistent"
|
||||
mock_settings.get_project_claude_skills_dir.return_value = None
|
||||
mock_settings.get_user_agent_md_path.return_value = agent_dir / "AGENTS.md"
|
||||
mock_settings.get_project_agent_md_path.return_value = []
|
||||
mock_settings.get_user_agents_dir.return_value = tmp_path / "agents"
|
||||
mock_settings.get_project_agents_dir.return_value = None
|
||||
mock_settings.model_name = None
|
||||
mock_settings.model_provider = None
|
||||
mock_settings.model_unsupported_modalities = frozenset()
|
||||
mock_settings.model_context_limit = None
|
||||
mock_settings.project_root = None
|
||||
|
||||
captured_sources: list[list[Any]] = []
|
||||
|
||||
class FakeSkillsMiddleware:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
captured_sources.append(kwargs.get("sources", []))
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.with_config.return_value = mock_agent
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent._SUPPORTS_SKILL_SOURCE_TUPLES", False),
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware", FakeSkillsMiddleware),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch("deepagents_code.agent.create_deep_agent", return_value=mock_agent),
|
||||
patch(
|
||||
"deepagents._models.init_chat_model",
|
||||
return_value=fake_model,
|
||||
),
|
||||
):
|
||||
create_cli_agent(
|
||||
model="fake-model",
|
||||
assistant_id="test",
|
||||
enable_memory=False,
|
||||
enable_skills=True,
|
||||
enable_shell=False,
|
||||
)
|
||||
|
||||
assert len(captured_sources) == 1
|
||||
sources = captured_sources[0]
|
||||
# Fallback stripped all labels; middleware receives bare strings.
|
||||
assert sources == [
|
||||
str(built_in_dir),
|
||||
str(skills_dir),
|
||||
str(user_agent_skills_dir),
|
||||
]
|
||||
for source in sources:
|
||||
assert isinstance(source, str), f"expected str, got {type(source)!r}"
|
||||
|
||||
|
||||
class TestCreateCliAgentMemorySources:
|
||||
"""Test that `create_cli_agent` wires project AGENTS.md into memory sources."""
|
||||
@@ -1701,7 +1625,7 @@ class TestCreateCliAgentMemorySources:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware", FakeMemoryMiddleware),
|
||||
patch("deepagents_code.agent.FilesystemBackend"),
|
||||
patch(
|
||||
@@ -1768,7 +1692,7 @@ class TestCreateCliAgentMemorySources:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware", FakeMemoryMiddleware),
|
||||
patch("deepagents_code.agent.FilesystemBackend"),
|
||||
patch(
|
||||
@@ -1948,7 +1872,7 @@ class TestCreateCliAgentProjectContext:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware", FakeSkillsMiddleware),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware", FakeSkillsMiddleware),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch("deepagents_code.agent.list_subagents", return_value=[]) as mock_list,
|
||||
patch("deepagents_code.agent.create_deep_agent", return_value=mock_agent),
|
||||
@@ -2028,7 +1952,7 @@ class TestCreateCliAgentProjectContext:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware", FakeMemoryMiddleware),
|
||||
patch("deepagents_code.agent.FilesystemBackend"),
|
||||
patch("deepagents_code.agent.create_deep_agent", return_value=mock_agent),
|
||||
@@ -2104,7 +2028,7 @@ class TestCreateCliAgentProjectContext:
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.LocalShellBackend", return_value=mock_backend
|
||||
) as mock_shell,
|
||||
@@ -2230,7 +2154,7 @@ class TestCreateCliAgentProjectContext:
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.FilesystemBackend") as mock_filesystem,
|
||||
patch("deepagents_code.agent.create_deep_agent", return_value=mock_agent),
|
||||
patch("deepagents._models.init_chat_model", return_value=fake_model),
|
||||
@@ -2691,7 +2615,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
@@ -2730,7 +2654,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
@@ -2776,7 +2700,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -2834,7 +2758,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -2887,7 +2811,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -2940,7 +2864,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -3014,7 +2938,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -3088,7 +3012,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -3141,7 +3065,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -3191,7 +3115,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -3245,7 +3169,7 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -3343,7 +3267,7 @@ class TestExperimentalTodoMiddlewareWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
@@ -3578,7 +3502,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
@@ -3616,7 +3540,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch("deepagents_code.agent.ReliableRubricMiddleware") as mock_rubric,
|
||||
patch(
|
||||
@@ -3686,7 +3610,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
@@ -3720,7 +3644,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
fake_model = _make_fake_chat_model()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
@@ -3750,7 +3674,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
fake_sandbox = Mock()
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents._models.init_chat_model",
|
||||
@@ -3792,7 +3716,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
mock_agent.with_config.return_value = mock_agent
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
@@ -3836,7 +3760,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents._models.init_chat_model",
|
||||
@@ -3885,7 +3809,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
mock_agent.with_config.return_value = mock_agent
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.PluginSkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
|
||||
@@ -19199,6 +19199,26 @@ class TestNotificationCenterIntegration:
|
||||
assert screen._selected_index == 2
|
||||
assert app._auto_approve is False
|
||||
|
||||
async def test_plugin_manager_shift_tab_moves_to_previous_tab(self) -> None:
|
||||
"""App-level shift+tab routes to PluginManagerScreen.previous_tab."""
|
||||
from deepagents_code.tui.modals.plugin_manager import PluginManagerScreen
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
screen = PluginManagerScreen()
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
assert screen._tab == "discover"
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert screen._tab == "errors"
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert screen._tab == "discover"
|
||||
assert app._auto_approve is False
|
||||
|
||||
async def test_toast_identity_warn_once_semantics(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from deepagents_code._env_vars import EXPERIMENTAL
|
||||
from deepagents_code.main import parse_args
|
||||
from deepagents_code.ui import show_help, show_threads_list_help
|
||||
|
||||
@@ -49,6 +50,36 @@ class TestInitialPromptArg:
|
||||
assert args.initial_prompt == ""
|
||||
|
||||
|
||||
def test_plugin_subcommand_parses_without_experimental_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Argparse still accepts `plugin` when experimental is off.
|
||||
|
||||
The experimental gate lives at execution time in `main` (exit 2 + hint),
|
||||
not in argparse, so disabled users get a clear message instead of a usage error.
|
||||
"""
|
||||
monkeypatch.delenv(EXPERIMENTAL, raising=False)
|
||||
with patch.object(sys, "argv", ["deepagents", "plugin", "list"]):
|
||||
args = parse_args()
|
||||
assert args.command == "plugin"
|
||||
assert args.plugin_command == "list"
|
||||
|
||||
|
||||
def test_plugin_marketplace_remove_parses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(EXPERIMENTAL, "1")
|
||||
with patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
["deepagents", "plugin", "marketplace", "remove", "company-tools"],
|
||||
):
|
||||
args = parse_args()
|
||||
assert args.plugin_command == "marketplace"
|
||||
assert args.marketplace_command == "remove"
|
||||
assert args.name == "company-tools"
|
||||
|
||||
|
||||
class TestInitialSkillArg:
|
||||
"""Tests for `--skill` startup skill argument."""
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ from deepagents_code.command_registry import (
|
||||
IMMEDIATE_UI,
|
||||
QUEUE_BOUND,
|
||||
SIDE_EFFECT_FREE,
|
||||
SLASH_COMMANDS,
|
||||
STARTUP_RECOVERY_COMMANDS,
|
||||
CommandEntry,
|
||||
get_slash_commands,
|
||||
)
|
||||
|
||||
|
||||
@@ -101,13 +101,14 @@ class TestBypassTiers:
|
||||
|
||||
|
||||
class TestSlashCommands:
|
||||
"""Validate the SLASH_COMMANDS autocomplete list."""
|
||||
"""Validate the get_slash_commands() autocomplete list."""
|
||||
|
||||
def test_length_matches_commands(self) -> None:
|
||||
assert len(SLASH_COMMANDS) == len(COMMANDS)
|
||||
def test_length_matches_public_commands(self) -> None:
|
||||
entries = get_slash_commands()
|
||||
assert len(entries) <= len(COMMANDS)
|
||||
|
||||
def test_entry_format(self) -> None:
|
||||
for entry in SLASH_COMMANDS:
|
||||
for entry in get_slash_commands():
|
||||
assert isinstance(entry, CommandEntry)
|
||||
assert isinstance(entry.name, str)
|
||||
assert entry.name.startswith("/")
|
||||
@@ -116,17 +117,30 @@ class TestSlashCommands:
|
||||
assert isinstance(entry.argument_hint, str)
|
||||
|
||||
def test_excludes_aliases(self) -> None:
|
||||
names = {entry.name for entry in SLASH_COMMANDS}
|
||||
names = {entry.name for entry in get_slash_commands()}
|
||||
for cmd in COMMANDS:
|
||||
for alias in cmd.aliases:
|
||||
assert alias not in names, (
|
||||
f"Alias {alias!r} should not appear in autocomplete"
|
||||
)
|
||||
|
||||
def test_to_entry_matches_slash_commands(self) -> None:
|
||||
"""SlashCommand.to_entry() produces the same entries as SLASH_COMMANDS."""
|
||||
for cmd, entry in zip(COMMANDS, SLASH_COMMANDS, strict=True):
|
||||
assert cmd.to_entry() == entry
|
||||
def test_entries_come_from_commands(self) -> None:
|
||||
"""Every public entry is derived from the command registry."""
|
||||
command_entries = {command.to_entry() for command in COMMANDS}
|
||||
assert set(get_slash_commands()) <= command_entries
|
||||
|
||||
def test_experimental_plugin_commands_hidden_by_default(self) -> None:
|
||||
names = {entry.name for entry in get_slash_commands()}
|
||||
assert "/plugins" not in names
|
||||
|
||||
def test_experimental_plugin_commands_visible_when_enabled(
|
||||
self, monkeypatch
|
||||
) -> None:
|
||||
from deepagents_code._env_vars import EXPERIMENTAL
|
||||
|
||||
monkeypatch.setenv(EXPERIMENTAL, "1")
|
||||
names = {entry.name for entry in get_slash_commands()}
|
||||
assert "/plugins" in names
|
||||
|
||||
|
||||
class TestHiddenCommands:
|
||||
@@ -136,10 +150,10 @@ class TestHiddenCommands:
|
||||
assert "/debug-error" in HIDDEN_COMMANDS
|
||||
|
||||
def test_hidden_not_in_autocomplete(self) -> None:
|
||||
names = {entry.name for entry in SLASH_COMMANDS}
|
||||
names = {entry.name for entry in get_slash_commands()}
|
||||
for hidden in HIDDEN_COMMANDS:
|
||||
assert hidden not in names, (
|
||||
f"Hidden command {hidden!r} leaked into SLASH_COMMANDS"
|
||||
f"Hidden command {hidden!r} leaked into get_slash_commands()"
|
||||
)
|
||||
|
||||
|
||||
@@ -148,7 +162,7 @@ class TestRestartCommand:
|
||||
|
||||
def test_restart_registered_for_autocomplete(self) -> None:
|
||||
restart_entry = next(
|
||||
entry for entry in SLASH_COMMANDS if entry.name == "/restart"
|
||||
entry for entry in get_slash_commands() if entry.name == "/restart"
|
||||
)
|
||||
|
||||
# Exact wording is pinned by TestCommandsCatalogDrift; here we only
|
||||
@@ -256,7 +270,9 @@ class TestCopyCommand:
|
||||
"""Validate the `/copy` entry specifically."""
|
||||
|
||||
def test_copy_registered_for_autocomplete(self) -> None:
|
||||
copy_entry = next(entry for entry in SLASH_COMMANDS if entry.name == "/copy")
|
||||
copy_entry = next(
|
||||
entry for entry in get_slash_commands() if entry.name == "/copy"
|
||||
)
|
||||
|
||||
# Exact wording is pinned by TestCommandsCatalogDrift; here we only
|
||||
# assert the entry is registered with a non-empty description.
|
||||
@@ -296,74 +312,34 @@ class TestCommandsCatalogDrift:
|
||||
|
||||
|
||||
class TestHelpBodyDrift:
|
||||
"""Ensure the /help body in app.py stays in sync with COMMANDS.
|
||||
"""Ensure `/help` stays wired to the public slash-command registry.
|
||||
|
||||
The "Commands: ..." line in the `/help` handler is hand-maintained
|
||||
separately from the `COMMANDS` tuple in `command_registry.py`. This
|
||||
test catches drift — e.g. a new command added to the registry but
|
||||
forgotten in the help output.
|
||||
Help text is composed at runtime from `get_slash_commands()`, so drift is
|
||||
no longer a stale hard-coded name list — it is someone reintroducing a
|
||||
hand-maintained command string in `app.py`.
|
||||
"""
|
||||
|
||||
def test_help_body_lists_all_commands(self) -> None:
|
||||
"""Every command in COMMANDS must appear in the /help body."""
|
||||
def test_help_handler_builds_command_list_from_registry(self) -> None:
|
||||
"""`/help` must iterate `get_slash_commands()`, not a hard-coded list."""
|
||||
app_src = (
|
||||
Path(__file__).resolve().parents[2] / "deepagents_code" / "app.py"
|
||||
).read_text()
|
||||
|
||||
# Anchor on the `help_body = (` assignment so an unrelated "Commands:"
|
||||
# literal elsewhere in app.py (e.g. the /goal status display) can never
|
||||
# hijack the match. The assignment is the single source of the /help
|
||||
# body, so assert it is unique — if a second one appears, fail loudly
|
||||
# here rather than silently scraping the wrong block.
|
||||
anchors = re.findall(r"help_body = \(", app_src)
|
||||
assert len(anchors) == 1, (
|
||||
f"Expected exactly one `help_body = (` assignment in app.py, found "
|
||||
f"{len(anchors)}. Update this test's anchor if the /help body moved."
|
||||
)
|
||||
|
||||
# Isolate the /help "Commands: ..." section (before "Interactive Features").
|
||||
match = re.search(
|
||||
r'help_body = \(\s*"Commands:\s*(.*?)(?=Interactive Features)',
|
||||
app_src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "Could not locate Commands section in help_body"
|
||||
commands_section = match.group(1)
|
||||
|
||||
# Sentinel check: the captured section must contain known-present
|
||||
# canonical commands. If the lazy `.*?` ever mis-captures (matching the
|
||||
# wrong region or sweeping unrelated source), this fails with a clear
|
||||
# message instead of surfacing a garbage token like `/non-` from a
|
||||
# comment further down the file.
|
||||
for sentinel in ("/quit", "/help"):
|
||||
assert sentinel in commands_section, (
|
||||
f"Expected {sentinel} in the captured /help Commands section; "
|
||||
"the help_body anchor likely matched the wrong region."
|
||||
)
|
||||
|
||||
help_cmds = set(re.findall(r"/[a-z][-a-z]*", commands_section))
|
||||
registry_names = {cmd.name for cmd in COMMANDS}
|
||||
registry_aliases = {alias for cmd in COMMANDS for alias in cmd.aliases}
|
||||
|
||||
# Commands intentionally omitted from the help body
|
||||
excluded = {"/version"}
|
||||
|
||||
# /skill:<name> is dynamic, not a registry entry; regex extracts "/skill"
|
||||
help_cmds.discard("/skill")
|
||||
|
||||
# Canonical names must appear in help; aliases (e.g. `/criteria`, `/q`)
|
||||
# may also be advertised but are never required.
|
||||
missing = registry_names - help_cmds - excluded
|
||||
extra = help_cmds - registry_names - registry_aliases
|
||||
|
||||
assert not missing, (
|
||||
f"Commands in COMMANDS but missing from /help body: {missing}\n"
|
||||
"Add them to help_body in app.py _handle_command()."
|
||||
)
|
||||
assert not extra, (
|
||||
f"Commands in /help body but missing from COMMANDS: {extra}\n"
|
||||
"Remove them from help_body or add to COMMANDS in command_registry.py."
|
||||
help_handler = app_src.split('elif cmd == "/help":', 1)[1].split(
|
||||
"elif cmd in", 1
|
||||
)[0]
|
||||
assert "get_slash_commands()" in help_handler
|
||||
assert "for entry in get_slash_commands()" in help_handler
|
||||
|
||||
def test_help_command_line_includes_every_public_entry(self) -> None:
|
||||
"""The same join used by `/help` must list every public registry entry."""
|
||||
command_names = ", ".join(
|
||||
f"{entry.name} {entry.argument_hint}".rstrip()
|
||||
for entry in get_slash_commands()
|
||||
)
|
||||
help_line = f"Commands: {command_names}, /skill:<name>"
|
||||
for entry in get_slash_commands():
|
||||
assert entry.name in help_line
|
||||
|
||||
def test_help_body_describes_incognito_shell_prefix(self) -> None:
|
||||
"""The `/help` body should document local-only incognito shell mode."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from deepagents_code.main import cli_main
|
||||
from deepagents_code.main import _preload_session_mcp_server_info, cli_main
|
||||
|
||||
|
||||
def _make_acp_args(**overrides: object) -> argparse.Namespace:
|
||||
@@ -56,16 +56,26 @@ def test_acp_mode_loads_tools_and_mcp_and_runs_server() -> None:
|
||||
fetch_tool = object()
|
||||
thread_tool = object()
|
||||
search_tool = object()
|
||||
acp_project_root = object()
|
||||
acp_project_context = SimpleNamespace(
|
||||
project_root=acp_project_root,
|
||||
user_cwd=object(),
|
||||
)
|
||||
plugin_configs = ({"mcpServers": {"plugin": {}}},)
|
||||
|
||||
def _resolve_mcp_tools_with_bound_loop(
|
||||
*,
|
||||
explicit_config_path: str | None,
|
||||
no_mcp: bool,
|
||||
trust_project_mcp: bool | None,
|
||||
project_context: object | None,
|
||||
additional_configs: tuple[dict[str, object], ...],
|
||||
) -> tuple[list[object], object, list[SimpleNamespace]]:
|
||||
assert explicit_config_path is None
|
||||
assert not no_mcp
|
||||
assert trust_project_mcp is False
|
||||
assert project_context is acp_project_context
|
||||
assert additional_configs == plugin_configs
|
||||
nonlocal mcp_loop
|
||||
mcp_loop = asyncio.get_running_loop()
|
||||
return [mcp_tool], mcp_manager, mcp_server_info
|
||||
@@ -87,6 +97,14 @@ def test_acp_mode_loads_tools_and_mcp_and_runs_server() -> None:
|
||||
patch(
|
||||
"deepagents_code.config.create_model", return_value=model_result
|
||||
) as mock_create_model,
|
||||
patch(
|
||||
"deepagents_code.project_utils.ProjectContext.from_user_cwd",
|
||||
return_value=acp_project_context,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.plugins.adapters.mcp.discover_plugin_mcp_configs",
|
||||
return_value=plugin_configs,
|
||||
) as discover_plugin_mcp,
|
||||
patch(
|
||||
"deepagents_code.mcp_tools.resolve_and_load_mcp_tools", resolve_mcp_tools
|
||||
),
|
||||
@@ -114,7 +132,10 @@ def test_acp_mode_loads_tools_and_mcp_and_runs_server() -> None:
|
||||
explicit_config_path=None,
|
||||
no_mcp=False,
|
||||
trust_project_mcp=False,
|
||||
project_context=acp_project_context,
|
||||
additional_configs=plugin_configs,
|
||||
)
|
||||
discover_plugin_mcp.assert_called_once_with(project_dir=acp_project_root)
|
||||
model_result.apply_to_settings.assert_called_once_with()
|
||||
mock_create_agent.assert_called_once()
|
||||
call_kwargs = mock_create_agent.call_args.kwargs
|
||||
@@ -182,6 +203,46 @@ def test_acp_mode_omits_web_search_without_tavily() -> None:
|
||||
assert call_kwargs["checkpointer"] is not None
|
||||
|
||||
|
||||
def test_mcp_preload_includes_plugin_configs() -> None:
|
||||
"""The TUI metadata preload should include enabled plugin MCP servers."""
|
||||
project_root = object()
|
||||
project_context = SimpleNamespace(project_root=project_root, user_cwd=object())
|
||||
plugin_configs = ({"mcpServers": {"plugin": {}}},)
|
||||
session_manager = SimpleNamespace(cleanup=AsyncMock(return_value=None))
|
||||
server_info = [SimpleNamespace(name="plugin")]
|
||||
resolver = AsyncMock(return_value=([], session_manager, server_info))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.project_utils.ProjectContext.from_user_cwd",
|
||||
return_value=project_context,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.plugins.adapters.mcp.discover_plugin_mcp_configs",
|
||||
return_value=plugin_configs,
|
||||
) as discover_plugin_mcp,
|
||||
patch("deepagents_code.mcp_tools.resolve_and_load_mcp_tools", resolver),
|
||||
):
|
||||
result = asyncio.run(
|
||||
_preload_session_mcp_server_info(
|
||||
mcp_config_path=None,
|
||||
no_mcp=False,
|
||||
trust_project_mcp=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == server_info
|
||||
resolver.assert_awaited_once_with(
|
||||
explicit_config_path=None,
|
||||
no_mcp=False,
|
||||
trust_project_mcp=None,
|
||||
project_context=project_context,
|
||||
additional_configs=plugin_configs,
|
||||
)
|
||||
discover_plugin_mcp.assert_called_once_with(project_dir=project_root)
|
||||
session_manager.cleanup.assert_awaited_once_with()
|
||||
|
||||
|
||||
def test_non_acp_mode_checks_dependencies_before_parsing() -> None:
|
||||
"""Non-ACP invocations should still run dependency checks first."""
|
||||
with (
|
||||
|
||||
@@ -14,7 +14,7 @@ import pytest
|
||||
|
||||
from deepagents_code._session_stats import format_token_count
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.command_registry import SLASH_COMMANDS
|
||||
from deepagents_code.command_registry import get_slash_commands
|
||||
from deepagents_code.offload import _offload_fallback_root
|
||||
from deepagents_code.tui.widgets.messages import AppMessage, ErrorMessage
|
||||
|
||||
@@ -91,13 +91,13 @@ class TestOffloadInAutocomplete:
|
||||
"""Verify /offload is registered in the autocomplete system."""
|
||||
|
||||
def test_offload_in_slash_commands(self) -> None:
|
||||
"""The /offload command should be in the SLASH_COMMANDS list."""
|
||||
labels = [entry.name for entry in SLASH_COMMANDS]
|
||||
"""The /offload command should be in the get_slash_commands() list."""
|
||||
labels = [entry.name for entry in get_slash_commands()]
|
||||
assert "/offload" in labels
|
||||
|
||||
def test_offload_sorted_alphabetically(self) -> None:
|
||||
"""The /offload entry should appear between /model and /quit."""
|
||||
labels = [entry.name for entry in SLASH_COMMANDS]
|
||||
labels = [entry.name for entry in get_slash_commands()]
|
||||
model_idx = labels.index("/model")
|
||||
offload_idx = labels.index("/offload")
|
||||
quit_idx = labels.index("/quit")
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock
|
||||
import dotenv as _dotenv_module
|
||||
import pytest
|
||||
|
||||
from deepagents_code.command_registry import SLASH_COMMANDS
|
||||
from deepagents_code.command_registry import get_slash_commands
|
||||
from deepagents_code.config import Settings
|
||||
from deepagents_code.skills.load import ExtendedSkillMetadata
|
||||
|
||||
@@ -815,7 +815,7 @@ class TestReloadInAutocomplete:
|
||||
|
||||
def test_reload_in_slash_commands(self) -> None:
|
||||
"""`/reload` should be registered in slash command completions."""
|
||||
assert any(entry.name == "/reload" for entry in SLASH_COMMANDS)
|
||||
assert any(entry.name == "/reload" for entry in get_slash_commands())
|
||||
|
||||
|
||||
class TestReloadSkillReport:
|
||||
@@ -1033,3 +1033,66 @@ class TestReloadThemeReapply:
|
||||
)
|
||||
assert active == "langchain"
|
||||
assert "Switched theme to" not in text
|
||||
|
||||
|
||||
class TestReloadPluginsViaReload:
|
||||
"""Experimental plugins should reload through `/reload`."""
|
||||
|
||||
async def test_reports_plugin_summary_when_experimental(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""`/reload` includes a plugin summary when experimental mode is on."""
|
||||
from deepagents_code._env_vars import EXPERIMENTAL
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.plugins.models import PluginDiscoveryResult
|
||||
from deepagents_code.tui.widgets.messages import AppMessage
|
||||
|
||||
monkeypatch.setenv(EXPERIMENTAL, "1")
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
async def _fake_discover() -> bool: # noqa: RUF029
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(app, "_discover_skills", _fake_discover)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.plugins.discover_plugins",
|
||||
lambda: PluginDiscoveryResult(plugins=()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.plugins.adapters.mcp.plugin_mcp_configs",
|
||||
lambda _plugins: (),
|
||||
)
|
||||
|
||||
await app._handle_command("/reload")
|
||||
await pilot.pause()
|
||||
|
||||
text = "\n".join(str(w._content) for w in app.query(AppMessage))
|
||||
assert "Plugins: 0 plugins · 0 skills · 0 plugin MCP servers" in text
|
||||
|
||||
async def test_skips_plugin_summary_when_experimental_off(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""`/reload` omits plugin summary when experimental mode is off."""
|
||||
from deepagents_code._env_vars import EXPERIMENTAL
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.tui.widgets.messages import AppMessage
|
||||
|
||||
monkeypatch.delenv(EXPERIMENTAL, raising=False)
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
async def _fake_discover() -> bool: # noqa: RUF029
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(app, "_discover_skills", _fake_discover)
|
||||
|
||||
await app._handle_command("/reload")
|
||||
await pilot.pause()
|
||||
|
||||
text = "\n".join(str(w._content) for w in app.query(AppMessage))
|
||||
assert "Plugins:" not in text
|
||||
|
||||
@@ -370,6 +370,86 @@ class TestServerGraph:
|
||||
assert tools == [fetch_tool, thread_tool]
|
||||
assert mcp_server_info == []
|
||||
|
||||
async def test_build_tools_passes_project_dir_to_plugin_mcp_discovery(
|
||||
self,
|
||||
) -> None:
|
||||
"""Server graph discovery should preserve project substitution context.
|
||||
|
||||
Plugin discovery must run off the event loop: it creates per-plugin
|
||||
data dirs via `os.mkdir`, which `blockbuster` rejects on the loop.
|
||||
"""
|
||||
fetch_tool = object()
|
||||
thread_tool = object()
|
||||
project_root = object()
|
||||
project_context = SimpleNamespace(
|
||||
project_root=project_root,
|
||||
user_cwd=object(),
|
||||
)
|
||||
plugin_configs: tuple[dict[str, object], ...] = (
|
||||
{"mcpServers": {"plugin": {}}},
|
||||
)
|
||||
resolve_mcp_tools = AsyncMock(return_value=([], None, []))
|
||||
loop_thread_id = threading.get_ident()
|
||||
discover_thread_ids: list[int] = []
|
||||
|
||||
def discover_plugin_mcp_side_effect(
|
||||
*, project_dir: object | None = None
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
discover_thread_ids.append(threading.get_ident())
|
||||
assert project_dir is project_root
|
||||
return plugin_configs
|
||||
|
||||
config_module = _module_with_attrs(
|
||||
"deepagents_code.config",
|
||||
settings=SimpleNamespace(has_tavily=False),
|
||||
)
|
||||
tools_module = _module_with_attrs(
|
||||
"deepagents_code.tools",
|
||||
fetch_url=fetch_tool,
|
||||
get_current_thread_id=thread_tool,
|
||||
web_search=object(),
|
||||
)
|
||||
|
||||
class FakeSessionManager:
|
||||
pass
|
||||
|
||||
mcp_module = _module_with_attrs(
|
||||
"deepagents_code.mcp_tools",
|
||||
MCPSessionManager=FakeSessionManager,
|
||||
resolve_and_load_mcp_tools=resolve_mcp_tools,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"deepagents_code.config": config_module,
|
||||
"deepagents_code.tools": tools_module,
|
||||
"deepagents_code.mcp_tools": mcp_module,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.plugins.adapters.mcp.discover_plugin_mcp_configs",
|
||||
side_effect=discover_plugin_mcp_side_effect,
|
||||
) as discover_plugin_mcp,
|
||||
):
|
||||
module = _import_fresh_server_graph()
|
||||
tools, mcp_server_info = await module._build_tools(
|
||||
ServerConfig(no_mcp=False),
|
||||
project_context,
|
||||
)
|
||||
|
||||
assert tools == [fetch_tool, thread_tool]
|
||||
assert mcp_server_info == []
|
||||
discover_plugin_mcp.assert_called_once_with(project_dir=project_root)
|
||||
assert discover_thread_ids
|
||||
assert discover_thread_ids[0] != loop_thread_id
|
||||
resolve_mcp_tools.assert_awaited_once()
|
||||
await_args = resolve_mcp_tools.await_args
|
||||
assert await_args is not None
|
||||
assert await_args.kwargs["additional_configs"] == plugin_configs
|
||||
assert await_args.kwargs["project_context"] is project_context
|
||||
|
||||
|
||||
class TestStartupErrorMarker:
|
||||
"""`emit_startup_failure` must produce the parser marker on stderr.
|
||||
|
||||
@@ -223,6 +223,53 @@ class TestBuildSkillCommands:
|
||||
"""Verify the alias set only contains actual skill-backed commands."""
|
||||
assert {"remember", "skill-creator"} == _STATIC_SKILL_ALIASES
|
||||
|
||||
def test_plugin_skill_separates_machine_name_from_display(self) -> None:
|
||||
"""Plugin skills insert the namespaced name but show a short label."""
|
||||
skills = [
|
||||
{
|
||||
"name": "my-plugin@market:foo:review",
|
||||
"description": "Review code",
|
||||
"path": "/plugin/SKILL.md",
|
||||
"license": None,
|
||||
"compatibility": None,
|
||||
"metadata": {},
|
||||
"allowed_tools": [],
|
||||
"source": "plugin",
|
||||
}
|
||||
]
|
||||
result = build_skill_commands(skills) # ty: ignore
|
||||
assert len(result) == 1
|
||||
entry = result[0]
|
||||
# Machine name (matched + inserted) keeps the full namespace.
|
||||
assert entry.name == "/skill:my-plugin@market:foo:review"
|
||||
# Popup label is the short terminal segment.
|
||||
assert entry.label() == "/skill:review"
|
||||
# Description is tagged with the plugin id for source clarity.
|
||||
assert entry.description == "(my-plugin@market) Review code"
|
||||
# Both the full name and terminal segment are fuzzy-matchable.
|
||||
assert "review" in entry.hidden_keywords
|
||||
assert "my-plugin@market" in entry.hidden_keywords
|
||||
|
||||
def test_non_plugin_skill_label_equals_name(self) -> None:
|
||||
"""Non-plugin skills keep label == name (no display override)."""
|
||||
skills = [
|
||||
{
|
||||
"name": "web-research",
|
||||
"description": "Research topics",
|
||||
"path": "/user/SKILL.md",
|
||||
"license": None,
|
||||
"compatibility": None,
|
||||
"metadata": {},
|
||||
"allowed_tools": [],
|
||||
"source": "user",
|
||||
}
|
||||
]
|
||||
result = build_skill_commands(skills) # ty: ignore
|
||||
entry = result[0]
|
||||
assert entry.name == "/skill:web-research"
|
||||
assert entry.label() == "/skill:web-research"
|
||||
assert entry.description == "Research topics"
|
||||
|
||||
|
||||
class TestSkillCommandParsing:
|
||||
"""Test parse_skill_command() from command_registry."""
|
||||
|
||||
@@ -461,6 +461,40 @@ class TestLoadMcpServerInfo:
|
||||
assert result == server_info
|
||||
session_manager.cleanup.assert_awaited_once()
|
||||
|
||||
def test_passes_plugin_configs_and_project_dir(self) -> None:
|
||||
"""Tool catalog discovery should include project-scoped plugin MCP."""
|
||||
project_root = object()
|
||||
project_context = SimpleNamespace(
|
||||
project_root=project_root,
|
||||
user_cwd=object(),
|
||||
)
|
||||
plugin_configs = ({"mcpServers": {"plugin": {}}},)
|
||||
loader = AsyncMock(return_value=([], None, []))
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.project_utils.ProjectContext.from_user_cwd",
|
||||
return_value=project_context,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.plugins.adapters.mcp.discover_plugin_mcp_configs",
|
||||
return_value=plugin_configs,
|
||||
) as discover_plugin_mcp,
|
||||
patch("deepagents_code.mcp_tools.resolve_and_load_mcp_tools", new=loader),
|
||||
):
|
||||
result = asyncio.run(
|
||||
_load_mcp_server_info(mcp_config_path=None, trust_project_mcp=None)
|
||||
)
|
||||
|
||||
assert result == []
|
||||
loader.assert_awaited_once_with(
|
||||
explicit_config_path=None,
|
||||
no_mcp=False,
|
||||
trust_project_mcp=None,
|
||||
project_context=project_context,
|
||||
additional_configs=plugin_configs,
|
||||
)
|
||||
discover_plugin_mcp.assert_called_once_with(project_dir=project_root)
|
||||
|
||||
def test_cleanup_failure_is_swallowed(self) -> None:
|
||||
session_manager = AsyncMock()
|
||||
session_manager.cleanup.side_effect = RuntimeError("cleanup boom")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Modal UI tests."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Plugin manager modal tests."""
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for the plugin manager modal structure."""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from textual.widgets import Static
|
||||
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.tui.modals.plugin_manager import PluginManagerScreen
|
||||
from deepagents_code.tui.modals.plugin_manager.content import _plugin_options
|
||||
from deepagents_code.tui.modals.plugin_manager.models import _PluginRow
|
||||
|
||||
|
||||
def test_plugin_manager_css_is_colocated_with_screen() -> None:
|
||||
screen_file = Path(inspect.getfile(PluginManagerScreen))
|
||||
css_path = screen_file.parent / PluginManagerScreen.CSS_PATH
|
||||
assert PluginManagerScreen.CSS_PATH == "plugin_manager.tcss"
|
||||
assert css_path.is_file(), f"expected colocated CSS at {css_path}"
|
||||
|
||||
|
||||
def test_plugin_options_preserve_selectable_rows_and_spacers() -> None:
|
||||
rows = (
|
||||
_PluginRow("first@source", "First", False, None, None),
|
||||
_PluginRow("second@source", "Second", True, "1.0", "Author"),
|
||||
)
|
||||
|
||||
options = _plugin_options(rows, action="detail", status=None)
|
||||
|
||||
assert [option.id for option in options] == [
|
||||
"detail:first@source",
|
||||
"spacer:1",
|
||||
"detail:second@source",
|
||||
]
|
||||
assert options[1].disabled
|
||||
|
||||
|
||||
async def test_plugin_manager_overlays_underlying_content() -> None:
|
||||
"""Transparent modal backdrop must composite the screen underneath."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
app.theme = "textual-dark"
|
||||
await pilot.pause()
|
||||
|
||||
await app.mount(Static("TOP_MARKER_VISIBLE", id="top-marker"))
|
||||
marker = app.query_one("#top-marker")
|
||||
marker.styles.dock = "top"
|
||||
marker.styles.height = 1
|
||||
await pilot.pause()
|
||||
|
||||
app.push_screen(PluginManagerScreen())
|
||||
await pilot.pause()
|
||||
|
||||
assert app.screen.styles.background.a == 0
|
||||
plain = re.sub(r"<[^>]+>", " ", app.export_screenshot())
|
||||
assert "TOP_MARKER_VISIBLE" in plain
|
||||
assert "Plugins" in plain
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from deepagents_code.command_registry import SLASH_COMMANDS, CommandEntry
|
||||
from deepagents_code.command_registry import CommandEntry, get_slash_commands
|
||||
from deepagents_code.tui.widgets import autocomplete as autocomplete_module
|
||||
from deepagents_code.tui.widgets.autocomplete import (
|
||||
MAX_SUGGESTIONS,
|
||||
@@ -178,7 +178,7 @@ class TestSlashCommandController:
|
||||
@pytest.fixture
|
||||
def controller(self, mock_view):
|
||||
"""Create a SlashCommandController with mock view."""
|
||||
return SlashCommandController(SLASH_COMMANDS, mock_view)
|
||||
return SlashCommandController(get_slash_commands(), mock_view)
|
||||
|
||||
def test_can_handle_slash_prefix(self, controller):
|
||||
"""Handles text starting with /."""
|
||||
@@ -215,7 +215,7 @@ class TestSlashCommandController:
|
||||
|
||||
mock_view.render_completion_suggestions.assert_called()
|
||||
suggestions = mock_view.render_completion_suggestions.call_args[0][0]
|
||||
assert len(suggestions) == min(len(SLASH_COMMANDS), MAX_SUGGESTIONS)
|
||||
assert len(suggestions) == min(len(get_slash_commands()), MAX_SUGGESTIONS)
|
||||
|
||||
def test_clears_on_no_match(self, controller, mock_view):
|
||||
"""Clears suggestions when no commands match after having suggestions."""
|
||||
@@ -246,7 +246,7 @@ class TestSlashCommandController:
|
||||
controller.on_text_changed("/", 1)
|
||||
mock_view.render_completion_suggestions.assert_called()
|
||||
suggestions = mock_view.render_completion_suggestions.call_args[0][0]
|
||||
assert len(suggestions) == min(len(SLASH_COMMANDS), MAX_SUGGESTIONS)
|
||||
assert len(suggestions) == min(len(get_slash_commands()), MAX_SUGGESTIONS)
|
||||
|
||||
def test_hidden_keyword_match_continue(self, controller, mock_view):
|
||||
"""Typing 'continue' surfaces /threads via hidden keyword."""
|
||||
@@ -288,6 +288,19 @@ class TestSlashCommandController:
|
||||
suggestions = mock_view.render_completion_suggestions.call_args[0][0]
|
||||
assert any("/help" in s[0] for s in suggestions)
|
||||
|
||||
def test_prefix_ties_follow_registry_order_for_re_commands(self, mock_view) -> None:
|
||||
"""Equal-score prefixes keep registry order (`re`/`rel` disambiguation)."""
|
||||
controller = SlashCommandController(get_slash_commands(), mock_view)
|
||||
assert any(entry.name == "/reload" for entry in controller._commands)
|
||||
|
||||
controller.on_text_changed("/re", 3)
|
||||
suggestions = mock_view.render_completion_suggestions.call_args[0][0]
|
||||
assert suggestions[0][0].startswith("/remember")
|
||||
|
||||
controller.on_text_changed("/rel", 4)
|
||||
suggestions = mock_view.render_completion_suggestions.call_args[0][0]
|
||||
assert suggestions[0][0].startswith("/reload")
|
||||
|
||||
def test_prefix_match_ranks_first(self, controller, mock_view):
|
||||
"""Prefix matches on command name rank above description matches."""
|
||||
controller.on_text_changed("/he", 3)
|
||||
@@ -491,7 +504,7 @@ class TestMultiCompletionManager:
|
||||
@pytest.fixture
|
||||
def manager(self, mock_view, tmp_path):
|
||||
"""Create a MultiCompletionManager with both controllers."""
|
||||
slash_ctrl = SlashCommandController(SLASH_COMMANDS, mock_view)
|
||||
slash_ctrl = SlashCommandController(get_slash_commands(), mock_view)
|
||||
file_ctrl = FuzzyFileController(mock_view, cwd=tmp_path)
|
||||
# Cast needed: lists are invariant, so the inferred type
|
||||
# list[SlashCommandController | FuzzyFileController] won't match
|
||||
@@ -602,6 +615,59 @@ class TestSlashCommandControllerUpdateCommands:
|
||||
assert any("/skill:code-review" in s[0] for s in suggestions)
|
||||
|
||||
|
||||
class TestSlashCommandControllerDisplaySeparation:
|
||||
"""Popup shows the label but completion inserts the machine name."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_view(self) -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, mock_view: MagicMock) -> SlashCommandController:
|
||||
commands = [
|
||||
CommandEntry(
|
||||
name="/skill:my-plugin:review",
|
||||
description="(my-plugin) Review code",
|
||||
hidden_keywords="my-plugin review",
|
||||
argument_hint="",
|
||||
display_name="/skill:review",
|
||||
),
|
||||
]
|
||||
return SlashCommandController(commands, mock_view)
|
||||
|
||||
def test_popup_shows_short_label(
|
||||
self, controller: SlashCommandController, mock_view: MagicMock
|
||||
) -> None:
|
||||
"""The suggestion popup renders the short display label."""
|
||||
controller.on_text_changed("/skill:rev", 10)
|
||||
|
||||
mock_view.render_completion_suggestions.assert_called()
|
||||
suggestions = mock_view.render_completion_suggestions.call_args[0][0]
|
||||
assert suggestions[0][0] == "/skill:review"
|
||||
|
||||
def test_completion_inserts_machine_name(
|
||||
self, controller: SlashCommandController, mock_view: MagicMock
|
||||
) -> None:
|
||||
"""Applying the completion inserts the full namespaced name."""
|
||||
controller.on_text_changed("/skill:rev", 10)
|
||||
applied = controller._apply_selected_completion(10)
|
||||
|
||||
assert applied is True
|
||||
mock_view.replace_completion_range.assert_called_once_with(
|
||||
0, 10, "/skill:my-plugin:review"
|
||||
)
|
||||
|
||||
def test_terminal_segment_fuzzy_matches_plugin_skill(
|
||||
self, controller: SlashCommandController, mock_view: MagicMock
|
||||
) -> None:
|
||||
"""Typing just the terminal segment surfaces the plugin skill."""
|
||||
controller.on_text_changed("/review", 7)
|
||||
|
||||
mock_view.render_completion_suggestions.assert_called()
|
||||
suggestions = mock_view.render_completion_suggestions.call_args[0][0]
|
||||
assert suggestions[0][0] == "/skill:review"
|
||||
|
||||
|
||||
class TestFuzzyFileControllerSetCwd:
|
||||
"""Tests for FuzzyFileController.set_cwd switching completion roots."""
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from textual.widgets import Static
|
||||
from textual.widgets.text_area import Selection
|
||||
|
||||
from deepagents_code import _textual_patches as _textual_patches
|
||||
from deepagents_code.command_registry import SLASH_COMMANDS
|
||||
from deepagents_code.command_registry import get_slash_commands
|
||||
from deepagents_code.input import MediaTracker
|
||||
from deepagents_code.media_utils import ImageData, create_multimodal_content
|
||||
from deepagents_code.tui.widgets import (
|
||||
@@ -1711,7 +1711,7 @@ class TestDismissCompletion:
|
||||
|
||||
# Menu should reappear with all commands
|
||||
assert len(chat._current_suggestions) == min(
|
||||
len(SLASH_COMMANDS), MAX_SUGGESTIONS
|
||||
len(get_slash_commands()), MAX_SUGGESTIONS
|
||||
)
|
||||
assert popup.styles.display == "block"
|
||||
|
||||
@@ -2598,6 +2598,7 @@ class TestSlashCompletionCursorMapping:
|
||||
|
||||
chat._text_area.insert("/")
|
||||
await _pause_for_strip(pilot)
|
||||
# Shared prefix `re`: shorter `/remember` ranks above `/reload`.
|
||||
chat._text_area.insert("re")
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user