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() {