Refactor plugin/project helpers; add plugin UI

Major refactor of plugin and project helper APIs and add a plugin management UI. Key changes: - Rename project meta helpers from get_project_meta_folder -> get_project_meta and update callers across many modules (projects, memory, skills_import, secrets, subagents, skills). - Overhaul python/helpers/plugins.py: introduce PluginMetadata and PluginListItem (Pydantic), new get_plugins_list/get_enhanced_plugins_list, support for reading plugin config (config.json), enable/disable logic (.enabled/.disabled), get_enabled_plugin_paths(), and helpers to find/read/save plugin assets. Plugin discovery now supports webui/main/config detection. - Use enabled-plugin-aware lookups in subagents, extension loading, and other places (plugins.get_enabled_plugin_paths / get_enhanced_plugins_list used instead of previous list_plugins/get_plugin_paths where appropriate). - Add API endpoint python/api/plugins_list.py to return JSON plugin lists. - Add frontend plugin management UI: web components webui/components/plugins/list/plugin-list.html and pluginListStore.js; wire quick-actions button to open the plugins modal. - Add new webui assets and config pages for example_agent and memory plugins; add plugins/plugin.json metadata for example_agent and memory. - Memory plugin fixes: updated calls to use get_project_meta and adjusted memory path helpers to use project meta layout. - File helper: add read_file_json to read JSON files directly. - run_ui: tightened plugin asset serving security (only serve from plugin webui or plugin extensions/webui), added unified _serve_plugin_asset helper, and load plugin API handlers using enhanced plugin list. - Small fixes: adjustments to parse_prompt/read_prompt/tool lookup to use updated subagents.get_paths signature, extension loader now uses enabled plugin paths, and web UI extensions JS updated to expect string paths. These changes centralize project meta handling, improve plugin discovery and enable/disable behavior, and add a basic plugin management UI and API.

frdel committed Feb 19, 2026 at 17:20 UTC 5acb733b9e6e62c6e54422578c33b389d8925752
22 files changed +688 -143
agent.py
+3 -3
@@ -646,7 +646,7 @@ class Agent:
646 return system_prompt
647
648 def parse_prompt(self, _prompt_file: str, **kwargs):
649 - dirs = subagents.get_paths(self, "prompts", include_plugins=True)
649 + dirs = subagents.get_paths(self, "prompts")
650
651 prompt = files.parse_file(
652 _prompt_file, _directories=dirs, _agent=self, **kwargs
@@ -654,7 +654,7 @@ class Agent:
654 return prompt
655
656 def read_prompt(self, file: str, **kwargs) -> str:
657 - dirs = subagents.get_paths(self, "prompts", include_plugins=True)
657 + dirs = subagents.get_paths(self, "prompts")
658
659 prompt = files.read_prompt_file(file, _directories=dirs, _agent=self, **kwargs)
660 if files.is_full_json_template(prompt):
@@ -990,7 +990,7 @@ class Agent:
990 classes = []
991
992 # search for tools in agent's folder hierarchy
993 - paths = subagents.get_paths(self, "tools", name + ".py", default_root="python", include_plugins=True)
993 + paths = subagents.get_paths(self, "tools", name + ".py", default_root="python")
994
995 for path in paths:
996 try:
plugins/example_agent/plugin.json
+5
@@ -0,0 +1,5 @@
1 +{
2 + "description": "Example Agent Plugin",
3 + "per_project_config": true,
4 + "per_agent_config": false
5 +}
plugins/example_agent/webui/config.html new
+14
@@ -0,0 +1,14 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Example Agent Configuration</title>
7 +</head>
8 +<body>
9 + <div class="container">
10 + <h1>Example Agent Plugin Configuration</h1>
11 + <p>This is a dummy configuration page for the example agent plugin.</p>
12 + </div>
13 +</body>
14 +</html>
plugins/memory/api/knowledge_path_get.py
+1 -1
@@ -12,7 +12,7 @@ class GetKnowledgePath(ApiHandler):
12
13 project_name = projects.get_context_project_name(context)
14 if project_name:
15 - knowledge_folder = projects.get_project_meta_folder(project_name, "knowledge")
15 + knowledge_folder = projects.get_project_meta(project_name, "knowledge")
16 else:
17 knowledge_folder = get_custom_knowledge_subdir_abs(context.agent0)
18
plugins/memory/helpers/memory.py
+8 -8
@@ -486,9 +486,9 @@ def reload():
486 def abs_db_dir(memory_subdir: str) -> str:
487 # patch for projects, this way we don't need to re-work the structure of memory subdirs
488 if memory_subdir.startswith("projects/"):
489 - from python.helpers.projects import get_project_meta_folder
489 + from python.helpers.projects import get_project_meta
490
491 - return files.get_abs_path(get_project_meta_folder(memory_subdir[9:]), "memory")
491 + return files.get_abs_path(get_project_meta(memory_subdir[9:]), "memory")
492 # standard subdirs
493 return files.get_abs_path("usr/memory", memory_subdir)
494
@@ -496,10 +496,10 @@ def abs_db_dir(memory_subdir: str) -> str:
496 def abs_knowledge_dir(knowledge_subdir: str, *sub_dirs: str) -> str:
497 # patch for projects, this way we don't need to re-work the structure of knowledge subdirs
498 if knowledge_subdir.startswith("projects/"):
499 - from python.helpers.projects import get_project_meta_folder
499 + from python.helpers.projects import get_project_meta
500
501 return files.get_abs_path(
502 - get_project_meta_folder(knowledge_subdir[9:]), "knowledge", *sub_dirs
502 + get_project_meta(knowledge_subdir[9:]), "knowledge", *sub_dirs
503 )
504 # standard subdirs
505 if knowledge_subdir == "default":
@@ -536,7 +536,7 @@ def get_context_memory_subdir(context: AgentContext) -> str:
536 def get_existing_memory_subdirs() -> list[str]:
537 try:
538 from python.helpers.projects import (
539 - get_project_meta_folder,
539 + get_project_meta,
540 get_projects_parent_folder,
541 )
542
@@ -546,7 +546,7 @@ def get_existing_memory_subdirs() -> list[str]:
546 project_subdirs = files.get_subdirectories(get_projects_parent_folder())
547 for project_subdir in project_subdirs:
548 if files.exists(
549 - get_project_meta_folder(project_subdir), "memory", "index.faiss"
549 + get_project_meta(project_subdir), "memory", "index.faiss"
550 ):
551 subdirs.append(f"projects/{project_subdir}")
552
@@ -564,7 +564,7 @@ def get_knowledge_subdirs_by_memory_subdir(
564 memory_subdir: str, default: list[str]
565 ) -> list[str]:
566 if memory_subdir.startswith("projects/"):
567 - from python.helpers.projects import get_project_meta_folder
567 + from python.helpers.projects import get_project_meta
568
569 - default.append(get_project_meta_folder(memory_subdir[9:], "knowledge"))
569 + default.append(get_project_meta(memory_subdir[9:], "knowledge"))
570 return default
plugins/memory/plugin.json
+5
@@ -0,0 +1,5 @@
1 +{
2 + "description": "Memory Plugin",
3 + "per_project_config": true,
4 + "per_agent_config": true
5 +}
\ No newline at end of file
plugins/memory/webui/config.html new
+14
@@ -0,0 +1,14 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Memory Configuration</title>
7 +</head>
8 +<body>
9 + <div class="container">
10 + <h1>Memory Plugin Configuration</h1>
11 + <p>This is a dummy configuration page for the memory plugin.</p>
12 + </div>
13 +</body>
14 +</html>
plugins/memory/webui/main.html new
+11
@@ -0,0 +1,11 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Memory Dashboard</title>
7 +</head>
8 +<body>
9 + <x-component path="/plugins/memory/webui/memory-dashboard.html"></x-component>
10 +</body>
11 +</html>
python/api/plugins_list.py new
+13
@@ -0,0 +1,13 @@
1 +from python.helpers.api import ApiHandler, Input, Output, Request
2 +from python.helpers import plugins
3 +
4 +class PluginsList(ApiHandler):
5 + async def process(self, input: Input, request: Request) -> Output:
6 + filter = input.get("filter", {})
7 +
8 + custom = filter.get("custom", False)
9 + builtin = filter.get("builtin", False)
10 +
11 + plugin_list = plugins.get_enhanced_plugins_list(custom=custom, builtin=builtin)
12 +
13 + return {"ok": True, "plugins": [p.model_dump(mode="json") for p in plugin_list]}
python/api/skills.py
+1 -1
@@ -44,7 +44,7 @@ class Skills(ApiHandler):
44 ]
45 if project_name:
46 roots.append(
47 - projects.get_project_meta_folder(project_name, "agents", agent_profile, "skills")
47 + projects.get_project_meta(project_name, "agents", agent_profile, "skills")
48 )
49
50 skill_list = [
python/helpers/extension.py
+1 -1
@@ -33,7 +33,7 @@ async def call_extensions(
33 paths = subagents.get_paths(agent, "extensions", extension_point, default_root="python")
34
35 # Add plugin backend extension paths (plugins/*/extensions/python/{extension_point})
36 - plugin_paths = plugins.get_plugin_paths("extensions", "python", extension_point)
36 + plugin_paths = plugins.get_enabled_plugin_paths(agent, "extensions", "python", extension_point)
37 paths.extend(p for p in plugin_paths if p not in paths)
38
39 all_exts = [cls for path in paths for cls in _get_extensions(path)]
python/helpers/files.py
+7
@@ -216,6 +216,13 @@ def read_file(relative_path: str, encoding="utf-8"):
216 with open(absolute_path, "r", encoding=encoding) as f:
217 return f.read()
218
219 +def read_file_json(relative_path: str, encoding="utf-8"):
220 + # Try to get the absolute path for the file from the original directory or backup directories
221 + absolute_path = get_abs_path(relative_path)
222 +
223 + # Read the file content
224 + with open(absolute_path, "r", encoding=encoding) as f:
225 + return json.load(f)
226
227 def read_file_bin(relative_path: str):
228 # Try to get the absolute path for the file from the original directory or backup directories
python/helpers/plugins.py
+198 -87
@@ -1,11 +1,11 @@
1 from __future__ import annotations
2
3 import re, json
4 -from dataclasses import dataclass
4 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
9
10 if TYPE_CHECKING:
11 from agent import Agent
@@ -17,150 +17,257 @@ _META_TARGET_RE = re.compile(
17 )
18
19 META_FILE_NAME = "plugin.json"
20 -SETTINGS_FILE_NAME = "settings.json"
20 +CONFIG_FILE_NAME = "config.json"
21 +DISABLED_FILE_NAME = ".disabled"
22 +ENABLED_FILE_NAME = ".enabled"
23
22 -@dataclass(slots=True)
23 -class PluginListItem:
24 +
25 +class PluginMetadata(BaseModel):
26 + description: str = ""
27 +
28 +
29 +class PluginListItem(BaseModel):
30 name: str
25 - path: Path
31 + path: str
32 + description: str = ""
33 + has_main_screen: bool = False
34 + has_config_screen: bool = False
35
36
37 def get_plugin_roots() -> List[str]:
38 """Plugin root directories, ordered by priority (user first)."""
30 - # Project-specific plugins (commented out for now, will add project/agent plugins later)
31 - # projects = files.find_existing_paths_by_pattern("usr/projects/*/.a0proj/plugins")
39 return [
33 - # *projects,
40 files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR),
41 files.get_abs_path(files.PLUGINS_DIR),
42 ]
43
44
39 -def list_plugins() -> List[PluginListItem]:
40 - """Discover plugins by directory convention. First root wins on ID conflict."""
41 - by_id: Dict[str, PluginListItem] = {}
45 +def get_plugins_list():
46 + result: list[str] = []
47 + seen_names: set[str] = set()
48 for root in get_plugin_roots():
43 - root_path = Path(root)
44 - if not root_path.exists():
45 - continue
46 - for d in sorted(root_path.iterdir(), key=lambda p: p.name):
47 - if not d.is_dir() or d.name.startswith("."):
49 + for dir in Path(root).iterdir():
50 + if not dir.is_dir() or dir.name.startswith("."):
51 + continue
52 + if dir.name in seen_names:
53 continue
49 - if d.name not in by_id:
50 - by_id[d.name] = PluginListItem(name=d.name, path=d)
51 - return list(by_id.values())
54 + if files.exists(str(dir), META_FILE_NAME):
55 + seen_names.add(dir.name)
56 + result.append(dir.name)
57 + result.sort(key=lambda p: Path(p).name)
58 + return result
59 +
60 +
61 +def get_enhanced_plugins_list(
62 + custom: bool = True, builtin: bool = True
63 +) -> List[PluginListItem]:
64 + """Discover plugins by directory convention. First root wins on ID conflict."""
65 + results = []
66 +
67 + def load_plugins(root_path: str):
68 + for d in sorted(Path(root_path).iterdir(), key=lambda p: p.name):
69 + try:
70 + if not d.is_dir() or d.name.startswith("."):
71 + continue
72 + meta = PluginMetadata.model_validate(
73 + files.read_file_json(str(d / META_FILE_NAME))
74 + )
75 + has_main_screen = files.exists(str(d / "webui" / "main.html"))
76 + has_config_screen = files.exists(str(d / "webui" / "config.html"))
77 + results.append(
78 + PluginListItem(
79 + name=d.name,
80 + path=str(d),
81 + description=meta.description,
82 + has_main_screen=has_main_screen,
83 + has_config_screen=has_config_screen,
84 + )
85 + )
86 + except:
87 + pass
88 +
89 + if custom:
90 + load_plugins(files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR))
91 + if builtin:
92 + load_plugins(files.get_abs_path(files.PLUGINS_DIR))
93 + return results
94
95
96 def find_plugin_dir(plugin_name: str):
55 - """Find a single plugin by ID."""
97 if not plugin_name:
98 return None
99
100 # check if the plugin is in the user directory
60 - user_plugin_path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, META_FILE_NAME)
101 + user_plugin_path = files.get_abs_path(
102 + files.USER_DIR, files.PLUGINS_DIR, plugin_name, META_FILE_NAME
103 + )
104 if files.exists(user_plugin_path):
105 return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name)
63 -
106 +
107 # check if the plugin is in the default directory
65 - default_plugin_path = files.get_abs_path(files.PLUGINS_DIR, plugin_name, META_FILE_NAME)
108 + default_plugin_path = files.get_abs_path(
109 + files.PLUGINS_DIR, plugin_name, META_FILE_NAME
110 + )
111 if files.exists(default_plugin_path):
112 return files.get_abs_path(files.PLUGINS_DIR, plugin_name)
68 -
113 +
114 return None
115
116
117 def get_plugin_paths(*subpaths: str) -> List[str]:
73 - """
74 - Resolve existing directories under each plugin matching subpaths.
75 -
76 - Example:
77 - get_plugin_paths("extensions", "python", "monologue_end")
78 - -> ["/abs/plugins/memory/extensions/python/monologue_end", ...]
79 - """
80 - sub = "/".join(subpaths) if subpaths else ""
118 + sub = "*/" + "/".join(subpaths) if subpaths else "*"
119 paths: List[str] = []
82 - for plugin in list_plugins():
83 - candidate = str(plugin.path / sub) if sub else str(plugin.path)
84 - if Path(candidate).is_dir() and candidate not in paths:
85 - paths.append(candidate)
120 + for root in get_plugin_roots():
121 + paths.extend(
122 + files.find_existing_paths_by_pattern(files.get_abs_path(root, sub))
123 + )
124 return paths
125
126 +def get_enabled_plugin_paths(agent:Agent|None, *subpaths: str) -> List[str]:
127 + enabled = get_enabled_plugins(agent)
128 + paths: list[str] = []
129
89 -def get_webui_extensions(extension_point:str, filters:List[str]|None=None) -> List[Dict[str, Any]]:
90 - entries: List[Dict[str, Any]] = []
91 - effective_filters = filters or ["*"]
92 - for plugin in list_plugins():
93 - frontend_dir = plugin.path / "extensions" / "webui" / extension_point
94 - if not frontend_dir.is_dir():
130 + for plugin in enabled:
131 + base_dir = find_plugin_dir(plugin)
132 + if not base_dir:
133 continue
96 - matched_files: List[Path] = []
97 - seen: set[str] = set()
98 - for pattern in effective_filters:
99 - for p in frontend_dir.rglob(pattern):
100 - if not p.is_file():
101 - continue
102 - p_str = str(p)
103 - if p_str in seen:
104 - continue
105 - seen.add(p_str)
106 - matched_files.append(p)
134
108 - for ext_file in sorted(matched_files, key=lambda p: p.name):
109 - try:
110 - rel_path = files.deabsolute_path(str(ext_file))
111 - entry: Dict[str, Any] = {
112 - "plugin_name": plugin.name,
113 - "path": rel_path,
114 - }
115 - entries.append(entry)
116 - except Exception:
117 - print_style.PrintStyle.error(f"Failed to load frontend extension file {ext_file}")
135 + if not subpaths:
136 + if files.exists(base_dir):
137 + paths.append(base_dir)
138 + continue
139 +
140 + path = files.get_abs_path(base_dir, *subpaths)
141 + if files.exists(path):
142 + paths.append(path)
143 +
144 + return paths
145 +
146 +
147 +def get_enabled_plugins(agent: Agent | None):
148 + plugins = get_plugins_list()
149 + active = []
150 +
151 + if agent:
152 + from python.helpers import subagents
153 +
154 + for plugin in plugins:
155 + # plugins are toggled via .enabled / .disabled files
156 + # every plugin is on by default, unless disabled in usr dir
157 + enabled = True
158 +
159 + if agent:
160 + agent_paths = subagents.get_paths(
161 + agent,
162 + files.PLUGINS_DIR,
163 + plugin,
164 + must_exist_completely=True,
165 + include_default=False,
166 + include_user=True,
167 + include_plugins=False,
168 + include_project=True
169 + )
170 +
171 + # go through agent paths in reverse order and determine the state
172 + for agent_path in reversed(agent_paths):
173 + if enabled:
174 + enabled = not files.exists(files.get_abs_path(agent_path, DISABLED_FILE_NAME))
175 + else:
176 + enabled = files.exists(files.get_abs_path(agent_path, ENABLED_FILE_NAME))
177 +
178 +
179 + if enabled:
180 + active.append(plugin)
181 +
182 + return active
183 +
184 +
185 +
186 +def get_webui_extensions(extension_point: str, filters: List[str] | None = None):
187 + entries: List[str] = []
188 + effective_filters = filters or ["*"]
189 +
190 + for filter in effective_filters:
191 + extensions = get_plugin_paths("extensions", "webui", extension_point, filter)
192 + for extension in extensions:
193 + rel_path = files.deabsolute_path(extension)
194 + entries.append(rel_path)
195 +
196 return entries
197
120 -def get_plugin_settings(plugin_name:str, agent:Agent|None):
121 - file_path = find_plugin_file(plugin_name, SETTINGS_FILE_NAME, agent=agent)
198 +
199 +def get_plugin_config(plugin_name: str, agent: Agent | None):
200 + file_path = find_plugin_asset(plugin_name, CONFIG_FILE_NAME, agent=agent)
201 if file_path:
202 return json.loads(files.read_file(file_path))
203 return None
204
126 -def save_plugin_settings(plugin_name:str, project_name:str, agent_profile:str, settings:dict):
127 - file_path = determine_plugin_save_file_path(plugin_name, project_name, agent_profile, SETTINGS_FILE_NAME)
205 +
206 +def save_plugin_config(
207 + plugin_name: str, project_name: str, agent_profile: str, settings: dict
208 +):
209 + file_path = determine_plugin_asset_path(
210 + plugin_name, project_name, agent_profile, CONFIG_FILE_NAME
211 + )
212 if file_path:
213 files.write_file(file_path, json.dumps(settings))
214
131 -def find_plugin_file(plugin_name:str, *subpaths:str, agent:Agent|None=None):
132 - profile_name = agent.config.profile if agent and agent.config.profile else ""
215 +
216 +def find_plugin_asset(plugin_name: str, *subpaths: str, agent: Agent | None = None):
217 project_name = ""
218
219 if agent:
220 + profile_name = agent.config.profile if agent and agent.config.profile else ""
221 +
222 from python.helpers import projects
223 +
224 project_name = projects.get_context_project_name(agent.context) or ""
225
226 if project_name and profile_name:
227 # project/.a0proj/agents/<profile>/plugins/<plugin_name>/...
141 - project_agent_file = projects.get_project_meta_folder(
142 - project_name, files.AGENTS_DIR, profile_name, files.PLUGINS_DIR, plugin_name, *subpaths
228 + project_agent_file = projects.get_project_meta(
229 + project_name,
230 + files.AGENTS_DIR,
231 + profile_name,
232 + files.PLUGINS_DIR,
233 + plugin_name,
234 + *subpaths,
235 )
236 if files.exists(project_agent_file):
237 return project_agent_file
238
239 if project_name:
240 # project/.a0proj/plugins/<plugin_name>/...
149 - project_file = projects.get_project_meta_folder(project_name, files.PLUGINS_DIR, plugin_name, *subpaths)
241 + project_file = projects.get_project_meta(
242 + project_name, files.PLUGINS_DIR, plugin_name, *subpaths
243 + )
244 if files.exists(project_file):
245 return project_file
246
153 - if profile_name:
154 - from python.helpers import subagents
155 - # usr/agents/<profile>/plugins/<plugin_name>/...
156 - path = files.get_abs_path(subagents.USER_AGENTS_DIR, profile_name, files.PLUGINS_DIR, plugin_name, *subpaths)
157 - if files.exists(path):
158 - return path
247 + if profile_name:
248 + from python.helpers import subagents
249
160 - # agents/<profile>/plugins/<plugin_name>/...
161 - path = files.get_abs_path(subagents.DEFAULT_AGENTS_DIR, profile_name, files.PLUGINS_DIR, plugin_name, *subpaths)
162 - if files.exists(path):
163 - return path
250 + # usr/agents/<profile>/plugins/<plugin_name>/...
251 + path = files.get_abs_path(
252 + subagents.USER_AGENTS_DIR,
253 + profile_name,
254 + files.PLUGINS_DIR,
255 + plugin_name,
256 + *subpaths,
257 + )
258 + if files.exists(path):
259 + return path
260 +
261 + # agents/<profile>/plugins/<plugin_name>/...
262 + path = files.get_abs_path(
263 + subagents.DEFAULT_AGENTS_DIR,
264 + profile_name,
265 + files.PLUGINS_DIR,
266 + plugin_name,
267 + *subpaths,
268 + )
269 + if files.exists(path):
270 + return path
271
272 # usr/plugins/<plugin_name>/...
273 path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, *subpaths)
@@ -173,15 +280,19 @@ def find_plugin_file(plugin_name:str, *subpaths:str, agent:Agent|None=None):
280 return path
281
282 return None
176 -
177 -def determine_plugin_save_file_path(plugin_name:str, project_name:str, agent_profile:str, *subpaths:str):
283 +
284 +
285 +def determine_plugin_asset_path(
286 + plugin_name: str, project_name: str, agent_profile: str, *subpaths: str
287 +):
288 base_path = files.get_abs_path(files.USER_DIR)
179 -
289 +
290 if project_name:
291 from python.helpers import projects
182 - base_path = projects.get_project_meta_folder(project_name)
292 +
293 + base_path = projects.get_project_meta(project_name)
294
295 if agent_profile:
296 base_path = files.get_abs_path(base_path, files.AGENTS_DIR, agent_profile)
297
187 - return files.get_abs_path(base_path, files.PLUGINS_DIR, plugin_name, *subpaths)
\ No newline at end of file
298 + return files.get_abs_path(base_path, files.PLUGINS_DIR, plugin_name, *subpaths)
python/helpers/projects.py
+8 -8
@@ -67,7 +67,7 @@ def get_project_folder(name: str):
67 return files.get_abs_path(get_projects_parent_folder(), name)
68
69
70 -def get_project_meta_folder(name: str, *sub_dirs: str):
70 +def get_project_meta(name: str, *sub_dirs: str):
71 return files.get_abs_path(get_project_folder(name), PROJECT_META_DIR, *sub_dirs)
72
73
@@ -398,20 +398,20 @@ def get_context_project_name(context: "AgentContext") -> str | None:
398
399 def load_project_variables(name: str):
400 try:
401 - abs_path = files.get_abs_path(get_project_meta_folder(name), "variables.env")
401 + abs_path = files.get_abs_path(get_project_meta(name), "variables.env")
402 return files.read_file(abs_path)
403 except Exception:
404 return ""
405
406
407 def save_project_variables(name: str, variables: str):
408 - abs_path = files.get_abs_path(get_project_meta_folder(name), "variables.env")
408 + abs_path = files.get_abs_path(get_project_meta(name), "variables.env")
409 files.write_file(abs_path, variables)
410
411
412 def load_project_subagents(name: str) -> dict[str, SubAgentSettings]:
413 try:
414 - abs_path = files.get_abs_path(get_project_meta_folder(name), "agents.json")
414 + abs_path = files.get_abs_path(get_project_meta(name), "agents.json")
415 data = dirty_json.parse(files.read_file(abs_path))
416 if isinstance(data, dict):
417 return _normalize_subagents(data) # type: ignore[arg-type,return-value]
@@ -421,7 +421,7 @@ def load_project_subagents(name: str) -> dict[str, SubAgentSettings]:
421
422
423 def save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings]):
424 - abs_path = files.get_abs_path(get_project_meta_folder(name), "agents.json")
424 + abs_path = files.get_abs_path(get_project_meta(name), "agents.json")
425 normalized = _normalize_subagents(subagents_data)
426 content = dirty_json.stringify(normalized)
427 files.write_file(abs_path, content)
@@ -475,15 +475,15 @@ def get_context_memory_subdir(context: "AgentContext") -> str | None:
475
476 def create_project_meta_folders(name: str):
477 # create instructions folder
478 - files.create_dir(get_project_meta_folder(name, PROJECT_INSTRUCTIONS_DIR))
478 + files.create_dir(get_project_meta(name, PROJECT_INSTRUCTIONS_DIR))
479
480 # create knowledge folders (plugins create their own subdirs lazily)
481 - files.create_dir(get_project_meta_folder(name, PROJECT_KNOWLEDGE_DIR))
481 + files.create_dir(get_project_meta(name, PROJECT_KNOWLEDGE_DIR))
482
483
484 def get_knowledge_files_count(name: str):
485 knowledge_folder = files.get_abs_path(
486 - get_project_meta_folder(name, PROJECT_KNOWLEDGE_DIR)
486 + get_project_meta(name, PROJECT_KNOWLEDGE_DIR)
487 )
488 return len(files.list_files_in_dir_recursively(knowledge_folder))
489
python/helpers/secrets.py
+2 -2
@@ -519,7 +519,7 @@ def get_secrets_manager(context: "AgentContext|None" = None) -> SecretsManager:
519 if context:
520 project = projects.get_context_project_name(context)
521 if project:
522 - secret_files.append(files.get_abs_path(projects.get_project_meta_folder(project), "secrets.env"))
522 + secret_files.append(files.get_abs_path(projects.get_project_meta(project), "secrets.env"))
523
524 return SecretsManager.get_instance(*secret_files)
525
@@ -533,7 +533,7 @@ def get_project_secrets_manager(project_name: str, merge_with_global: bool = Fal
533 secret_files.append(DEFAULT_SECRETS_FILE)
534
535 # merged with project secrets if active
536 - secret_files.append(files.get_abs_path(projects.get_project_meta_folder(project_name), "secrets.env"))
536 + secret_files.append(files.get_abs_path(projects.get_project_meta(project_name), "secrets.env"))
537
538 return SecretsManager.get_instance(*secret_files)
539
python/helpers/skills_import.py
+4 -4
@@ -172,8 +172,8 @@ def _resolve_conflict(dest: Path, policy: ConflictPolicy) -> Tuple[Path, bool]:
172
173 def get_project_skills_folder(project_name: str) -> Path:
174 """Get the skills folder path for a project."""
175 - from python.helpers.projects import get_project_meta_folder
176 - return Path(get_project_meta_folder(project_name, PROJECT_SKILLS_DIR))
175 + from python.helpers.projects import get_project_meta
176 + return Path(get_project_meta(project_name, PROJECT_SKILLS_DIR))
177
178
179 def get_agent_profile_skills_folder(profile_name: str) -> Path:
@@ -181,8 +181,8 @@ def get_agent_profile_skills_folder(profile_name: str) -> Path:
181
182
183 def get_project_agent_profile_skills_folder(project_name: str, profile_name: str) -> Path:
184 - from python.helpers.projects import get_project_meta_folder
185 - return Path(get_project_meta_folder(project_name, "agents", profile_name, "skills"))
184 + from python.helpers.projects import get_project_meta
185 + return Path(get_project_meta(project_name, "agents", profile_name, "skills"))
186
187
188 def resolve_skills_destination_root(
python/helpers/subagents.py
+16 -14
@@ -64,7 +64,7 @@ def get_agents_dict(
64 merged: dict[str, SubAgentListItem] = dict(default_agents)
65
66 # merge with plugin agents
67 - for plugin_dir in plugins.get_plugin_paths("agents"):
67 + for plugin_dir in plugins.get_enabled_plugin_paths(None, "agents"):
68 plugin_agents = _get_agents_list_from_dir(plugin_dir, origin="plugin")
69 merged = _merge_agent_dicts(merged, plugin_agents)
70
@@ -75,7 +75,7 @@ def get_agents_dict(
75 if project_name:
76 from python.helpers import projects
77
78 - project_agents_dir = projects.get_project_meta_folder(project_name, "agents")
78 + project_agents_dir = projects.get_project_meta(project_name, "agents")
79 project_agents = _get_agents_list_from_dir(project_agents_dir, origin="project")
80 merged = _merge_agent_dicts(merged, project_agents)
81
@@ -120,7 +120,8 @@ def load_agent_data(name: str, project_name: str | None = None) -> SubAgent:
120 merged = default_agent
121
122 # merge with plugin agents
123 - for plugin_dir in plugins.get_plugin_paths("agents"):
123 + # TODO review this
124 + for plugin_dir in plugins.get_enabled_plugin_paths(None, "agents"):
125 plugin_agent = _load_agent_data_from_dir(plugin_dir, name, origin="plugin")
126 merged = _merge_agent(merged, plugin_agent)
127
@@ -131,7 +132,7 @@ def load_agent_data(name: str, project_name: str | None = None) -> SubAgent:
132 if project_name:
133 from python.helpers import projects
134
134 - project_agents_dir = projects.get_project_meta_folder(project_name, "agents")
135 + project_agents_dir = projects.get_project_meta(project_name, "agents")
136 project_agent = _load_agent_data_from_dir(
137 project_agents_dir, name, origin="project"
138 )
@@ -242,7 +243,7 @@ def _merge_agent_list_items(
243 def get_agents_roots() -> list[str]:
244 from python.helpers import plugins
245
245 - plugin_agents = plugins.get_plugin_paths("agents")
246 + plugin_agents = plugins.get_enabled_plugin_paths(None, "agents")
247 project_agents = files.find_existing_paths_by_pattern("usr/projects/*/.a0proj/agents")
248 paths = [
249 files.get_abs_path(DEFAULT_AGENTS_DIR),
@@ -328,7 +329,7 @@ def get_paths(
329 include_project: bool = True,
330 include_user: bool = True,
331 include_default: bool = True,
331 - include_plugins: bool = False,
332 + include_plugins: bool = True,
333 default_root: str = "",
334 ) -> list[str]:
335 """Returns list of file paths for the given agent and subpaths, searched in order of priority:
@@ -345,7 +346,7 @@ def get_paths(
346
347 if project_name and profile_name:
348 # project/agents/<profile>/...
348 - project_agent_dir = projects.get_project_meta_folder(
349 + project_agent_dir = projects.get_project_meta(
350 project_name, "agents", profile_name
351 )
352 if files.exists(files.get_abs_path(project_agent_dir, *check_subpaths)):
@@ -353,7 +354,7 @@ def get_paths(
354
355 if project_name:
356 # project/.a0proj/...
356 - path = projects.get_project_meta_folder(project_name, *subpaths)
357 + path = projects.get_project_meta(project_name, *subpaths)
358 if (not must_exist_completely) or files.exists(path):
359 paths.append(path)
360
@@ -365,11 +366,12 @@ def get_paths(
366 paths.append(path)
367
368 # plugin agents/<profile>/...
368 - from python.helpers import plugins
369 - for plugin_dir in plugins.get_plugin_paths("agents"):
370 - path = files.get_abs_path(plugin_dir, profile_name, *subpaths)
371 - if (not must_exist_completely) or files.exists(files.get_abs_path(plugin_dir, profile_name, *check_subpaths)):
372 - paths.append(path)
369 + if include_plugins:
370 + from python.helpers import plugins
371 + for plugin_dir in plugins.get_enabled_plugin_paths(agent, "agents", profile_name):
372 + path = files.get_abs_path(plugin_dir, *subpaths)
373 + if (not must_exist_completely) or files.exists(files.get_abs_path(plugin_dir, *check_subpaths)):
374 + paths.append(path)
375
376 # agents/<profile>/...
377 path = files.get_abs_path(DEFAULT_AGENTS_DIR, profile_name, *subpaths)
@@ -386,7 +388,7 @@ def get_paths(
388 # plugins/*/subpaths...
389 from python.helpers import plugins
390
389 - for plugin in plugins.list_plugins():
391 + for plugin in plugins.get_enhanced_plugins_list():
392 path = files.get_abs_path(str(plugin.path), *subpaths)
393 if (not must_exist_completely) or files.exists(path):
394 if path not in paths:
run_ui.py
+17 -5
@@ -8,8 +8,10 @@ from functools import wraps
8 import threading
9 import asyncio
10
11 +from pathlib import Path
12 import urllib.request
13 import urllib.error
14 +from regex.regex import F
15 import uvicorn
16 from flask import Flask, request, Response, session, redirect, url_for, render_template_string
17 from werkzeug.wrappers.response import Response as BaseResponse
@@ -240,7 +242,16 @@ async def serve_index():
242 # Serve plugin assets
243 @webapp.route("/plugins/<plugin_name>/<path:asset_path>", methods=["GET"])
244 @requires_auth
245 +async def serve_builtin_plugin_asset(plugin_name, asset_path):
246 + return await _serve_plugin_asset(plugin_name, asset_path)
247 +
248 +@webapp.route("/usr/plugins/<plugin_name>/<path:asset_path>", methods=["GET"])
249 +@requires_auth
250 async def serve_plugin_asset(plugin_name, asset_path):
251 + return await _serve_plugin_asset(plugin_name, asset_path)
252 +
253 +
254 +async def _serve_plugin_asset(plugin_name, asset_path):
255 """
256 Serve static assets from plugin directories.
257 Resolves using the plugin system (with overrides).
@@ -257,10 +268,11 @@ async def serve_plugin_asset(plugin_name, asset_path):
268 try:
269 # Construct path using plugin root
270 asset_file = files.get_abs_path(plugin_dir, asset_path)
260 - plugin_root = plugin_dir
271 + webui_dir = files.get_abs_path(plugin_dir, "webui")
272 + webui_extensions_dir = files.get_abs_path(plugin_dir, "extensions/webui")
273
262 - # Security: ensure the resolved path is within the plugin directory
263 - if not files.is_in_dir(str(asset_file), str(plugin_root)):
274 + # Security: ensure the resolved path is within the plugin webui directory
275 + if not files.is_in_dir(str(asset_file), str(webui_dir)) and not files.is_in_dir(str(asset_file), str(webui_extensions_dir)):
276 return Response("Access denied", 403)
277
278 if not files.is_file(asset_file):
@@ -499,8 +511,8 @@ def run():
511 # Load API handlers from plugins (prefixed with /plugins/{plugin_id}/)
512 from python.helpers import plugins
513
502 - for plugin in plugins.list_plugins():
503 - api_path = plugin.path / "api"
514 + for plugin in plugins.get_enhanced_plugins_list():
515 + api_path = Path(plugin.path) / "api"
516 if not api_path.exists() or not api_path.is_dir():
517 continue
518
webui/components/plugins/list/plugin-list.html new
+309
@@ -0,0 +1,309 @@
1 +<html>
2 +<head>
3 + <title>Plugins</title>
4 + <script type="module">
5 + import { store } from "/components/plugins/list/pluginListStore.js";
6 + </script>
7 +</head>
8 +
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: '' })">
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: '' })">
20 + Custom
21 + </button>
22 + </li>
23 + <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 + Builtin
29 + </button>
30 + </li>
31 + </ul>
32 +
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>
87 + </div>
88 +
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
97 + </button>
98 + </div>
99 + </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>
140 + </div>
141 + </div>
142 + </div>
143 +
144 + </div>
145 + </template>
146 + </div>
147 +
148 + <style>
149 + .nav {
150 + display: flex;
151 + padding-left: 0;
152 + margin: 0.75rem 0 0 0;
153 + list-style: none;
154 + border-bottom: 1px solid var(--color-border);
155 + gap: 0.25rem;
156 + }
157 +
158 + .nav-link {
159 + border: 1px solid transparent;
160 + border-top-left-radius: 4px;
161 + border-top-right-radius: 4px;
162 + padding: 0.4rem 0.7rem;
163 + background: transparent;
164 + color: var(--color-text-secondary);
165 + cursor: pointer;
166 + }
167 +
168 + .nav-link.active {
169 + color: var(--color-text-primary);
170 + border-color: var(--color-border);
171 + border-bottom-color: transparent;
172 + background: var(--color-bg-primary);
173 + }
174 +
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 +
187 + .plugins-toolbar {
188 + display: flex;
189 + align-items: center;
190 + gap: 0.75rem;
191 + flex-wrap: wrap;
192 + margin-top: 0.25rem;
193 + margin-bottom: 0.75rem;
194 + }
195 +
196 + .plugins-toolbar-actions {
197 + margin-left: auto;
198 + flex: 0 0 auto;
199 + }
200 +
201 + .plugins-list {
202 + margin-top: 0.25rem;
203 + }
204 +
205 + .plugin-card {
206 + border: 1px solid var(--color-border);
207 + border-radius: 4px;
208 + padding: 0.75rem;
209 + margin-top: 0.75rem;
210 + background: var(--color-bg-primary);
211 + }
212 +
213 + .plugin-header {
214 + display: flex;
215 + align-items: flex-start;
216 + justify-content: space-between;
217 + gap: 1rem;
218 + flex-wrap: nowrap;
219 + min-width: 0;
220 + }
221 +
222 + .plugin-heading {
223 + display: flex;
224 + flex-direction: column;
225 + min-width: 0;
226 + flex: 1 1 auto;
227 + }
228 +
229 + .plugin-title {
230 + font-weight: 600;
231 + font-size: 1rem;
232 + min-width: 0;
233 + flex: 1 1 auto;
234 + }
235 +
236 + .plugin-actions {
237 + display: flex;
238 + align-items: center;
239 + gap: 0.5rem;
240 + padding-bottom: var(--spacing-sm);
241 + flex-wrap: nowrap;
242 + min-width: 0;
243 + flex: 0 0 auto;
244 + max-width: 100%;
245 + justify-content: flex-end;
246 + }
247 +
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 +
282 + .plugin-description {
283 + margin-top: 0.35rem;
284 + color: var(--color-text-secondary);
285 + font-size: var(--font-size-small);
286 + }
287 +
288 + .plugin-path {
289 + margin-top: 0.35rem;
290 + font-size: 0.85rem;
291 + color: var(--color-text-muted);
292 + min-width: 0;
293 + max-width: 100%;
294 + white-space: pre-wrap;
295 + word-break: break-word;
296 + overflow-wrap: anywhere;
297 + }
298 +
299 + .loading {
300 + width: 100%;
301 + text-align: center;
302 + margin-top: 0.75rem;
303 + margin-bottom: 0.75rem;
304 + color: var(--color-secondary);
305 + }
306 + </style>
307 +</body>
308 +
309 +</html>
webui/components/plugins/list/pluginListStore.js new
+43
@@ -0,0 +1,43 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as api from "/js/api.js";
3 +import {
4 + store as notificationStore,
5 + defaultPriority,
6 +} from "/components/notifications/notification-store.js";
7 +
8 +// define the model object holding data and functions
9 +const model = {
10 + loading: false,
11 + plugins: [],
12 +
13 + async loadPluginList(filter) {
14 + this.loading = true;
15 + this.plugins = [];
16 + try {
17 + const response = await api.callJsonApi("plugins_list", { filter });
18 + this.plugins = response.plugins;
19 + } catch (e) {
20 + showErrorNotification(e, "Failed to load plugins list");
21 + } finally {
22 + this.loading = false;
23 + }
24 + },
25 +};
26 +
27 +function showErrorNotification(error, heading) {
28 + const text = error.message || error.text || JSON.stringify(error);
29 + notificationStore.frontendError(
30 + text,
31 + heading,
32 + 3,
33 + "pluginsList",
34 + defaultPriority,
35 + true,
36 + );
37 +}
38 +
39 +// convert it to alpine store
40 +const store = createStore("pluginListStore", model);
41 +
42 +// export for use in other files
43 +export { store };
webui/components/sidebar/top-section/quick-actions.html
+3 -3
@@ -20,8 +20,8 @@
20 </button>
21
22 <!-- Scheduler -->
23 - <button class="config-button" id="scheduler" @click="openModal('modals/scheduler/scheduler-modal.html')" title="Scheduler">
24 - <span class="material-symbols-outlined">schedule</span>
23 + <button class="config-button" id="plugins" @click="openModal('components/plugins/list/plugin-list.html')" title="Plugins">
24 + <span class="material-symbols-outlined">extension</span>
25 </button>
26
27 <!-- Settings -->
@@ -56,7 +56,7 @@
56 <span>Projects</span>
57 </button>
58
59 - <button class="dropdown-item" @click="openModal('modals/scheduler/scheduler-modal.html'); dropdownOpen = false">
59 + <button class="dropdown-item" @click="openModal('components/scheduler/scheduler-modal.html'); dropdownOpen = false">
60 <span class="material-symbols-outlined">schedule</span>
61 <span>Scheduler</span>
62 </button>
webui/js/extensions.js
+5 -6
@@ -1,8 +1,7 @@
1 import * as api from "./api.js";
2
3 /**
4 - * @typedef {Object} WebuiExtension
5 - * @property {string} path
4 + * @typedef {string} WebuiExtension
5 */
6
7
@@ -62,9 +61,9 @@ export async function loadJsExtensions(extensionPoint) {
61 });
62 /** @type {JsExtensionImport[]} */
63 const imports = await Promise.all(
65 - response.extensions.map(async extension => ({
66 - path: extension.path,
67 - module: await import(normalizePath(extension.path))
64 + response.extensions.map(async (path) => ({
65 + path,
66 + module: await import(normalizePath(path))
67 }))
68 );
69 jsExtensionsCache.set(extensionPoint, imports);
@@ -134,7 +133,7 @@ export async function importHtmlExtensions(extensionPoint, targetElement) {
133 });
134 let combinedHTML = "";
135 for (const extension of response.extensions) {
137 - const path = normalizePath(extension.path);
136 + const path = normalizePath(extension);
137 combinedHTML += `<x-component path="${path}"></x-component>`;
138 }
139 htmlExtensionsCache.set(extensionPoint, combinedHTML);