From 03e04f9a384210aef5531e4206764fc0cdeba5db Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 24 Jul 2026 16:44:44 +0800 Subject: [PATCH] core/cli: plugins list/enable/disable commands and /plugins slash --- README.md | 4 + doc/plugins.md | 33 ++++- src/plyngent/cli/app.py | 161 +++++++++++++++++++++- src/plyngent/cli/slash.py | 71 ++++++++++ src/plyngent/config/store.py | 68 +++++++++ src/plyngent/tools/plugins.py | 85 ++++++++++++ tests/test_cli/test_plugins_cmd.py | 105 ++++++++++++++ tests/test_config/test_plugins_section.py | 61 ++++++++ tests/test_tools/test_plugins.py | 9 ++ 9 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 tests/test_cli/test_plugins_cmd.py create mode 100644 tests/test_config/test_plugins_section.py diff --git a/README.md b/README.md index a8f0d87..b9fd261 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ plyngent chat -p "Summarize this repo" --provider oai --model gpt-5.4-mini --no- # 3) List providers from config 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. diff --git a/doc/plugins.md b/doc/plugins.md index 16d50b7..7eba177 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -121,7 +121,32 @@ enable = ["acme"] `enable` / `disable` are **plugin entry-point names** (e.g. `acme`), not individual 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): @@ -129,12 +154,6 @@ CLI load order (simplified): 2. `load_plugin_tools(plugins.enable, disable=plugins.disable)` 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:…`). ## Policy checklist for authors diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 1c71745..a722059 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -35,9 +35,18 @@ if TYPE_CHECKING: _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: - 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: path = resolve_config_path(config_path) 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) +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") def config_group() -> None: """Manage plyngent configuration.""" diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index 6fb0aef..a420a74 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -972,6 +972,77 @@ def tools_cmd( 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") @click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]") @click.pass_obj diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index 42b9e0d..c8244f6 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -158,6 +158,74 @@ class ConfigStore: """Typed plugin allowlist (enable / disable entry-point names).""" 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) -- @property diff --git a/src/plyngent/tools/plugins.py b/src/plyngent/tools/plugins.py index 277b9bb..ae55c7e 100644 --- a/src/plyngent/tools/plugins.py +++ b/src/plyngent/tools/plugins.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.metadata +from dataclasses import dataclass from typing import TYPE_CHECKING 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) +@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: """Return the set of plugin ids to load, or ``None`` meaning all. diff --git a/tests/test_cli/test_plugins_cmd.py b/tests/test_cli/test_plugins_cmd.py new file mode 100644 index 0000000..09345d4 --- /dev/null +++ b/tests/test_cli/test_plugins_cmd.py @@ -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 diff --git a/tests/test_config/test_plugins_section.py b/tests/test_config/test_plugins_section.py new file mode 100644 index 0000000..adba041 --- /dev/null +++ b/tests/test_config/test_plugins_section.py @@ -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 == [] diff --git a/tests/test_tools/test_plugins.py b/tests/test_tools/test_plugins.py index 08c5093..cde4277 100644 --- a/tests/test_tools/test_plugins.py +++ b/tests/test_tools/test_plugins.py @@ -14,6 +14,15 @@ def test_resolve_plugin_allowlist() -> None: 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: with catalog_scope(empty=True): loaded = load_plugin_tools(None)