mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
core/cli: plugins list/enable/disable commands and /plugins slash
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
+158
-1
@@ -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:
|
||||||
|
if require_providers:
|
||||||
return load_config_with_optional_edit(config_path)
|
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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 == []
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user