Files
deepagents/libs/code/deepagents_code/command_registry.py
T
Johannes du Plessis 0187d14b83 feat(code): add plugin marketplace support (#4554)
`deepagents-code` now supports experimental plugin marketplaces with
namespaced skills and MCP servers. Enable with
`DEEPAGENTS_CODE_EXPERIMENTAL=1`.

Docs: https://github.com/langchain-ai/docs/pull/4905

---

`deepagents-code` can now manage experimental plugin marketplaces and
load installed plugins that contribute namespaced skills and MCP
servers. The new plugin manager establishes the `tui/modals`,
`tui/screens`, and `tui/widgets` UI convention, with colocated styling
and focused state/content modules.

- Supports local, GitHub, Git, and marketplace JSON sources.
- Adds install, enable, disable, uninstall, and safe marketplace removal
through `/plugins` and `dcode plugin`.
- Reloads plugin skills and plugin MCP configuration with `/reload` when
experimental mode is enabled.
- Namespaces plugin skills as `{plugin_id}:{skill_name}` through a
code-local `PluginSkillsMiddleware` adapter, so no SDK changes are
required.

Made by [Open
SWE](https://openswe.vercel.app/agents/019f5dea-0f1f-73fe-9803-32d1b13fbd4c)

---------

Signed-off-by: Johannes du Plessis <johannes@langchain.dev>
Co-authored-by: Alexander Olsen <aolsenjazz@gmail.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mason Daugherty <mason@langchain.dev>
Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
2026-07-15 09:11:28 -04:00

520 lines
18 KiB
Python

"""Unified slash-command registry.
Every slash command is declared once as a `SlashCommand` entry in `COMMANDS`.
Bypass-tier frozensets and autocomplete entries are derived automatically — no
other file should hard-code command metadata.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
from deepagents_code.skills.load import ExtendedSkillMetadata
class BypassTier(StrEnum):
"""Classification that controls whether a command can skip the message queue."""
ALWAYS = "always"
"""Execute regardless of any busy state, including mid-thread-switch."""
CONNECTING = "connecting"
"""Bypass only during initial server connection, not during agent/shell."""
IMMEDIATE_UI = "immediate_ui"
"""Open modal UI immediately; real work deferred via `_defer_action` callback."""
SIDE_EFFECT_FREE = "side_effect_free"
"""Execute the side effect immediately; defer chat output until idle."""
QUEUED = "queued"
"""Must wait in the queue when the app is busy."""
@dataclass(frozen=True, slots=True, kw_only=True)
class SlashCommand:
"""A single slash-command definition."""
name: str
"""Canonical command name (e.g. `/quit`)."""
description: str
"""Short user-facing description."""
bypass_tier: BypassTier
"""Queue-bypass classification."""
hidden_keywords: str = ""
"""Space-separated terms for fuzzy matching (never displayed)."""
argument_hint: str = ""
"""Placeholder text for autocomplete when the command accepts args."""
aliases: tuple[str, ...] = ()
"""Alternative names (e.g. `("/q",)` for `/quit`)."""
def to_entry(self) -> CommandEntry:
"""Project this command into a `CommandEntry` for autocomplete.
Returns:
A `CommandEntry` carrying only the fields the autocomplete
layer needs.
"""
return CommandEntry(
name=self.name,
description=self.description,
hidden_keywords=self.hidden_keywords,
argument_hint=self.argument_hint,
)
COMMANDS: tuple[SlashCommand, ...] = (
SlashCommand(
name="/agents",
description="Browse and switch between available agents",
bypass_tier=BypassTier.IMMEDIATE_UI,
hidden_keywords="switch profile persona",
),
SlashCommand(
name="/auth",
description="Connect and manage provider and service credentials",
bypass_tier=BypassTier.IMMEDIATE_UI,
hidden_keywords=(
"key keys credential credentials login token api tracing langsmith"
),
aliases=("/connect",),
),
SlashCommand(
name="/clear",
description="Clear the chat and start a new thread",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="reset",
),
SlashCommand(
name="/copy",
description="Copy the latest assistant message to clipboard",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
),
SlashCommand(
name="/force-clear",
description="Stop active work, clear the chat, and start a new thread",
bypass_tier=BypassTier.ALWAYS,
hidden_keywords="reset interrupt",
),
SlashCommand(
name="/goal",
description="Set and manage a persistent objective with acceptance criteria",
bypass_tier=BypassTier.QUEUED,
hidden_keywords=(
"objective criteria acceptance amend pause resume rubric grader grading "
"model iterations"
),
argument_hint=(
"[<objective>|amend <feedback>|pause|resume|show|clear|model|"
"max-iterations]"
),
),
SlashCommand(
name="/editor",
description="Open prompt in an external editor ($EDITOR)",
bypass_tier=BypassTier.QUEUED,
),
SlashCommand(
name="/effort",
description="Set reasoning effort for the current model",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="reasoning thinking level",
argument_hint="[none|low|medium|high|xhigh|max|clear]",
),
SlashCommand(
name="/mcp",
description="Manage MCP servers and authentication",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
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",
bypass_tier=BypassTier.IMMEDIATE_UI,
),
SlashCommand(
name="/notifications",
description="Configure startup warnings",
bypass_tier=BypassTier.IMMEDIATE_UI,
hidden_keywords="warnings alerts suppress",
),
SlashCommand(
name="/offload",
description="Summarize and offload older messages to free context",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="compact",
aliases=("/compact",),
),
SlashCommand( # Static alias; not auto-generated from skill discovery
name="/remember",
description="Save useful context to memory or skills",
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",
bypass_tier=BypassTier.QUEUED,
argument_hint="[task]",
),
SlashCommand(
name="/threads",
description="Browse and resume past threads",
bypass_tier=BypassTier.IMMEDIATE_UI,
hidden_keywords="continue history sessions resume back previous",
argument_hint="[-r [ID]]",
),
SlashCommand(
name="/trace",
description="Open this thread in LangSmith",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
),
SlashCommand(
name="/tokens",
description="Show token usage",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="cost",
),
SlashCommand(
name="/tools",
description="List the tools available to the agent",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="mcp functions capabilities builtin built-in",
),
SlashCommand(
name="/rubric",
description="Set explicit acceptance criteria for rubric grading",
bypass_tier=BypassTier.IMMEDIATE_UI,
hidden_keywords="criteria acceptance grader grading evaluation iterations",
argument_hint="[set|next|file|show|clear|model|max-iterations]",
aliases=("/criteria",),
),
SlashCommand(
name="/restart",
description="Restart the agent server",
bypass_tier=BypassTier.ALWAYS,
hidden_keywords="respawn server",
),
SlashCommand(
name="/theme",
description="Change color theme",
bypass_tier=BypassTier.IMMEDIATE_UI,
hidden_keywords="dark light color appearance",
),
SlashCommand(
name="/scrollbar",
description="Show or hide the chat scrollbar",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
hidden_keywords="scroll scroller bar vertical",
),
SlashCommand(
name="/timestamps",
description="Show or hide message timestamps",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
hidden_keywords="time footer footers date dates",
),
SlashCommand(
name="/update",
description="Check for and install updates",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="upgrade dependencies deps refresh",
argument_hint="[--deps] [--prerelease]",
),
SlashCommand(
name="/install",
description="Install an optional integration",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="extra extras add provider sandbox dependency",
argument_hint="<extra> [--force]",
),
SlashCommand(
name="/auto-update",
description="Turn automatic updates on or off",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
),
SlashCommand(
name="/changelog",
description="Open the changelog in a browser",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
),
SlashCommand(
name="/version",
description="Show version information",
bypass_tier=BypassTier.CONNECTING,
aliases=("/about",),
),
SlashCommand(
name="/feedback",
description="Send feedback or report an issue",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
),
SlashCommand(
name="/docs",
description="Open the docs",
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
),
SlashCommand(
name="/help",
description="Show help and available commands",
bypass_tier=BypassTier.QUEUED,
),
SlashCommand(
name="/quit",
description="Exit app",
bypass_tier=BypassTier.ALWAYS,
hidden_keywords="close leave",
aliases=("/q",),
),
)
"""All slash commands."""
# ---------------------------------------------------------------------------
# Derived bypass-tier frozensets
# ---------------------------------------------------------------------------
def _build_bypass_set(tier: BypassTier) -> frozenset[str]:
"""Build a frozenset of command names (including aliases) for a tier.
Args:
tier: The bypass tier to collect.
Returns:
Frozenset of all names and aliases that belong to `tier`.
"""
names: set[str] = set()
for cmd in COMMANDS:
if cmd.bypass_tier == tier:
names.add(cmd.name)
names.update(cmd.aliases)
return frozenset(names)
ALWAYS_IMMEDIATE: frozenset[str] = _build_bypass_set(BypassTier.ALWAYS)
"""Commands that execute regardless of any busy state."""
BYPASS_WHEN_CONNECTING: frozenset[str] = _build_bypass_set(BypassTier.CONNECTING)
"""Commands that bypass only during initial server connection."""
IMMEDIATE_UI: frozenset[str] = _build_bypass_set(BypassTier.IMMEDIATE_UI)
"""Commands that open modal UI immediately, deferring real work."""
SIDE_EFFECT_FREE: frozenset[str] = _build_bypass_set(BypassTier.SIDE_EFFECT_FREE)
"""Commands whose side effect fires immediately; chat output deferred until idle."""
QUEUE_BOUND: frozenset[str] = _build_bypass_set(BypassTier.QUEUED)
"""Commands that must wait in the queue when the app is busy."""
HIDDEN_COMMANDS: frozenset[str] = frozenset({"/debug", "/debug-error"})
"""Power-user commands kept out of autocomplete and help."""
STARTUP_RECOVERY_COMMANDS: frozenset[str] = frozenset(
{"/install", "/reload", "/update"}
)
"""`QUEUED`-tier commands that must still run when startup has failed.
When the configured model can't be built (e.g. its provider package is
missing) the server never starts and the app holds a `_server_startup_error`
state that parks queued messages. These are the recovery escape hatches for
that state — install the missing package, reload config/env, or upgrade the
tool — so they must bypass the queue rather than sit behind the very failure
they repair. `/model` and `/auth` already escape via `IMMEDIATE_UI` (which
opens a modal and defers the real work); the commands here instead perform
their repair work directly, so they stay `QUEUED` and rely on this exemption.
The bypass itself is gated in `_can_bypass_queue`. Every entry is also
`QUEUE_BOUND` — the recovery exemption is orthogonal to the normal queue.
"""
ALL_CLASSIFIED: frozenset[str] = (
ALWAYS_IMMEDIATE
| BYPASS_WHEN_CONNECTING
| IMMEDIATE_UI
| SIDE_EFFECT_FREE
| QUEUE_BOUND
| HIDDEN_COMMANDS
)
"""Union of all tiers plus hidden commands — used by drift tests."""
# ---------------------------------------------------------------------------
# Autocomplete entries
# ---------------------------------------------------------------------------
class CommandEntry(NamedTuple):
"""A single autocomplete entry for the slash-command controller."""
name: str
"""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."""
hidden_keywords: str
"""Space-separated terms for fuzzy matching (never displayed)."""
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.
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]:
"""Extract skill name and args from a `/skill:<name>` command.
Args:
command: The full command string (e.g., `/skill:web-research find X`).
Returns:
Tuple of `(skill_name, args)`.
The skill name is normalized to lowercase. Both are empty strings
when the command has no skill name after the prefix.
"""
after_prefix = command[len("/skill:") :].strip()
parts = after_prefix.split(maxsplit=1)
if not parts or not parts[0]:
return "", ""
skill_name = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
return skill_name, args
_STATIC_SKILL_ALIASES: frozenset[str] = frozenset({"remember", "skill-creator"})
"""Built-in skill names that have a dedicated top-level slash command.
Only list skills whose `/skill:<name>` form is redundant because a `/<name>`
convenience alias exists in `COMMANDS`. Do **not** add every command name
here — that would silently suppress unrelated user skills that happen to share a
name with a slash command (e.g., a user skill called `model` should still
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. 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
autocomplete entries.
Args:
skills: List of discovered skill metadata.
Returns:
List of `CommandEntry` instances.
"""
return [
_skill_command_entry(skill)
for skill in skills
if skill["name"] not in _STATIC_SKILL_ALIASES
]