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:
Johannes du Plessis
2026-07-15 06:11:28 -07:00
committed by GitHub
parent 9a6789ac19
commit 0187d14b83
56 changed files with 7241 additions and 289 deletions
+2 -1
View File
@@ -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 |
+9
View File
@@ -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."
+33 -31
View File
@@ -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,
)
)
+95 -14
View File
@@ -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(
+96 -16
View File
@@ -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."""
+61
View File
@@ -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,
+5
View File
@@ -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)
+227
View File
@@ -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
+555
View File
@@ -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
+13
View File
@@ -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 -18
View File
@@ -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(
+24
View File
@@ -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"
]
+29 -105
View File
@@ -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",
+20
View File
@@ -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:
+31
View File
@@ -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 (
+4 -4
View File
@@ -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")
+65 -2
View File
@@ -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()