Expose installed plugin toggles

Advertise the installed_plugins connector capability and add a protected API endpoint that lists already-installed Agent Zero plugins and toggles supported plugins only. The endpoint normalizes plugin metadata, preserves the installed-only safety boundary, and refuses changes to protected plugins such as _a0_connector so the CLI cannot disconnect itself.

Alessandro committed May 26, 2026 at 20:05 UTC d4dc83ba787d2bbb8c4413c0aac41e8bdd6cde84
2 files changed +160
plugins/_a0_connector/api/v1/capabilities.py
+1
@@ -37,6 +37,7 @@ _OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
37 "skills_list": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
38 "skills_activate": ("helpers.skills", "helpers.persist_chat"),
39 "skills_delete": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
40 + "installed_plugins": ("helpers.plugins",),
41 "model_presets": ("plugins._model_config.helpers.model_config",),
42 "model_switcher": ("plugins._model_config.helpers.model_config",),
43 "browser_runtime_config": ("plugins._browser.helpers.config", "helpers.plugins"),
plugins/_a0_connector/api/v1/installed_plugins.py new
+159
@@ -0,0 +1,159 @@
1 +"""POST /api/plugins/_a0_connector/v1/installed_plugins."""
2 +from __future__ import annotations
3 +
4 +from typing import Any, Mapping
5 +
6 +from helpers.api import Request, Response
7 +import plugins._a0_connector.api.v1.base as connector_base
8 +
9 +
10 +_PROTECTED_PLUGIN_REASONS = {
11 + "_a0_connector": "The A0 Connector plugin keeps this CLI session connected.",
12 +}
13 +
14 +
15 +def _clean_text(value: object) -> str:
16 + return str(value or "").strip()
17 +
18 +
19 +def _plugin_mapping(plugin: object) -> dict[str, Any]:
20 + if isinstance(plugin, Mapping):
21 + return dict(plugin)
22 +
23 + model_dump = getattr(plugin, "model_dump", None)
24 + if callable(model_dump):
25 + data = model_dump(mode="json")
26 + return data if isinstance(data, dict) else {}
27 +
28 + result: dict[str, Any] = {}
29 + for key in (
30 + "name",
31 + "display_name",
32 + "description",
33 + "version",
34 + "author",
35 + "repo",
36 + "always_enabled",
37 + "is_custom",
38 + "has_main_screen",
39 + "has_config_screen",
40 + "toggle_state",
41 + ):
42 + if hasattr(plugin, key):
43 + result[key] = getattr(plugin, key)
44 + return result
45 +
46 +
47 +def _plugin_payload(plugin: object) -> dict[str, Any]:
48 + data = _plugin_mapping(plugin)
49 + name = _clean_text(data.get("name"))
50 + display_name = _clean_text(data.get("display_name")) or name
51 + toggle_state = _clean_text(data.get("toggle_state")).lower()
52 + always_enabled = bool(data.get("always_enabled"))
53 + enabled = always_enabled or toggle_state == "enabled"
54 + protected_reason = _PROTECTED_PLUGIN_REASONS.get(name, "")
55 + if always_enabled and not protected_reason:
56 + protected_reason = "Agent Zero marks this plugin as always enabled."
57 +
58 + return {
59 + "name": name,
60 + "display_name": display_name,
61 + "description": _clean_text(data.get("description")),
62 + "version": _clean_text(data.get("version")),
63 + "author": _clean_text(data.get("author")),
64 + "repo": _clean_text(data.get("repo")),
65 + "source": "custom" if bool(data.get("is_custom")) else "builtin",
66 + "is_custom": bool(data.get("is_custom")),
67 + "always_enabled": always_enabled,
68 + "enabled": enabled,
69 + "toggle_state": "enabled" if enabled else "disabled",
70 + "toggleable": not bool(protected_reason),
71 + "protected_reason": protected_reason,
72 + "has_main_screen": bool(data.get("has_main_screen")),
73 + "has_config_screen": bool(data.get("has_config_screen")),
74 + }
75 +
76 +
77 +def _installed_plugin_payloads() -> list[dict[str, Any]]:
78 + from helpers import plugins
79 +
80 + items = plugins.get_enhanced_plugins_list(custom=True, builtin=True)
81 + payloads = [_plugin_payload(item) for item in items]
82 + return [payload for payload in payloads if payload["name"]]
83 +
84 +
85 +def _find_installed_plugin(plugin_name: str) -> dict[str, Any] | None:
86 + normalized = plugin_name.strip()
87 + if not normalized:
88 + return None
89 + for payload in _installed_plugin_payloads():
90 + if payload["name"] == normalized:
91 + return payload
92 + return None
93 +
94 +
95 +def _parse_enabled(value: object) -> bool | None:
96 + if isinstance(value, bool):
97 + return value
98 + if isinstance(value, str):
99 + normalized = value.strip().lower()
100 + if normalized in {"1", "true", "yes", "on", "enable", "enabled"}:
101 + return True
102 + if normalized in {"0", "false", "no", "off", "disable", "disabled"}:
103 + return False
104 + return None
105 +
106 +
107 +class InstalledPlugins(connector_base.ProtectedConnectorApiHandler):
108 + """List and toggle already-installed Agent Zero Core plugins only."""
109 +
110 + async def process(self, input: dict, request: Request) -> dict | Response:
111 + del request
112 +
113 + action = _clean_text(input.get("action") or "list").lower()
114 + if action == "list":
115 + plugins = _installed_plugin_payloads()
116 + enabled_count = sum(1 for plugin in plugins if plugin["enabled"])
117 + return {
118 + "ok": True,
119 + "plugins": plugins,
120 + "installed_count": len(plugins),
121 + "enabled_count": enabled_count,
122 + }
123 +
124 + if action != "set_enabled":
125 + return Response(status=400, response=f"Unknown action: {action}")
126 +
127 + plugin_name = _clean_text(input.get("plugin_name"))
128 + if not plugin_name:
129 + return Response(status=400, response="Missing plugin_name")
130 +
131 + enabled = _parse_enabled(input.get("enabled"))
132 + if enabled is None:
133 + return Response(status=400, response="Missing or invalid enabled state")
134 +
135 + plugin = _find_installed_plugin(plugin_name)
136 + if plugin is None:
137 + return Response(status=404, response="Plugin not found")
138 +
139 + if not plugin["toggleable"]:
140 + return Response(
141 + status=400,
142 + response=plugin["protected_reason"] or "Plugin cannot be toggled.",
143 + )
144 +
145 + from helpers import plugins as plugin_helpers
146 +
147 + plugin_helpers.toggle_plugin(
148 + plugin_name,
149 + enabled,
150 + project_name="",
151 + agent_profile="",
152 + clear_overrides=False,
153 + )
154 + updated = _find_installed_plugin(plugin_name) or plugin
155 + return {
156 + "ok": True,
157 + "plugin": updated,
158 + "plugins": _installed_plugin_payloads(),
159 + }