plugin installer flow cleanup

Move ZIP staging and marketplace index reconciliation out of the plugin installer API and into helper functions, keeping the API handler transport-only. Fix root-level ZIP installs so the original upload name determines the plugin ID instead of the temp staged filename, and clean up the marketplace detail UI with README main/master fallback, immutable installed-state updates, deduped thumbnail sources, merged tag styling, and no native tooltips in the hero manage actions.

Alessandro committed Mar 9, 2026 at 13:35 UTC 64aff8423b8100bdac15fa82cd7f87051c96077f
4 files changed +150 -98
plugins/plugin_installer/api/plugin_install.py
+9 -38
@@ -1,13 +1,13 @@
1 from __future__ import annotations
2
3 -import time
4 -import uuid
5 -from pathlib import Path
6 -
7 -from helpers.api import ApiHandler, Input, Request, Output
8 -from helpers import files
3 +from helpers.api import ApiHandler, Input, Output, Request
4 from werkzeug.datastructures import FileStorage
10 -from werkzeug.utils import secure_filename
5 +
6 +from plugins.plugin_installer.helpers.install import (
7 + get_marketplace_index,
8 + install_from_git,
9 + install_uploaded_zip,
10 +)
11
12 class PluginInstall(ApiHandler):
13 """Plugin installation API. Handles ZIP upload, Git clone, and index fetch."""
@@ -37,19 +37,7 @@ class PluginInstall(ApiHandler):
37 if not plugin_file.filename:
38 return {"success": False, "error": "No file selected"}
39
40 - # Save upload to temp
41 - tmp_dir = Path(files.get_abs_path("tmp", "plugin_uploads"))
42 - tmp_dir.mkdir(parents=True, exist_ok=True)
43 - base = secure_filename(plugin_file.filename)
44 - if not base.lower().endswith(".zip"):
45 - base = f"{base}.zip"
46 - unique = uuid.uuid4().hex[:8]
47 - stamp = time.strftime("%Y%m%d_%H%M%S")
48 - tmp_path = str(tmp_dir / f"plugin_{stamp}_{unique}_{base}")
49 - plugin_file.save(tmp_path)
50 -
51 - from plugins.plugin_installer.helpers.install import install_from_zip
52 - return install_from_zip(tmp_path)
40 + return install_uploaded_zip(plugin_file)
41
42 def _install_git(self, input: dict) -> dict:
43 git_url = (input.get("git_url", "") or "").strip()
@@ -57,24 +45,7 @@ class PluginInstall(ApiHandler):
45 if not git_url:
46 return {"success": False, "error": "Git URL is required"}
47
60 - from plugins.plugin_installer.helpers.install import install_from_git
48 return install_from_git(git_url, git_token)
49
50 def _fetch_index(self, input: dict) -> dict:
64 - from plugins.plugin_installer.helpers.install import fetch_plugin_index
65 - from helpers.plugins import get_plugins_list
66 -
67 - index_data = fetch_plugin_index()
68 - plugins = index_data.get("plugins", {})
69 - installed_dirs = set(get_plugins_list())
70 -
71 - def _dir_name(github: str) -> str:
72 - seg = (github or "").rstrip("/").split("/")[-1]
73 - return seg[:-4] if seg.endswith(".git") else seg
74 -
75 - installed_keys = [
76 - key for key, p in plugins.items()
77 - if _dir_name(p.get("github", "")) in installed_dirs
78 - ]
79 -
80 - return {"success": True, "index": index_data, "installed_plugins": installed_keys}
51 + return {"success": True, **get_marketplace_index()}
plugins/plugin_installer/helpers/install.py
+82 -19
@@ -1,25 +1,55 @@
1 from __future__ import annotations
2
3 +import json
4 import os
5 import shutil
6 import time
7 +import urllib.request
8 +import uuid
9 import zipfile
10 from pathlib import Path
8 -from typing import Optional
11 +from typing import Any, Optional
12
13 from helpers import files
14 +from helpers import yaml as yaml_helper
15 from helpers.plugins import (
16 META_FILE_NAME,
17 PluginMetadata,
18 + get_plugins_list,
19 invalidate_plugin_cache,
20 )
16 -from helpers import yaml as yaml_helper
21 +from werkzeug.datastructures import FileStorage
22 +from werkzeug.utils import secure_filename
23
24 def _get_user_plugins_dir() -> str:
25 """Return absolute path to usr/plugins/."""
26 return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
27
28
29 +def _derive_git_plugin_name(url: str) -> str:
30 + """Derive the canonical plugin ID from a Git repository URL."""
31 + repo_name = url.rstrip("/").split("/")[-1]
32 + if repo_name.endswith(".git"):
33 + repo_name = repo_name[:-4]
34 + if not repo_name:
35 + raise ValueError("Could not derive plugin name from URL")
36 + return repo_name
37 +
38 +
39 +def _derive_zip_plugin_name(
40 + plugin_root: str, extract_dir: str, original_filename: str | None
41 +) -> str:
42 + """Resolve the plugin ID for an uploaded ZIP archive."""
43 + if plugin_root != extract_dir:
44 + return os.path.basename(plugin_root)
45 +
46 + upload_name = Path((original_filename or "").strip()).name
47 + plugin_name = Path(upload_name).stem
48 + if not plugin_name:
49 + raise ValueError("Could not derive plugin name from uploaded filename")
50 + return plugin_name
51 +
52 +
53 def validate_plugin_dir(path: str) -> PluginMetadata:
54 """Check directory contains plugin.yaml and return parsed metadata.
55 Raises ValueError if plugin.yaml is missing or invalid."""
@@ -48,7 +78,28 @@ def _find_plugin_root(extracted_dir: str) -> str:
78 raise ValueError(f"No {META_FILE_NAME} found in the uploaded archive")
79
80
51 -def install_from_zip(zip_path: str) -> dict:
81 +def install_uploaded_zip(plugin_file: FileStorage) -> dict:
82 + """Persist an uploaded ZIP temporarily and install it."""
83 + original_filename = Path((plugin_file.filename or "").strip()).name
84 + if not original_filename:
85 + raise ValueError("No file selected")
86 +
87 + tmp_dir = Path(files.get_abs_path("tmp", "plugin_uploads"))
88 + tmp_dir.mkdir(parents=True, exist_ok=True)
89 +
90 + temp_name = secure_filename(original_filename) or "plugin.zip"
91 + if not temp_name.lower().endswith(".zip"):
92 + temp_name = f"{temp_name}.zip"
93 +
94 + unique = uuid.uuid4().hex[:8]
95 + stamp = time.strftime("%Y%m%d_%H%M%S")
96 + tmp_path = str(tmp_dir / f"plugin_{stamp}_{unique}_{temp_name}")
97 + plugin_file.save(tmp_path)
98 +
99 + return install_from_zip(tmp_path, original_filename=original_filename)
100 +
101 +
102 +def install_from_zip(zip_path: str, original_filename: str | None = None) -> dict:
103 """Extract ZIP, find plugin.yaml, move its parent to usr/plugins/.
104 Returns dict with plugin name and metadata.
105 Cleans up tmp files regardless of outcome."""
@@ -74,13 +125,7 @@ def install_from_zip(zip_path: str) -> dict:
125 # Find plugin.yaml
126 plugin_root = _find_plugin_root(extract_dir)
127 meta = validate_plugin_dir(plugin_root)
77 - plugin_name = os.path.basename(plugin_root)
78 -
79 - # If the zip only has one top-level dir that IS the plugin root,
80 - # use that dir's name. Otherwise use the zip's stem.
81 - if plugin_root == extract_dir:
82 - # plugin.yaml at root of extraction — use zip filename stem
83 - plugin_name = Path(zip_path).stem
128 + plugin_name = _derive_zip_plugin_name(plugin_root, extract_dir, original_filename)
129
130 check_plugin_conflict(plugin_name)
131
@@ -110,12 +155,7 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
155 Returns dict with plugin name and metadata."""
156 from helpers.git import clone_repo
157
113 - # Derive plugin name from URL
114 - repo_name = url.rstrip("/").split("/")[-1]
115 - if repo_name.endswith(".git"):
116 - repo_name = repo_name[:-4]
117 - if not repo_name:
118 - raise ValueError("Could not derive plugin name from URL")
158 + repo_name = _derive_git_plugin_name(url)
159
160 check_plugin_conflict(repo_name)
161
@@ -146,11 +186,34 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
186 }
187
188
189 +def get_marketplace_index() -> dict[str, Any]:
190 + """Return the plugin index plus installed marketplace keys."""
191 + index_data = fetch_plugin_index()
192 + if not isinstance(index_data, dict):
193 + raise ValueError("Plugin index response was not a JSON object")
194 +
195 + plugins = index_data.get("plugins")
196 + if not isinstance(plugins, dict):
197 + raise ValueError("Plugin index payload is missing a valid 'plugins' map")
198 +
199 + installed_dirs = set(get_plugins_list())
200 + installed_keys: list[str] = []
201 + for key, plugin_data in plugins.items():
202 + if not isinstance(plugin_data, dict):
203 + continue
204 + github_url = plugin_data.get("github", "")
205 + try:
206 + plugin_name = _derive_git_plugin_name(github_url)
207 + except ValueError:
208 + continue
209 + if plugin_name in installed_dirs:
210 + installed_keys.append(key)
211 +
212 + return {"index": index_data, "installed_plugins": installed_keys}
213 +
214 +
215 def fetch_plugin_index() -> dict:
216 """Download the plugin index from GitHub releases."""
151 - import urllib.request
152 - import json
153 -
217 index_url = "https://github.com/agent0ai/a0-plugins/releases/download/generated-index/index.json"
218 req = urllib.request.Request(index_url, headers={"User-Agent": "AgentZero"})
219 with urllib.request.urlopen(req, timeout=30) as resp:
plugins/plugin_installer/webui/install-detail.html
+2 -12
@@ -28,41 +28,35 @@
28 <div class="pi-hero-manage">
29 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_main_screen">
30 <button type="button" class="button"
31 - title="Open"
31 @click="$store.pluginInstallStore.manageOpenPlugin()">
32 <span class="icon material-symbols-outlined">dashboard</span> Open
33 </button>
34 </template>
35 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_config_screen">
36 <button type="button" class="button"
38 - title="Config"
37 @click="$store.pluginInstallStore.manageOpenConfig()">
38 <span class="icon material-symbols-outlined">settings</span> Config
39 </button>
40 </template>
41 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_readme">
42 <button type="button" class="button"
45 - title="README"
43 @click="$store.pluginInstallStore.manageOpenDoc('readme')">
44 <span class="icon material-symbols-outlined">description</span> README
45 </button>
46 </template>
47 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_license">
48 <button type="button" class="button"
52 - title="LICENSE"
49 @click="$store.pluginInstallStore.manageOpenDoc('license')">
50 <span class="icon material-symbols-outlined">gavel</span> License
51 </button>
52 </template>
53 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_init_script">
54 <button type="button" class="button"
59 - title="Run initializer script"
55 @click="$store.pluginInstallStore.manageOpenInit()">
56 <span class="icon material-symbols-outlined">terminal</span> Init
57 </button>
58 </template>
59 <button type="button" class="button"
65 - title="Info"
60 @click="$store.pluginInstallStore.manageOpenInfo()">
61 <span class="icon material-symbols-outlined">info</span> Info
62 </button>
@@ -323,12 +317,8 @@
317 }
318
319 .pi-tag {
326 - display: flex;
327 - flex-wrap: wrap;
328 - gap: 0.4rem;
329 - }
330 -
331 - .pi-tag {
320 + display: inline-flex;
321 + align-items: center;
322 font-size: 0.8rem;
323 padding: 0.25rem 0.6rem;
324 border-radius: 4px;
plugins/plugin_installer/webui/pluginInstallStore.js
+57 -29
@@ -157,7 +157,8 @@ const model = {
157 "Plugin Installer"
158 );
159 } catch (e) {
160 - this.error = `Installation error: ${e.message}`;
160 + const message = e instanceof Error ? e.message : String(e);
161 + this.error = `Installation error: ${message}`;
162 } finally {
163 this.loading = false;
164 this.loadingMessage = "";
@@ -197,7 +198,8 @@ const model = {
198 "Plugin Installer"
199 );
200 } catch (e) {
200 - this.error = `Clone error: ${e.message}`;
201 + const message = e instanceof Error ? e.message : String(e);
202 + this.error = `Clone error: ${message}`;
203 } finally {
204 this.loading = false;
205 this.loadingMessage = "";
@@ -226,7 +228,8 @@ const model = {
228 this.installedPlugins = data.installed_plugins || [];
229 this.page = 1;
230 } catch (e) {
229 - this.error = `Failed to load plugin index: ${e.message}`;
231 + const message = e instanceof Error ? e.message : String(e);
232 + this.error = `Failed to load plugin index: ${message}`;
233 } finally {
234 this.loading = false;
235 this.loadingMessage = "";
@@ -339,17 +342,18 @@ const model = {
342 },
343
344 openDetail(plugin) {
342 - this.selectedPlugin = plugin;
345 + const detailPlugin = plugin ? { ...plugin } : null;
346 + this.selectedPlugin = detailPlugin;
347 this.error = "";
348 this.installedPluginInfo = null;
349 this.readmeContent = null;
346 - this.detailPluginName = this._pluginName(plugin);
347 - this.detailThumbnailSources = this.getDetailThumbnailSources(plugin);
350 + this.detailPluginName = this._pluginName(detailPlugin);
351 + this.detailThumbnailSources = this.getDetailThumbnailSources(detailPlugin);
352 this.detailThumbnailIndex = 0;
349 - if (plugin.installed) {
353 + if (detailPlugin?.installed) {
354 this.fetchInstalledPluginInfo(this.detailPluginName);
355 }
352 - this.fetchReadme(plugin);
356 + this.fetchReadme(detailPlugin);
357 openModal("/plugins/plugin_installer/webui/install-detail.html");
358 },
359
@@ -360,14 +364,24 @@ const model = {
364 try {
365 this.readmeLoading = true;
366 this.readmeContent = null;
367 + let lastError = null;
368 +
369 + for (const branch of ["main", "master"]) {
370 + try {
371 + const response = await fetch(`${rawBase}/${branch}/README.md`);
372 + if (!response.ok) continue;
373 +
374 + const readme = await response.text();
375 + this.readmeContent = marked.parse(readme, { breaks: true });
376 + return;
377 + } catch (error) {
378 + lastError = error;
379 + }
380 + }
381
364 - const response = await fetch(`${rawBase}/main/README.md`);
365 - if (response.ok) {
366 - const readme = await response.text();
367 - this.readmeContent = marked.parse(readme, { breaks: true });
382 + if (lastError) {
383 + console.warn("Failed to fetch readme:", lastError);
384 }
369 - } catch (e) {
370 - console.warn("Failed to fetch readme:", e);
385 } finally {
386 this.readmeLoading = false;
387 }
@@ -406,11 +420,15 @@ const model = {
420 return;
421 }
422
409 - if (!this.installedPlugins.includes(plugin.key)) {
410 - this.installedPlugins.push(plugin.key);
423 + const installedKey = plugin.key || data.plugin_name;
424 + if (installedKey && !this.installedPlugins.includes(installedKey)) {
425 + this.installedPlugins = [...this.installedPlugins, installedKey];
426 }
412 - plugin.installed = true;
413 - this.selectedPlugin = { ...this.selectedPlugin, installed: true };
427 + this.selectedPlugin = {
428 + ...plugin,
429 + ...(this.selectedPlugin || {}),
430 + installed: true,
431 + };
432 this.detailPluginName = data.plugin_name;
433 this.detailThumbnailSources = this.getDetailThumbnailSources(this.selectedPlugin);
434 this.detailThumbnailIndex = 0;
@@ -421,7 +439,8 @@ const model = {
439 "Plugin Installer"
440 );
441 } catch (e) {
424 - this.error = `Installation error: ${e.message}`;
442 + const message = e instanceof Error ? e.message : String(e);
443 + this.error = `Installation error: ${message}`;
444 } finally {
445 this.loading = false;
446 this.loadingMessage = "";
@@ -438,7 +457,7 @@ const model = {
457 });
458 const plugins = Array.isArray(response.plugins) ? response.plugins : [];
459 this.installedPluginInfo = plugins.find((p) => p.name === pluginName) || null;
441 - } catch (e) {
460 + } catch (_error) {
461 this.installedPluginInfo = null;
462 }
463 },
@@ -453,7 +472,7 @@ const model = {
472
473 manageOpenPlugin() {
474 const info = this.installedPluginInfo;
456 - if (!info?.name || !info?.has_main_screen) return;
475 + if (!info || !info.name || !info.has_main_screen) return;
476 openModal(`/plugins/${info.name}/webui/main.html`);
477 },
478
@@ -489,10 +508,12 @@ const model = {
508 const pls = this._pluginListStore();
509 if (pls?.deletePlugin && this.installedPluginInfo) {
510 await pls.deletePlugin(this.installedPluginInfo);
492 - if (this.selectedPlugin) {
493 - this.selectedPlugin.installed = false;
494 - const idx = this.installedPlugins.indexOf(this.selectedPlugin.key);
495 - if (idx !== -1) this.installedPlugins.splice(idx, 1);
511 + const currentPlugin = this.selectedPlugin;
512 + if (currentPlugin) {
513 + this.selectedPlugin = { ...currentPlugin, installed: false };
514 + this.installedPlugins = this.installedPlugins.filter(
515 + (key) => key !== currentPlugin.key
516 + );
517 }
518 this.installedPluginInfo = null;
519 }
@@ -523,13 +544,20 @@ const model = {
544 */
545 getDetailThumbnailSources(plugin) {
546 const currentPlugin = plugin || this.selectedPlugin;
526 - const sources = [
547 + const rawSources = [
548 this.getThumbnailUrl(currentPlugin),
549 this.getLocalThumbnailUrl(),
550 ];
530 - return sources.filter(
531 - (url, index) => typeof url === "string" && sources.indexOf(url) === index
532 - );
551 + const uniqueSources = [];
552 + const seen = new Set();
553 +
554 + for (const url of rawSources) {
555 + if (typeof url !== "string" || seen.has(url)) continue;
556 + seen.add(url);
557 + uniqueSources.push(url);
558 + }
559 +
560 + return uniqueSources;
561 },
562
563 getDetailThumbnailUrl() {