core/cli: plugins list/enable/disable commands and /plugins slash

This commit is contained in:
2026-07-24 16:44:44 +08:00
parent 1d718582ac
commit 03e04f9a38
9 changed files with 588 additions and 9 deletions
+4
View File
@@ -101,6 +101,10 @@ plyngent chat -p "Summarize this repo" --provider oai --model gpt-5.4-mini --no-
# 3) List providers from config # 3) List providers from config
plyngent providers plyngent providers
# 4) Plugins (entry-point allowlist under [plugins])
plyngent plugins list
plyngent plugins enable acme
``` ```
In the REPL: type normally, use `/help` for slash commands, `"""``"""` for multiline, `/markdown` for Rich rendering, `/quit` to leave. In the REPL: type normally, use `/help` for slash commands, `"""``"""` for multiline, `/markdown` for Rich rendering, `/quit` to leave.
+26 -7
View File
@@ -121,7 +121,32 @@ enable = ["acme"]
`enable` / `disable` are **plugin entry-point names** (e.g. `acme`), not individual `enable` / `disable` are **plugin entry-point names** (e.g. `acme`), not individual
tool function names. tool function names.
Restart the chat REPL after changing config so the tool registry rebuilds. ### CLI management
```bash
# Installed entry points + enable/disable status
plyngent plugins list
plyngent plugins list --config /path/to/plyngent.toml
# Allowlist / block (writes [plugins] and saves the file)
plyngent plugins enable acme
plyngent plugins enable '*' # load all discovered
plyngent plugins disable legacy
plyngent plugins undeny legacy # drop disable only
plyngent plugins clear --yes
```
In the chat REPL (writes config and rebuilds the tool registry when tools are on):
```text
/plugins # same as /plugins list
/plugins enable acme
/plugins disable legacy
/plugins undeny legacy
/plugins reload # re-read config + rebuild tools
/plugins clear
/tools --list # model-visible tools + catalog source
```
CLI load order (simplified): CLI load order (simplified):
@@ -129,12 +154,6 @@ CLI load order (simplified):
2. `load_plugin_tools(plugins.enable, disable=plugins.disable)` 2. `load_plugin_tools(plugins.enable, disable=plugins.disable)`
3. `catalog.select(surface="local")``ToolRegistry` 3. `catalog.select(surface="local")``ToolRegistry`
Inspect the model surface in the REPL:
```text
/tools --list
```
Listed tools show **tags** and **catalog source** (`builtin` vs `plugin:…`). Listed tools show **tags** and **catalog source** (`builtin` vs `plugin:…`).
## Policy checklist for authors ## Policy checklist for authors
+159 -2
View File
@@ -35,9 +35,18 @@ if TYPE_CHECKING:
_DEFAULT_DB_FILENAME = "chat.db" _DEFAULT_DB_FILENAME = "chat.db"
def _load_config(config_path: Path | None) -> ConfigStore: def _load_config(config_path: Path | None, *, require_providers: bool = True) -> ConfigStore:
"""Load config; optionally skip the interactive “no providers” recovery path.
Plugin management only needs a valid TOML file (and will create one if
missing via :func:`load_config_with_optional_edit` when providers are required
for chat). For plugins, use ``require_providers=False``.
"""
try: try:
return load_config_with_optional_edit(config_path) if require_providers:
return load_config_with_optional_edit(config_path)
path = resolve_config_path(config_path)
return config_mod.load(path)
except config_mod.ConfigFormatError as exc: except config_mod.ConfigFormatError as exc:
path = resolve_config_path(config_path) path = resolve_config_path(config_path)
msg = f"invalid config TOML ({path}): {exc}" msg = f"invalid config TOML ({path}): {exc}"
@@ -501,6 +510,154 @@ def providers_cmd(config_path: Path | None) -> None:
_warn_bad_providers(store.bad_providers) _warn_bad_providers(store.bad_providers)
def print_plugins_table(store: ConfigStore) -> None:
"""Print discovered plugins with allowlist status (CLI + slash)."""
from plyngent.tools.plugins import list_plugin_statuses
cfg = store.plugins_config
click.echo(f"config={store.path}")
enable_s = ", ".join(cfg.enable) if cfg.enable else "(none)"
disable_s = ", ".join(cfg.disable) if cfg.disable else "(none)"
click.echo(f"enable=[{enable_s}] disable=[{disable_s}]")
rows = list_plugin_statuses(enable=cfg.enable, disable=cfg.disable)
if not rows:
click.echo("(no plyngent.tools entry points installed)")
return
click.echo("id\tstatus\tpackage\tvalue")
for row in rows:
if row.disabled:
status = "disabled"
elif row.enabled:
status = "enabled"
else:
status = "off"
pkg = row.plugin.package or "-"
if row.plugin.version:
pkg = f"{pkg}@{row.plugin.version}"
click.echo(f"{row.plugin.id}\t{status}\t{pkg}\t{row.plugin.value}")
@main.group("plugins")
def plugins_group() -> None:
"""Manage third-party plugins (``[plugins]`` allowlist in config)."""
@plugins_group.command("list")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_list_cmd(config_path: Path | None) -> None:
"""List installed ``plyngent.tools`` entry points and enable/disable status."""
store = _load_config(config_path, require_providers=False)
print_plugins_table(store)
@plugins_group.command("enable")
@click.argument("name")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_enable_cmd(name: str, config_path: Path | None) -> None:
"""Allowlist a plugin entry-point name (or ``*`` for all) and write config.
Removes the name from ``[plugins].disable`` if present. Takes effect for
new chat sessions (registry is built at chat start).
"""
store = _load_config(config_path, require_providers=False)
try:
_ = store.enable_plugin(name)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
store.write()
click.echo(f"enabled {name.strip()!r} in {store.path}")
enable_s = ", ".join(store.plugins_config.enable) or "(none)"
disable_s = ", ".join(store.plugins_config.disable) or "(none)"
click.echo(f"enable=[{enable_s}] disable=[{disable_s}]")
@plugins_group.command("disable")
@click.argument("name")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_disable_cmd(name: str, config_path: Path | None) -> None:
"""Block a plugin via ``[plugins].disable`` and write config.
Disable always wins over enable / ``*``. Use ``plugins undeny`` to drop the
block without adding the plugin to enable.
"""
store = _load_config(config_path, require_providers=False)
try:
_ = store.disable_plugin(name)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
store.write()
click.echo(f"disabled {name.strip()!r} in {store.path}")
enable_s = ", ".join(store.plugins_config.enable) or "(none)"
disable_s = ", ".join(store.plugins_config.disable) or "(none)"
click.echo(f"enable=[{enable_s}] disable=[{disable_s}]")
@plugins_group.command("undeny")
@click.argument("name")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
def plugins_undeny_cmd(name: str, config_path: Path | None) -> None:
"""Remove *name* from ``[plugins].disable`` only (does not enable it)."""
store = _load_config(config_path, require_providers=False)
try:
_ = store.undeny_plugin(name)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
store.write()
click.echo(f"undenied {name.strip()!r} in {store.path}")
disable_s = ", ".join(store.plugins_config.disable) or "(none)"
click.echo(f"disable=[{disable_s}]")
@plugins_group.command("clear")
@click.option(
"--config",
"config_path",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Path to plyngent.toml.",
)
@click.option(
"--yes",
"confirm_yes",
is_flag=True,
default=False,
help="Skip confirmation prompt.",
)
def plugins_clear_cmd(config_path: Path | None, *, confirm_yes: bool) -> None:
"""Clear ``[plugins].enable`` and ``disable`` (load no plugins)."""
store = _load_config(config_path, require_providers=False)
needs_confirm = not confirm_yes and (store.plugins_config.enable or store.plugins_config.disable)
if needs_confirm and not click.confirm("Clear all plugin enable/disable entries?", default=False):
raise click.Abort
_ = store.clear_plugins()
store.write()
click.echo(f"cleared plugins lists in {store.path}")
@main.group("config") @main.group("config")
def config_group() -> None: def config_group() -> None:
"""Manage plyngent configuration.""" """Manage plyngent configuration."""
+71
View File
@@ -972,6 +972,77 @@ def tools_cmd(
click.echo(f"tools={'on' if enabled else 'off'}") click.echo(f"tools={'on' if enabled else 'off'}")
def _slash_rebuild_tools_if_on(state: ReplState) -> None:
if state.tools_enabled:
state.rebuild_client()
def _slash_apply_plugin_action(state: ReplState, act: str, token: str) -> None:
if act == "enable":
_ = state.config.enable_plugin(token)
elif act == "disable":
_ = state.config.disable_plugin(token)
elif act == "undeny":
_ = state.config.undeny_plugin(token)
else:
msg = f"unknown plugins action {act!r}"
raise click.UsageError(msg)
state.config.write()
_slash_rebuild_tools_if_on(state)
cfg = state.config.plugins_config
click.echo(f"plugins {act} {token!r} enable={list(cfg.enable)!r} disable={list(cfg.disable)!r}")
@slash.command("plugins")
@click.argument(
"action",
required=False,
type=click.Choice(["list", "enable", "disable", "undeny", "reload", "clear"]),
)
@click.argument("name", required=False)
@click.pass_obj
def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) -> None:
"""List or change plugin allowlist; reload tools into this session.
``/plugins`` or ``/plugins list`` — installed entry points + config status.
``/plugins enable NAME`` / ``disable NAME`` / ``undeny NAME`` — update
config on disk, then rebuild the tool registry for this session.
``/plugins reload`` — re-read config and rebuild tools without list changes.
``/plugins clear`` — empty enable/disable lists and rebuild.
"""
act = (action or "list").lower()
if act == "list":
from plyngent.cli.app import print_plugins_table
print_plugins_table(state.config)
return
if act == "reload":
state.config.reload()
_slash_rebuild_tools_if_on(state)
cfg = state.config.plugins_config
click.echo(
f"reloaded plugins enable={list(cfg.enable)!r} disable={list(cfg.disable)!r} "
f"tools={'on' if state.tools_enabled else 'off'}"
)
return
if act == "clear":
_ = state.config.clear_plugins()
state.config.write()
_slash_rebuild_tools_if_on(state)
if state.tools_enabled:
click.echo("cleared plugins enable/disable; tools registry rebuilt")
else:
click.echo("cleared plugins enable/disable")
return
if name is None or not name.strip():
msg = f"/plugins {act} requires a plugin entry-point name"
raise click.UsageError(msg)
try:
_slash_apply_plugin_action(state, act, name.strip())
except ValueError as exc:
raise click.UsageError(str(exc)) from exc
@slash.command("yolo") @slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]") @click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj @click.pass_obj
+68
View File
@@ -158,6 +158,74 @@ class ConfigStore:
"""Typed plugin allowlist (enable / disable entry-point names).""" """Typed plugin allowlist (enable / disable entry-point names)."""
return self._plugins return self._plugins
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()]
self._plugins = msgspec.structs.replace(self._plugins, enable=cleaned)
return self._plugins
def set_plugins_disable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin disable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()]
self._plugins = msgspec.structs.replace(self._plugins, disable=cleaned)
return self._plugins
def enable_plugin(self, name: str) -> PluginsConfig:
"""Allowlist *name* and remove it from the disable list.
Special case: ``name == "*"`` sets enable to ``["*"]`` (load all).
"""
token = name.strip()
if not token:
msg = "plugin name must not be empty"
raise ValueError(msg)
disable = [n for n in self._plugins.disable if n.strip() and n.strip() != token]
if token == "*":
self._plugins = msgspec.structs.replace(self._plugins, enable=["*"], disable=disable)
return self._plugins
enable = list(self._plugins.enable)
if any(item.strip() == "*" for item in enable):
# Already load-all; only ensure not disabled.
self._plugins = msgspec.structs.replace(self._plugins, disable=disable)
return self._plugins
if token not in enable:
enable.append(token)
self._plugins = msgspec.structs.replace(self._plugins, enable=enable, disable=disable)
return self._plugins
def disable_plugin(self, name: str) -> PluginsConfig:
"""Block *name* via the disable list (wins over enable / ``*``).
Also removes *name* from a non-``*`` enable list so config stays tidy.
"""
token = name.strip()
if not token or token == "*":
msg = "disable requires a concrete plugin entry-point name"
raise ValueError(msg)
enable = list(self._plugins.enable)
if not any(item.strip() == "*" for item in enable) and token in enable:
enable = [n for n in enable if n.strip() != token]
disable = list(self._plugins.disable)
if token not in disable:
disable.append(token)
self._plugins = msgspec.structs.replace(self._plugins, enable=enable, disable=disable)
return self._plugins
def undeny_plugin(self, name: str) -> PluginsConfig:
"""Remove *name* from the disable list only (does not add to enable)."""
token = name.strip()
if not token:
msg = "plugin name must not be empty"
raise ValueError(msg)
disable = [n for n in self._plugins.disable if n.strip() != token]
self._plugins = msgspec.structs.replace(self._plugins, disable=disable)
return self._plugins
def clear_plugins(self) -> PluginsConfig:
"""Clear enable and disable lists (load no plugins)."""
self._plugins = PluginsConfig()
return self._plugins
# -- providers (read/write) -- # -- providers (read/write) --
@property @property
+85
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import importlib.metadata import importlib.metadata
from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from plyngent.tools.catalog import ToolSource, get_catalog, registration_source from plyngent.tools.catalog import ToolSource, get_catalog, registration_source
@@ -22,6 +23,90 @@ def _iter_entry_points() -> list[importlib.metadata.EntryPoint]:
return list(selected) return list(selected)
@dataclass(frozen=True, slots=True)
class DiscoveredPlugin:
"""One installed ``plyngent.tools`` entry point (not necessarily enabled)."""
id: str
value: str
package: str | None = None
version: str | None = None
@property
def module(self) -> str:
return self.value.split(":", 1)[0] if ":" in self.value else self.value
@dataclass(frozen=True, slots=True)
class PluginStatus:
"""Discovery + allowlist status for CLI listing."""
plugin: DiscoveredPlugin
enabled: bool
disabled: bool
@property
def will_load(self) -> bool:
"""True if current config would load this plugin."""
return self.enabled and not self.disabled
def list_discovered_plugins() -> list[DiscoveredPlugin]:
"""Return installed entry points for :data:`ENTRY_POINT_GROUP`, sorted by id."""
found: list[DiscoveredPlugin] = []
for entry in _iter_entry_points():
dist = entry.dist
package = dist.name if dist is not None else None
version = dist.version if dist is not None else None
found.append(
DiscoveredPlugin(
id=entry.name,
value=entry.value,
package=package,
version=version,
)
)
return sorted(found, key=lambda p: p.id)
def plugin_would_load(
plugin_id: str,
*,
enable: Sequence[str] | None,
disable: Iterable[str] | None = None,
) -> bool:
"""Whether *plugin_id* would be loaded under the given allowlist."""
disabled = {name.strip() for name in (disable or ()) if name.strip()}
if plugin_id in disabled:
return False
allow = resolve_plugin_allowlist(enable)
if allow is None:
return True
return plugin_id in allow
def list_plugin_statuses(
*,
enable: Sequence[str] | None,
disable: Iterable[str] | None = None,
) -> list[PluginStatus]:
"""Discovered plugins with enable/disable/will-load flags from config lists."""
disabled = {name.strip() for name in (disable or ()) if name.strip()}
allow = resolve_plugin_allowlist(enable)
rows: list[PluginStatus] = []
for plugin in list_discovered_plugins():
is_disabled = plugin.id in disabled
is_enabled = True if allow is None else plugin.id in allow
rows.append(
PluginStatus(
plugin=plugin,
enabled=is_enabled and not is_disabled,
disabled=is_disabled,
)
)
return rows
def resolve_plugin_allowlist(plugins: Sequence[str] | None) -> set[str] | None: def resolve_plugin_allowlist(plugins: Sequence[str] | None) -> set[str] | None:
"""Return the set of plugin ids to load, or ``None`` meaning all. """Return the set of plugin ids to load, or ``None`` meaning all.
+105
View File
@@ -0,0 +1,105 @@
"""CLI ``plyngent plugins`` and slash ``/plugins``."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
import tomlkit
from click.testing import CliRunner
from plyngent.cli.app import main
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.tools.plugins import DiscoveredPlugin, PluginStatus
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
@pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
# ReplState builds a real client; slash plugin tests only need config + agent.
await st.new_session("t")
yield st
await memory.close()
def _fake_statuses(*, enable: list[str], disable: list[str]) -> list[PluginStatus]:
del enable, disable
return [
PluginStatus(
plugin=DiscoveredPlugin(id="acme", value="acme_pkg:load", package="acme-pkg", version="1.0"),
enabled=True,
disabled=False,
),
PluginStatus(
plugin=DiscoveredPlugin(id="other", value="other:load", package=None, version=None),
enabled=False,
disabled=True,
),
]
def test_plugins_list_cmd(tmp_path: Path) -> None:
config = tmp_path / "plyngent.toml"
_ = config.write_text(
"""
[plugins]
enable = ["acme"]
disable = ["other"]
""",
encoding="utf-8",
)
runner = CliRunner()
with patch("plyngent.tools.plugins.list_plugin_statuses", side_effect=_fake_statuses):
result = runner.invoke(main, ["plugins", "list", "--config", str(config)])
assert result.exit_code == 0
assert "acme" in result.output
assert "enabled" in result.output
assert "other" in result.output
assert "disabled" in result.output
def test_plugins_enable_disable_write(tmp_path: Path) -> None:
config = tmp_path / "plyngent.toml"
_ = config.write_text("", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(main, ["plugins", "enable", "acme", "--config", str(config)])
assert result.exit_code == 0
text = config.read_text(encoding="utf-8")
assert "acme" in text
result = runner.invoke(main, ["plugins", "disable", "acme", "--config", str(config)])
assert result.exit_code == 0
text = config.read_text(encoding="utf-8")
assert "disable" in text
async def test_slash_plugins_enable_reload(state: ReplState) -> None:
with patch("plyngent.tools.plugins.list_plugin_statuses", return_value=[]):
assert await handle_slash(state, "/plugins list") is True
assert await handle_slash(state, "/plugins enable acme") is True
assert "acme" in state.config.plugins_config.enable
assert await handle_slash(state, "/plugins disable acme") is True
assert "acme" in state.config.plugins_config.disable
assert await handle_slash(state, "/plugins undeny acme") is True
assert "acme" not in state.config.plugins_config.disable
assert await handle_slash(state, "/plugins reload") is True
+61
View File
@@ -0,0 +1,61 @@
"""[plugins] section mutators and parse."""
from __future__ import annotations
from typing import TYPE_CHECKING
import tomlkit
from plyngent.config import load
from plyngent.config.store import ConfigStore
if TYPE_CHECKING:
from pathlib import Path
def test_plugins_defaults_and_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
store = load(path)
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
path.write_text(
"""
[plugins]
enable = ["acme", "*"]
disable = ["legacy"]
""",
encoding="utf-8",
)
store = load(path)
assert store.plugins_config.enable == ["acme", "*"]
assert store.plugins_config.disable == ["legacy"]
def test_enable_disable_undeny_write(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
store = ConfigStore(path=path, document=tomlkit.document())
_ = store.enable_plugin("acme")
assert store.plugins_config.enable == ["acme"]
store.write()
reloaded = load(path)
assert reloaded.plugins_config.enable == ["acme"]
_ = store.disable_plugin("acme")
assert "acme" not in store.plugins_config.enable
assert store.plugins_config.disable == ["acme"]
store.write()
reloaded = load(path)
assert reloaded.plugins_config.disable == ["acme"]
_ = store.undeny_plugin("acme")
assert store.plugins_config.disable == []
_ = store.enable_plugin("*")
assert store.plugins_config.enable == ["*"]
_ = store.enable_plugin("other")
# Already * — stay *
assert store.plugins_config.enable == ["*"]
_ = store.clear_plugins()
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
+9
View File
@@ -14,6 +14,15 @@ def test_resolve_plugin_allowlist() -> None:
assert resolve_plugin_allowlist(["acme", " beta "]) == {"acme", "beta"} assert resolve_plugin_allowlist(["acme", " beta "]) == {"acme", "beta"}
def test_plugin_would_load() -> None:
from plyngent.tools.plugins import plugin_would_load
assert plugin_would_load("acme", enable=["acme"], disable=[]) is True
assert plugin_would_load("acme", enable=["*"], disable=[]) is True
assert plugin_would_load("acme", enable=["*"], disable=["acme"]) is False
assert plugin_would_load("acme", enable=[], disable=[]) is False
def test_load_plugin_tools_default_loads_none() -> None: def test_load_plugin_tools_default_loads_none() -> None:
with catalog_scope(empty=True): with catalog_scope(empty=True):
loaded = load_plugin_tools(None) loaded = load_plugin_tools(None)