align plugin schema, config API actions, and plugin list UI

Alessandro committed Feb 20, 2026 at 08:32 UTC 604078376f6f67c57e862b7bab0d6bf9d43ae87a
15 files changed +413 -252
AGENTS.md
+3 -3
@@ -72,10 +72,10 @@ Modals in A0 are "stacked" and loaded dynamically via `openModal(path)`.
72
73 Plugins get a dedicated settings modal with **Project** and **Agent profile** context selectors. To enable it:
74
75 -1. Add `webui/settings.html` to your plugin (auto-detected).
75 +1. Add `webui/config.html` to your plugin (auto-detected).
76 2. Set `"settings_sections": ["agent"]` in `plugin.json` - this places a subsection with a Settings button in the chosen tab.
77
78 -Your `settings.html` binds to `$store.pluginSettings.settings` (a plain object persisted as `settings.json`). The modal's Save/Cancel footer handles persistence automatically. See `plugins/README.md` for the full contract and settings resolution priority chain.
78 +Your `config.html` binds to `$store.pluginSettings.settings` (a plain object persisted as `config.json`). The modal's Save/Cancel footer handles persistence automatically. See `plugins/README.md` for the full contract and settings resolution priority chain.
79
80 For plugins that surface **existing core settings** (e.g. wrapping `settings/agent/memory.html`), set `$store.pluginSettings.saveMode = 'core'` in `x-init` to route Save through the core settings API instead.
81
@@ -105,7 +105,7 @@ usr/plugins/my-plugin/
105 │ ├── python/agent_init/ # Auto-start logic
106 │ └── webui/ # sidebar-quick-actions-main-start/
107 └── webui/
108 - ├── settings.html # Optional: plugin settings UI
108 + ├── config.html # Optional: plugin settings UI
109 └── my-modal.html # Full plugin pages + stores
110 ```
111
plugins/README.md
+16 -16
@@ -53,7 +53,7 @@ usr/plugins/
53 python/<extension_point>/ # Python lifecycle extensions
54 webui/<extension_point>/ # WebUI HTML/JS hook contributions
55 webui/
56 - settings.html # Optional: plugin settings UI
56 + config.html # Optional: plugin settings UI
57 ... # Full plugin-owned UI pages/components
58 ```
59
@@ -70,7 +70,7 @@ Capability discovery is based on these paths:
70 - `extensions/python/<extension_point>/*.py` - backend lifecycle extensions
71 - `extensions/webui/<extension_point>/*` - WebUI extension assets (HTML/JS)
72 - `webui/**` - full plugin-owned UI pages/components (loaded directly by path)
73 -- `webui/settings.html` - if present, a Settings button appears for this plugin in the relevant settings tabs
73 +- `webui/config.html` - if present, a Settings button appears for this plugin in the relevant settings tabs
74 - `prompts/**/*.md` - prompt templates
75 - `agents/` - agent profiles
76
@@ -133,18 +133,18 @@ Runtime code calls:
133
134 If your plugin needs user-configurable settings:
135
136 -1. Add `webui/settings.html` to your plugin. The system detects this file automatically.
136 +1. Add `webui/config.html` to your plugin. The system detects this file automatically.
137 2. Declare which settings tabs should show a subsection for your plugin via `settings_sections` in `plugin.json`.
138 3. The plugin settings modal provides **Project** and **Agent profile** context selectors (same as the Skills list). Settings are scoped per-project and per-agent.
139
140 -### Settings HTML contract
140 +### Config HTML contract
141
142 -Your `settings.html` receives context from `$store.pluginSettings`:
142 +Your `config.html` receives context from `$store.pluginSettings`:
143
144 ```html
145 <html>
146 <head>
147 - <title>My Plugin Settings</title>
147 + <title>My Plugin Config</title>
148 <script type="module">
149 import { store } from "/components/plugins/plugin-settings-store.js";
150 </script>
@@ -158,19 +158,19 @@ Your `settings.html` receives context from `$store.pluginSettings`:
158 </html>
159 ```
160
161 -- `$store.pluginSettings.settings` - plain object loaded from `settings.json`, save-scoped to the selected project/agent.
162 -- The modal's **Save** button calls `POST /plugins` (`action: save_settings`) automatically.
161 +- `$store.pluginSettings.settings` - plain object loaded from `config.json`, save-scoped to the selected project/agent.
162 +- The modal's **Save** button calls `POST /plugins` (`action: save_config`) automatically.
163 - For plugins that surface **core settings** (like memory), set `saveMode = 'core'` in `x-init` so Save delegates to the core settings API instead.
164
165 ### Settings resolution priority (highest first)
166
167 ```
168 -project/.a0proj/agents/<profile>/plugins/<name>/settings.json
169 -project/.a0proj/plugins/<name>/settings.json
170 -usr/agents/<profile>/plugins/<name>/settings.json
171 -agents/<profile>/plugins/<name>/settings.json
172 -usr/plugins/<name>/settings.json
173 -plugins/<name>/settings.json
168 +project/.a0proj/agents/<profile>/plugins/<name>/config.json
169 +project/.a0proj/plugins/<name>/config.json
170 +usr/agents/<profile>/plugins/<name>/config.json
171 +agents/<profile>/plugins/<name>/config.json
172 +usr/plugins/<name>/config.json
173 +plugins/<name>/config.json
174 ```
175
176 ## Plugin Author Flow
@@ -182,14 +182,14 @@ plugins/<name>/settings.json
182 5. For HTML UI entries: place files under `extensions/webui/<extension_point>/`, use root `x-data` + one `x-move-*` directive.
183 6. For JS hooks: place `*.js` files under `extensions/webui/<extension_point>/`, export a default async function.
184 7. Place full plugin pages/components in `webui/` and open them directly by path.
185 -8. Optionally add `webui/settings.html` and set `settings_sections` in `plugin.json` to expose settings in the UI.
185 +8. Optionally add `webui/config.html` and set `settings_sections` in `plugin.json` to expose settings in the UI.
186
187 ## Routes
188
189 - Plugin static assets: `GET /plugins/<plugin_name>/<path>`
190 - Plugin APIs: `POST /api/plugins/<plugin_name>/<handler>`
191 - WebUI extension discovery: `POST /api/load_webui_extensions`
192 -- Plugin management (list, get/save settings): `POST /plugins`
192 +- Plugin management (get/save config): `POST /plugins`
193
194 ## Notes
195
plugins/example_agent/plugin.json
+3 -5
@@ -1,10 +1,8 @@
1 {
2 - "description": "Example Agent Plugin",
3 - "per_project_config": true,
4 - "per_agent_config": false
5 -}
2 "name": "Example Agent",
3 "description": "Example agent plugin demonstrating the Agent Zero plugin system.",
4 "version": "1.0.0",
9 - "settings_sections": []
5 + "settings_sections": [],
6 + "per_project_config": true,
7 + "per_agent_config": false
8 }
plugins/memory/plugin.json
+3 -5
@@ -1,10 +1,8 @@
1 {
2 - "description": "Memory Plugin",
3 - "per_project_config": true,
4 - "per_agent_config": true
5 -}
2 "name": "Memory",
3 "description": "Provides persistent memory capabilities to Agent Zero agents.",
4 "version": "1.0.0",
9 - "settings_sections": ["agent"]
5 + "settings_sections": ["agent"],
6 + "per_project_config": true,
7 + "per_agent_config": true
8 }
plugins/memory/webui/config.html
+21
@@ -1,3 +1,24 @@
1 +<html>
2 +<head>
3 + <title>Memory Settings</title>
4 + <script type="module">
5 + import { store } from "/components/settings/settings-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <!--
10 + This file surfaces the existing core memory settings inside the plugin settings modal.
11 + It sets saveMode='core' so the modal's Save button delegates to $store.settings.saveSettings().
12 + -->
13 + <div x-data
14 + x-init="
15 + $store.pluginSettings.saveMode = 'core';
16 + if ($store.settings && !$store.settings.settings) $store.settings.onOpen();
17 + ">
18 + <x-component path="settings/agent/memory.html"></x-component>
19 + </div>
20 +</body>
21 +</html>
22 <!DOCTYPE html>
23 <html lang="en">
24 <head>
plugins/memory/webui/settings.html deleted
-21
@@ -1,21 +0,0 @@
1 -<html>
2 -<head>
3 - <title>Memory Settings</title>
4 - <script type="module">
5 - import { store } from "/components/settings/settings-store.js";
6 - </script>
7 -</head>
8 -<body>
9 - <!--
10 - This file surfaces the existing core memory settings inside the plugin settings modal.
11 - It sets saveMode='core' so the modal's Save button delegates to $store.settings.saveSettings().
12 - -->
13 - <div x-data
14 - x-init="
15 - $store.pluginSettings.saveMode = 'core';
16 - if ($store.settings && !$store.settings.settings) $store.settings.onOpen();
17 - ">
18 - <x-component path="settings/agent/memory.html"></x-component>
19 - </div>
20 -</body>
21 -</html>
python/api/plugins.py
+24 -14
@@ -1,40 +1,50 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 -from python.helpers import plugins
2 +from python.helpers import plugins, files
3
4
5 class Plugins(ApiHandler):
6 """
7 Core plugin management API.
8 - Actions: list, get_settings, save_settings
8 + Actions: get_config, save_config
9 """
10
11 async def process(self, input: dict, request: Request) -> dict | Response:
12 - action = input.get("action", "list")
12 + action = input.get("action", "get_config")
13
14 - if action == "list":
15 - tab = input.get("tab") # optional: filter by settings_tab
16 - data = plugins.list_plugins_with_metadata(tab_filter=tab or None)
17 - return {"ok": True, "data": data}
18 -
19 - if action == "get_settings":
14 + # Accept legacy aliases during migration.
15 + if action in {"get_config", "get_settings"}:
16 plugin_name = input.get("plugin_name", "")
17 project_name = input.get("project_name", "")
18 agent_profile = input.get("agent_profile", "")
19 if not plugin_name:
20 return Response(status=400, response="Missing plugin_name")
25 - settings = plugins.get_plugin_settings(plugin_name,
26 - project_name=project_name,
27 - agent_profile=agent_profile)
21 +
22 + settings = None
23 + if project_name or agent_profile:
24 + file_path = plugins.determine_plugin_asset_path(
25 + plugin_name,
26 + project_name,
27 + agent_profile,
28 + plugins.CONFIG_FILE_NAME,
29 + )
30 + if files.exists(file_path):
31 + settings = files.read_file_json(file_path)
32 +
33 + if settings is None:
34 + settings = plugins.get_plugin_config(plugin_name, agent=None)
35 +
36 return {"ok": True, "data": settings or {}}
37
30 - if action == "save_settings":
38 + if action in {"save_config", "save_settings"}:
39 plugin_name = input.get("plugin_name", "")
40 project_name = input.get("project_name", "")
41 agent_profile = input.get("agent_profile", "")
42 settings = input.get("settings", {})
43 if not plugin_name:
44 return Response(status=400, response="Missing plugin_name")
37 - plugins.save_plugin_settings(plugin_name, project_name, agent_profile, settings)
45 + if not isinstance(settings, dict):
46 + return Response(status=400, response="settings must be an object")
47 + plugins.save_plugin_config(plugin_name, project_name, agent_profile, settings)
48 return {"ok": True}
49
50 return Response(status=400, response=f"Unknown action: {action}")
python/helpers/plugins.py
+21 -4
@@ -5,7 +5,7 @@ from pathlib import Path
5 from typing import Any, Dict, List, Optional, TYPE_CHECKING
6
7 from python.helpers import files, print_style
8 -from pydantic import BaseModel
8 +from pydantic import BaseModel, Field
9
10 if TYPE_CHECKING:
11 from agent import Agent
@@ -23,13 +23,24 @@ ENABLED_FILE_NAME = ".enabled"
23
24
25 class PluginMetadata(BaseModel):
26 + name: str = ""
27 description: str = ""
28 + version: str = ""
29 + settings_sections: List[str] = Field(default_factory=list)
30 + per_project_config: bool = False
31 + per_agent_config: bool = False
32
33
34 class PluginListItem(BaseModel):
35 name: str
36 path: str
37 + display_name: str = ""
38 description: str = ""
39 + version: str = ""
40 + settings_sections: List[str] = Field(default_factory=list)
41 + per_project_config: bool = False
42 + per_agent_config: bool = False
43 + is_custom: bool = False
44 has_main_screen: bool = False
45 has_config_screen: bool = False
46
@@ -64,7 +75,7 @@ def get_enhanced_plugins_list(
75 """Discover plugins by directory convention. First root wins on ID conflict."""
76 results = []
77
67 - def load_plugins(root_path: str):
78 + def load_plugins(root_path: str, is_custom: bool):
79 for d in sorted(Path(root_path).iterdir(), key=lambda p: p.name):
80 try:
81 if not d.is_dir() or d.name.startswith("."):
@@ -78,7 +89,13 @@ def get_enhanced_plugins_list(
89 PluginListItem(
90 name=d.name,
91 path=str(d),
92 + display_name=meta.name or d.name,
93 description=meta.description,
94 + version=meta.version,
95 + settings_sections=meta.settings_sections,
96 + per_project_config=meta.per_project_config,
97 + per_agent_config=meta.per_agent_config,
98 + is_custom=is_custom,
99 has_main_screen=has_main_screen,
100 has_config_screen=has_config_screen,
101 )
@@ -87,9 +104,9 @@ def get_enhanced_plugins_list(
104 pass
105
106 if custom:
90 - load_plugins(files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR))
107 + load_plugins(files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR), True)
108 if builtin:
92 - load_plugins(files.get_abs_path(files.PLUGINS_DIR))
109 + load_plugins(files.get_abs_path(files.PLUGINS_DIR), False)
110 return results
111
112
skills/a0-create-plugin/SKILL.md
+14 -9
@@ -63,11 +63,11 @@ Import it in the HTML `<head>`:
63
64 ## ⚙️ Plugin Settings
65
66 -If your plugin needs user-configurable settings, add `webui/settings.html`. The system detects it automatically and shows a Settings button in the relevant tabs (per `settings_sections` in `plugin.json`).
66 +If your plugin needs user-configurable settings, add `webui/config.html`. The system detects it automatically and shows a Settings button in the relevant tabs (per `settings_sections` in `plugin.json`).
67
68 ### Settings modal contract
69
70 -The modal provides Project + Agent profile context selectors. Your `settings.html` binds to `$store.pluginSettings.settings`:
70 +The modal provides Project + Agent profile context selectors. Your `config.html` binds to `$store.pluginSettings.settings`:
71
72 ```html
73 <html>
@@ -86,7 +86,7 @@ The modal provides Project + Agent profile context selectors. Your `settings.htm
86 </html>
87 ```
88
89 -The modal's Save button persists `$store.pluginSettings.settings` to `settings.json` in the correct scope (project/agent/global).
89 +The modal's Save button persists `$store.pluginSettings.settings` to `config.json` in the correct scope (project/agent/global).
90
91 ### Surfacing core settings (e.g. memory pattern)
92
@@ -124,13 +124,18 @@ response = await task.result()
124
125 ### Reading Plugin Settings (backend)
126 ```python
127 -from python.helpers.plugins import get_plugin_settings
127 +from python.helpers.plugins import get_plugin_config, save_plugin_config
128
129 # Runtime (with running agent - resolves project/profile from context)
130 -settings = get_plugin_settings("my-plugin", agent=agent)
131 -
132 -# UI path (explicit strings, no agent instance needed)
133 -settings = get_plugin_settings("my-plugin", project_name="my-project", agent_profile="default")
130 +settings = get_plugin_config("my-plugin", agent=agent) or {}
131 +
132 +# Explicit write target (project/profile scope)
133 +save_plugin_config(
134 + "my-plugin",
135 + project_name="my-project",
136 + agent_profile="default",
137 + settings=settings,
138 +)
139 ```
140
141 ## 📁 Directory Layout
@@ -143,7 +148,7 @@ usr/plugins/<name>/
148 python/agent_init/ # Python lifecycle extensions
149 webui/<point>/ # HTML/JS hook extensions
150 webui/
146 - settings.html # Optional: plugin settings UI
151 + config.html # Optional: plugin settings UI
152 my-modal.html # Full plugin pages
153 my-store.js # Alpine stores
154 ```
webui/components/plugins/list/plugin-list.html
+97 -162
@@ -9,138 +9,95 @@
9 <body>
10 <div x-data>
11 <template x-if="$store.pluginListStore">
12 - <div x-init="$store.pluginListStore.loadPluginList && $store.pluginListStore.loadPluginList({ builtin: false, custom: true, search: '' })">
12 + <div x-init="$store.pluginListStore.setTab && $store.pluginListStore.setTab('custom')">
13
14 <ul class="nav nav-tabs" id="plugin-list-tabs" role="tablist">
15 <li class="nav-item" role="presentation">
16 - <button class="nav-link active" id="plugins-custom-tab" data-bs-toggle="tab"
17 - data-bs-target="#plugins-custom" type="button" role="tab"
18 - aria-controls="plugins-custom" aria-selected="true"
19 - @click="$store.pluginListStore.loadPluginList && $store.pluginListStore.loadPluginList({ builtin: false, custom: true, search: '' })">
16 + <button class="nav-link"
17 + :class="{ active: $store.pluginListStore.activeTab === 'custom' }"
18 + id="plugins-custom-tab"
19 + type="button"
20 + role="tab"
21 + aria-controls="plugins-custom"
22 + :aria-selected="$store.pluginListStore.activeTab === 'custom'"
23 + @click="$store.pluginListStore.setTab('custom')">
24 Custom
25 </button>
26 </li>
27 <li class="nav-item" role="presentation">
24 - <button class="nav-link" id="plugins-builtin-tab" data-bs-toggle="tab"
25 - data-bs-target="#plugins-builtin" type="button" role="tab"
26 - aria-controls="plugins-builtin" aria-selected="false"
27 - @click="$store.pluginListStore.loadPluginList && $store.pluginListStore.loadPluginList({ builtin: true, custom: false, search: '' })">
28 + <button class="nav-link"
29 + :class="{ active: $store.pluginListStore.activeTab === 'builtin' }"
30 + id="plugins-builtin-tab"
31 + type="button"
32 + role="tab"
33 + aria-controls="plugins-builtin"
34 + :aria-selected="$store.pluginListStore.activeTab === 'builtin'"
35 + @click="$store.pluginListStore.setTab('builtin')">
36 Builtin
37 </button>
38 </li>
39 </ul>
40
33 - <div class="tab-content" id="plugin-list-tabs-content">
34 - <div class="tab-pane fade show active" id="plugins-custom" role="tabpanel"
35 - aria-labelledby="plugins-custom-tab">
36 - <div>
37 - <div class="plugins-toolbar">
38 - <div class="plugins-toolbar-actions">
39 - <button type="button" class="button confirm" title="Refresh"
40 - @click="$store.pluginListStore.loadPluginList && $store.pluginListStore.loadPluginList({ builtin: false, custom: true, search: '' })"
41 - :disabled="$store.pluginListStore.loading">
42 - <span class="icon material-symbols-outlined">refresh</span> Refresh
43 - </button>
44 - </div>
45 - </div>
46 -
47 - <div x-show="$store.pluginListStore.loading" class="loading">
48 - <span>Loading plugins...</span>
49 - </div>
50 -
51 - <div class="plugins-list">
52 - <template x-for="plugin in ($store.pluginListStore.plugins || [])" :key="plugin.path || plugin.name">
53 - <div class="plugin-card">
54 - <div class="plugin-header">
55 - <div class="plugin-heading">
56 - <div class="plugin-title" x-text="plugin.name || '(unnamed plugin)'"></div>
57 - <code class="plugin-path" x-text="plugin.path"></code>
58 - </div>
59 - <div class="plugin-actions">
60 - <template x-if="plugin.has_main_screen">
61 - <button type="button" class="button" title="Open"
62 - @click="$store.pluginListStore.openPlugin && $store.pluginListStore.openPlugin(plugin)">
63 - <span class="icon material-symbols-outlined">assistant_on_hub</span> Open
64 - </button>
65 - </template>
66 - <template x-if="plugin.has_config_screen">
67 - <button type="button" class="button" title="Config"
68 - @click="$store.pluginListStore.openPluginConfig && $store.pluginListStore.openPluginConfig(plugin)">
69 - <span class="icon material-symbols-outlined">settings</span> Config
70 - </button>
71 - </template>
72 - <button type="button" class="button" title="Info"
73 - @click="$store.pluginListStore.openPluginInfo && $store.pluginListStore.openPluginInfo(plugin)">
74 - <span class="icon material-symbols-outlined">info</span> Info
75 - </button>
76 - <button type="button" class="button cancel icon-button" title="Delete"
77 - @click="$confirmClick($event, () => $store.pluginListStore.deletePlugin && $store.pluginListStore.deletePlugin(plugin))">
78 - <span class="icon material-symbols-outlined">delete</span>
79 - </button>
80 - </div>
81 - </div>
82 - <div class="plugin-description" x-text="plugin.description || 'No description provided.'"></div>
83 - </div>
84 - </template>
85 - </div>
86 - </div>
41 + <div class="plugins-toolbar">
42 + <div class="plugins-toolbar-actions">
43 + <button type="button"
44 + class="button confirm"
45 + title="Refresh"
46 + @click="$store.pluginListStore.refresh()"
47 + :disabled="$store.pluginListStore.loading">
48 + <span class="icon material-symbols-outlined">refresh</span> Refresh
49 + </button>
50 </div>
51 + </div>
52
89 - <div class="tab-pane fade" id="plugins-builtin" role="tabpanel" aria-labelledby="plugins-builtin-tab">
90 - <div>
91 - <div class="plugins-toolbar">
92 - <div class="plugins-toolbar-actions">
93 - <button type="button" class="button confirm" title="Refresh"
94 - @click="$store.pluginListStore.loadPluginList && $store.pluginListStore.loadPluginList({ builtin: true, custom: false, search: '' })"
95 - :disabled="$store.pluginListStore.loading">
96 - <span class="icon material-symbols-outlined">refresh</span> Refresh
53 + <div x-show="$store.pluginListStore.loading" class="loading">
54 + <span>Loading plugins...</span>
55 + </div>
56 +
57 + <div class="plugins-list">
58 + <template x-for="plugin in ($store.pluginListStore.plugins || [])" :key="plugin.path || plugin.name">
59 + <div class="plugin-card">
60 + <div class="plugin-header">
61 + <div class="plugin-heading">
62 + <div class="plugin-title" x-text="plugin.display_name || plugin.name || '(unnamed plugin)'"></div>
63 + <code class="plugin-path" x-text="plugin.path"></code>
64 + </div>
65 + <div class="plugin-actions">
66 + <template x-if="plugin.has_main_screen">
67 + <button type="button"
68 + class="button"
69 + title="Open"
70 + @click="$store.pluginListStore.openPlugin(plugin)">
71 + <span class="icon material-symbols-outlined">folder_open</span> Open
72 + </button>
73 + </template>
74 + <template x-if="plugin.has_config_screen">
75 + <button type="button"
76 + class="button"
77 + title="Config"
78 + @click="$store.pluginListStore.openPluginConfig(plugin)">
79 + <span class="icon material-symbols-outlined">settings</span> Config
80 + </button>
81 + </template>
82 + <button type="button"
83 + class="button"
84 + title="Info"
85 + @click="$store.pluginListStore.openPluginInfo(plugin)">
86 + <span class="icon material-symbols-outlined">info</span> Info
87 + </button>
88 + <button type="button"
89 + class="button cancel icon-button"
90 + :title="plugin.is_custom ? 'Delete' : 'Builtin plugins cannot be deleted'"
91 + :disabled="!plugin.is_custom"
92 + @click="$confirmClick($event, () => $store.pluginListStore.deletePlugin(plugin))">
93 + <span class="icon material-symbols-outlined">delete</span>
94 </button>
95 </div>
96 </div>
100 -
101 - <div x-show="$store.pluginListStore.loading" class="loading">
102 - <span>Loading plugins...</span>
103 - </div>
104 -
105 - <div class="plugins-list">
106 - <template x-for="plugin in ($store.pluginListStore.plugins || [])" :key="plugin.path || plugin.name">
107 - <div class="plugin-card">
108 - <div class="plugin-header">
109 - <div class="plugin-heading">
110 - <div class="plugin-title" x-text="plugin.name || '(unnamed plugin)'"></div>
111 - <code class="plugin-path" x-text="plugin.path"></code>
112 - </div>
113 - <div class="plugin-actions">
114 - <template x-if="plugin.has_main_screen">
115 - <button type="button" class="button" title="Open"
116 - @click="$store.pluginListStore.openPlugin && $store.pluginListStore.openPlugin(plugin)">
117 - <span class="icon material-symbols-outlined">assistant_on_hub</span> Open
118 - </button>
119 - </template>
120 - <template x-if="plugin.has_config_screen">
121 - <button type="button" class="button" title="Config"
122 - @click="$store.pluginListStore.openPluginConfig && $store.pluginListStore.openPluginConfig(plugin)">
123 - <span class="icon material-symbols-outlined">settings</span> Config
124 - </button>
125 - </template>
126 - <button type="button" class="button" title="Info"
127 - @click="$store.pluginListStore.openPluginInfo && $store.pluginListStore.openPluginInfo(plugin)">
128 - <span class="icon material-symbols-outlined">info</span> Info
129 - </button>
130 - <button type="button" class="button cancel icon-button" title="Delete"
131 - @click="$confirmClick($event, () => $store.pluginListStore.deletePlugin && $store.pluginListStore.deletePlugin(plugin))">
132 - <span class="icon material-symbols-outlined">delete</span>
133 - </button>
134 - </div>
135 - </div>
136 - <div class="plugin-description" x-text="plugin.description || 'No description provided.'"></div>
137 - </div>
138 - </template>
139 - </div>
97 + <div class="plugin-description" x-text="plugin.description || 'No description provided.'"></div>
98 </div>
141 - </div>
99 + </template>
100 </div>
143 -
101 </div>
102 </template>
103 </div>
@@ -172,24 +129,12 @@
129 background: var(--color-bg-primary);
130 }
131
175 - .tab-content {
176 - padding-top: 0.75rem;
177 - }
178 -
179 - .tab-pane {
180 - display: none;
181 - }
182 -
183 - .tab-pane.active {
184 - display: block;
185 - }
186 -
132 .plugins-toolbar {
133 display: flex;
134 align-items: center;
135 gap: 0.75rem;
136 flex-wrap: wrap;
192 - margin-top: 0.25rem;
137 + margin-top: 0.5rem;
138 margin-bottom: 0.75rem;
139 }
140
@@ -238,47 +183,13 @@
183 align-items: center;
184 gap: 0.5rem;
185 padding-bottom: var(--spacing-sm);
241 - flex-wrap: nowrap;
186 + flex-wrap: wrap;
187 min-width: 0;
243 - flex: 0 0 auto;
188 + flex: 0 1 11.5rem;
189 max-width: 100%;
190 justify-content: flex-end;
191 }
192
248 - @media (max-width: 640px) {
249 - .plugin-header {
250 - flex-direction: column;
251 - align-items: stretch;
252 - }
253 -
254 - .plugin-heading {
255 - width: 100%;
256 - flex: 0 0 auto;
257 - }
258 -
259 - .plugin-actions {
260 - width: 100%;
261 - justify-content: flex-start;
262 - padding-bottom: 0;
263 - flex-wrap: wrap;
264 - }
265 -
266 - .plugin-path {
267 - white-space: normal;
268 - word-break: break-word;
269 - }
270 - }
271 -
272 - .plugin-actions .button {
273 - padding: 0.25rem 0.5rem;
274 - font-size: 0.85rem;
275 - line-height: 1.1;
276 - }
277 -
278 - .plugin-actions .button .icon {
279 - font-size: 1.1rem;
280 - }
281 -
193 .plugin-description {
194 margin-top: 0.35rem;
195 color: var(--color-text-secondary);
@@ -303,6 +214,30 @@
214 margin-bottom: 0.75rem;
215 color: var(--color-secondary);
216 }
217 +
218 + @media (max-width: 640px) {
219 + .plugin-header {
220 + flex-direction: column;
221 + align-items: stretch;
222 + }
223 +
224 + .plugin-heading {
225 + width: 100%;
226 + flex: 0 0 auto;
227 + }
228 +
229 + .plugin-actions {
230 + width: 100%;
231 + justify-content: flex-start;
232 + padding-bottom: 0;
233 + flex-wrap: wrap;
234 + }
235 +
236 + .plugin-path {
237 + white-space: normal;
238 + word-break: break-word;
239 + }
240 + }
241 </style>
242 </body>
243
webui/components/plugins/list/pluginListStore.js
+72 -2
@@ -1,5 +1,6 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import * as api from "/js/api.js";
3 +import "/components/plugins/plugin-settings-store.js";
4 import {
5 store as notificationStore,
6 defaultPriority,
@@ -9,19 +10,88 @@ import {
10 const model = {
11 loading: false,
12 plugins: [],
13 + selectedPlugin: null,
14 + activeTab: "custom",
15
16 async loadPluginList(filter) {
17 this.loading = true;
15 - this.plugins = [];
18 + this.selectedPlugin = null;
19 try {
20 const response = await api.callJsonApi("plugins_list", { filter });
18 - this.plugins = response.plugins;
21 + this.plugins = Array.isArray(response.plugins) ? response.plugins : [];
22 } catch (e) {
23 + this.plugins = [];
24 showErrorNotification(e, "Failed to load plugins list");
25 } finally {
26 this.loading = false;
27 }
28 },
29 +
30 + async setTab(tab) {
31 + this.activeTab = tab === "builtin" ? "builtin" : "custom";
32 + const filter =
33 + this.activeTab === "builtin"
34 + ? { builtin: true, custom: false, search: "" }
35 + : { builtin: false, custom: true, search: "" };
36 + await this.loadPluginList(filter);
37 + },
38 +
39 + async refresh() {
40 + await this.setTab(this.activeTab);
41 + },
42 +
43 + openPlugin(plugin) {
44 + if (!plugin?.name || !plugin?.has_main_screen) return;
45 + window.openModal?.(`/plugins/${plugin.name}/webui/main.html`);
46 + },
47 +
48 + async openPluginConfig(plugin) {
49 + if (!plugin?.name || !plugin?.has_config_screen) return;
50 + try {
51 + const pluginSettingsStore = Alpine.store("pluginSettings");
52 + if (!pluginSettingsStore?.open) {
53 + throw new Error("Plugin settings store is unavailable.");
54 + }
55 + await pluginSettingsStore.open(plugin.name);
56 + window.openModal?.("components/plugins/plugin-settings.html");
57 + } catch (e) {
58 + showErrorNotification(e, "Failed to open plugin config");
59 + }
60 + },
61 +
62 + openPluginInfo(plugin) {
63 + if (!plugin) return;
64 + this.selectedPlugin = plugin;
65 + window.openModal?.("components/plugins/plugin-info.html");
66 + },
67 +
68 + async deletePlugin(plugin) {
69 + if (!plugin?.path) return;
70 +
71 + if (!plugin.is_custom) {
72 + showErrorNotification(
73 + new Error("Only custom plugins can be deleted from this modal."),
74 + "Delete blocked",
75 + );
76 + return;
77 + }
78 +
79 + try {
80 + const response = await api.callJsonApi("delete_work_dir_file", {
81 + path: plugin.path,
82 + currentPath: "/",
83 + });
84 + if (response?.error) {
85 + throw new Error(response.error);
86 + }
87 + if (window.toastFrontendSuccess) {
88 + window.toastFrontendSuccess("Plugin deleted", "Plugins");
89 + }
90 + await this.refresh();
91 + } catch (e) {
92 + showErrorNotification(e, "Failed to delete plugin");
93 + }
94 + },
95 };
96
97 function showErrorNotification(error, heading) {
webui/components/plugins/plugin-info.html new
+119
@@ -0,0 +1,119 @@
1 +<html>
2 +<head>
3 + <title>Plugin Info</title>
4 + <script type="module">
5 + import { store } from "/components/plugins/list/pluginListStore.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginListStore && $store.pluginListStore.selectedPlugin">
11 + <div class="plugin-info-view">
12 + <div class="plugin-info-header">
13 + <h2 class="plugin-info-title"
14 + x-text="$store.pluginListStore.selectedPlugin.display_name || $store.pluginListStore.selectedPlugin.name"></h2>
15 + <div class="plugin-info-source"
16 + x-text="$store.pluginListStore.selectedPlugin.is_custom ? 'Custom plugin' : 'Builtin plugin'"></div>
17 + </div>
18 +
19 + <div class="plugin-info-description"
20 + x-text="$store.pluginListStore.selectedPlugin.description || 'No description provided.'"></div>
21 +
22 + <div class="plugin-info-grid">
23 + <div class="plugin-info-label">Plugin ID:</div>
24 + <div class="plugin-info-value">
25 + <code x-text="$store.pluginListStore.selectedPlugin.name"></code>
26 + </div>
27 +
28 + <div class="plugin-info-label">Version:</div>
29 + <div class="plugin-info-value"
30 + x-text="$store.pluginListStore.selectedPlugin.version || 'n/a'"></div>
31 +
32 + <div class="plugin-info-label">Path:</div>
33 + <div class="plugin-info-value">
34 + <code x-text="$store.pluginListStore.selectedPlugin.path"></code>
35 + </div>
36 +
37 + <div class="plugin-info-label">Settings sections:</div>
38 + <div class="plugin-info-value"
39 + x-text="($store.pluginListStore.selectedPlugin.settings_sections || []).length ? $store.pluginListStore.selectedPlugin.settings_sections.join(', ') : 'None'"></div>
40 +
41 + <div class="plugin-info-label">Per-project config:</div>
42 + <div class="plugin-info-value"
43 + x-text="$store.pluginListStore.selectedPlugin.per_project_config ? 'Yes' : 'No'"></div>
44 +
45 + <div class="plugin-info-label">Per-agent config:</div>
46 + <div class="plugin-info-value"
47 + x-text="$store.pluginListStore.selectedPlugin.per_agent_config ? 'Yes' : 'No'"></div>
48 +
49 + <div class="plugin-info-label">Main screen:</div>
50 + <div class="plugin-info-value"
51 + x-text="$store.pluginListStore.selectedPlugin.has_main_screen ? 'Available' : 'Not available'"></div>
52 +
53 + <div class="plugin-info-label">Config screen:</div>
54 + <div class="plugin-info-value"
55 + x-text="$store.pluginListStore.selectedPlugin.has_config_screen ? 'Available' : 'Not available'"></div>
56 + </div>
57 + </div>
58 + </template>
59 + </div>
60 +
61 + <div class="modal-footer" data-modal-footer>
62 + <button class="btn btn-cancel" @click="window.closeModal?.()">Close</button>
63 + </div>
64 +
65 + <style>
66 + .plugin-info-header {
67 + display: flex;
68 + flex-wrap: wrap;
69 + align-items: baseline;
70 + justify-content: space-between;
71 + gap: 0.75rem;
72 + margin-bottom: 0.5rem;
73 + }
74 +
75 + .plugin-info-title {
76 + margin: 0;
77 + font-size: 1.1rem;
78 + }
79 +
80 + .plugin-info-source {
81 + color: var(--color-text-secondary);
82 + font-size: var(--font-size-small);
83 + }
84 +
85 + .plugin-info-description {
86 + margin-bottom: 1rem;
87 + color: var(--color-text-secondary);
88 + }
89 +
90 + .plugin-info-grid {
91 + display: grid;
92 + grid-template-columns: 12rem 1fr;
93 + gap: 0.5rem 0.75rem;
94 + }
95 +
96 + .plugin-info-label {
97 + font-weight: 600;
98 + color: var(--color-text-secondary);
99 + }
100 +
101 + .plugin-info-value {
102 + min-width: 0;
103 + word-break: break-word;
104 + overflow-wrap: anywhere;
105 + }
106 +
107 + @media (max-width: 640px) {
108 + .plugin-info-grid {
109 + grid-template-columns: 1fr;
110 + gap: 0.25rem;
111 + }
112 +
113 + .plugin-info-label {
114 + margin-top: 0.5rem;
115 + }
116 + }
117 + </style>
118 +</body>
119 +</html>
webui/components/plugins/plugin-settings-store.js
+3 -3
@@ -79,7 +79,7 @@ const model = {
79 method: "POST",
80 headers: { "Content-Type": "application/json" },
81 body: JSON.stringify({
82 - action: "get_settings",
82 + action: "get_config",
83 plugin_name: this.pluginName,
84 project_name: this.projectName || "",
85 agent_profile: this.agentProfileKey || "",
@@ -117,7 +117,7 @@ const model = {
117 method: "POST",
118 headers: { "Content-Type": "application/json" },
119 body: JSON.stringify({
120 - action: "save_settings",
120 + action: "save_config",
121 plugin_name: this.pluginName,
122 project_name: this.projectName || "",
123 agent_profile: this.agentProfileKey || "",
@@ -144,7 +144,7 @@ const model = {
144 // Reactive URL for the plugin's settings component (used with x-html injection)
145 get settingsComponentHtml() {
146 if (!this.pluginName) return "";
147 - return `<x-component path="/plugins/${this.pluginName}/webui/settings.html"></x-component>`;
147 + return `<x-component path="/plugins/${this.pluginName}/webui/config.html"></x-component>`;
148 },
149 };
150
webui/components/plugins/plugin-settings.html
+1 -1
@@ -52,7 +52,7 @@
52 <span>Loading settings...</span>
53 </div>
54
55 - <!-- Plugin settings body: plugin provides /plugins/<name>/webui/settings.html -->
55 + <!-- Plugin settings body: plugin provides /plugins/<name>/webui/config.html -->
56 <div x-show="!$store.pluginSettings.isLoading"
57 class="plugin-settings-body"
58 x-html="$store.pluginSettings.settingsComponentHtml">
webui/components/settings/plugins/plugins-subsection.html
+16 -7
@@ -25,21 +25,30 @@
25 if (!this.tab) return;
26 this.loading = true;
27 try {
28 - const r = await globalThis.fetchApi('/plugins', {
28 + const r = await globalThis.fetchApi('/plugins_list', {
29 method: 'POST',
30 headers: { 'Content-Type': 'application/json' },
31 - body: JSON.stringify({ action: 'list', tab: this.tab }),
31 + body: JSON.stringify({
32 + filter: { custom: true, builtin: true },
33 + }),
34 });
35 const data = await r.json().catch(() => ({}));
34 - this.plugins = (data.ok ? data.data || [] : []).filter(p => p.has_settings_ui);
36 + const plugins = data.ok ? (data.plugins || []) : [];
37 + this.plugins = plugins.filter((plugin) => {
38 + const sections = Array.isArray(plugin.settings_sections)
39 + ? plugin.settings_sections
40 + : [];
41 + return plugin.has_config_screen && sections.includes(this.tab);
42 + });
43 } catch {
44 this.plugins = [];
45 } finally {
46 this.loading = false;
47 }
48 },
41 - async openSettings(pluginName) {
42 - await Alpine.store('pluginSettings')?.open(pluginName);
49 + async openSettings(plugin) {
50 + if (!plugin?.name) return;
51 + await Alpine.store('pluginSettings')?.open(plugin.name);
52 window.openModal?.('components/plugins/plugin-settings.html');
53 }
54 }" x-init="init()">
@@ -55,13 +64,13 @@
64 <span>Loading...</span>
65 </div>
66
58 - <template x-for="plugin in plugins" :key="plugin.name">
67 + <template x-for="plugin in plugins" :key="plugin.path || plugin.name">
68 <div class="plugin-subsection-row">
69 <div class="plugin-subsection-info">
70 <span class="plugin-subsection-name" x-text="plugin.display_name || plugin.name"></span>
71 <span class="plugin-subsection-desc" x-text="plugin.description"></span>
72 </div>
64 - <button class="button" @click="openSettings(plugin.name)">
73 + <button class="button" @click="openSettings(plugin)">
74 <span class="icon material-symbols-outlined">settings</span>
75 Settings
76 </button>