Compare commits

...

6 Commits

Author SHA1 Message Date
Austin Pickett
e455de97b3 feat(dashboard): add hide/show toggle for dashboard plugins in sidebar
- New config key: dashboard.hidden_plugins (list of plugin names)
- GET /api/dashboard/plugins now filters out hidden plugins from sidebar
- POST /api/dashboard/plugins/{name}/visibility toggles visibility
- Hub response includes user_hidden boolean per plugin row
- Eye/EyeOff toggle on plugin cards with dashboard manifests
- i18n: 'Show in sidebar' / 'Hide from sidebar' (en/zh)
2026-04-30 20:02:15 -04:00
Austin Pickett
19667c8740 refactor(plugins): move rescan button to page header, remove redundant title
Use usePageHeader().setEnd to place the rescan button in the shared
header bar. Remove the inline H2 title (already shown by the header)
and the wrapper div.
2026-04-30 19:53:41 -04:00
Austin Pickett
08955d59aa fix(plugins): show 'Plugins' in page header instead of 'Web UI'
Add /plugins route to resolve-page-title BUILTIN map.
2026-04-30 19:51:13 -04:00
Austin Pickett
1deeb06fca style(plugins): make page full width 2026-04-30 19:39:51 -04:00
Austin Pickett
dcf99b55a3 fix(security): address CodeQL path-traversal and info-exposure findings
- Add _validate_plugin_name() guard on all {name} path param endpoints
  (rejects /, \, .. before reaching plugin logic)
- Strip after_install_path from install response (no internal paths to client)
- Update nix/tui.nix lockfile hash to match committed package-lock.json
2026-04-30 18:50:28 -04:00
Austin Pickett
7947b89710 feat(dashboard): add Plugins page with enable/disable, auth status, install/remove
- New PluginsPage.tsx: full plugin management UI (list, enable/disable,
  install from git, remove, git pull updates, provider picker)
- Backend: dashboard_set_agent_plugin_enabled now also toggles the
  plugin's toolset in platform_toolsets so enabling actually makes
  tools visible in agent sessions
- Backend: /api/dashboard/plugins/hub returns auth_required + auth_command
  per plugin (checks tool registry check_fn)
- Frontend: auth_required shown as Badge + CommandBlock with copy-able
  auth command
- Fix: Select overflow in providers card (min-w-0 grid cells, removed
  truncate/overflow-hidden that clipped dropdown)
- Refactor: _install_plugin_core extracted for non-interactive reuse,
  PluginOperationError for structured error handling
- i18n: en/zh/types updated with all new plugin page strings
2026-04-30 17:41:10 -04:00
12 changed files with 1603 additions and 192 deletions

View File

@@ -15,13 +15,18 @@ import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
from typing import Any, Optional
from hermes_constants import get_hermes_home
from hermes_cli.config import cfg_get
logger = logging.getLogger(__name__)
class PluginOperationError(Exception):
"""Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx)."""
# Minimum manifest version this installer understands.
# Plugins may declare ``manifest_version: 1`` in plugin.yaml;
# future breaking changes to the manifest schema bump this.
@@ -150,6 +155,24 @@ def _copy_example_files(plugin_dir: Path, console) -> None:
)
def _missing_requires_env_names(manifest: dict) -> list[str]:
"""Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``."""
requires_env = manifest.get("requires_env") or []
if not requires_env:
return []
from hermes_cli.config import get_env_value
env_specs: list[dict] = []
for entry in requires_env:
if isinstance(entry, str):
env_specs.append({"name": entry})
elif isinstance(entry, dict) and entry.get("name"):
env_specs.append(entry)
return [s["name"] for s in env_specs if s.get("name") and not get_env_value(s["name"])]
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
"""Prompt for required environment variables declared in plugin.yaml.
@@ -283,6 +306,95 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
# ---------------------------------------------------------------------------
def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, str]:
"""Clone Git plugin into ``~/.hermes/plugins``.
Returns ``(target_dir, installed_manifest, canonical_name)``.
Raises ``PluginOperationError`` on failure.
"""
import tempfile
try:
git_url = _resolve_git_url(identifier)
except ValueError as e:
raise PluginOperationError(str(e)) from e
plugins_dir = _plugins_dir()
with tempfile.TemporaryDirectory() as tmp:
tmp_target = Path(tmp) / "plugin"
try:
result = subprocess.run(
["git", "clone", "--depth", "1", git_url, str(tmp_target)],
capture_output=True,
text=True,
timeout=60,
)
except FileNotFoundError as e:
raise PluginOperationError(
"git is not installed or not in PATH.",
) from e
except subprocess.TimeoutExpired as e:
raise PluginOperationError(
"Git clone timed out after 60 seconds.",
) from e
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
raise PluginOperationError(f"Git clone failed:\n{err}")
manifest = _read_manifest(tmp_target)
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
try:
target = _sanitize_plugin_name(plugin_name, plugins_dir)
except ValueError as e:
raise PluginOperationError(str(e)) from e
mv = manifest.get("manifest_version")
if mv is not None:
try:
mv_int = int(mv)
except (ValueError, TypeError):
raise PluginOperationError(
f"Plugin '{plugin_name}' has invalid manifest_version "
f"'{mv}' (expected an integer).",
) from None
if mv_int > _SUPPORTED_MANIFEST_VERSION:
from hermes_cli.config import recommended_update_command
raise PluginOperationError(
f"Plugin '{plugin_name}' requires manifest_version {mv}, "
f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. "
f"Run {recommended_update_command()} to update Hermes.",
) from None
if target.exists():
if not force:
raise PluginOperationError(
f"Plugin '{plugin_name}' already exists. Use force reinstall "
f"or run `hermes plugins update {plugin_name}`.",
)
shutil.rmtree(target)
shutil.move(str(tmp_target), str(target))
has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists()
if not has_yaml and not (target / "__init__.py").exists():
logger.warning(
"%s has no plugin.yaml / __init__.py; may not be a valid plugin",
plugin_name,
)
from rich.console import Console
_copy_example_files(target, Console())
installed_manifest = _read_manifest(target)
installed_name = installed_manifest.get("name") or target.name
return target, installed_manifest, installed_name
def cmd_install(
identifier: str,
force: bool = False,
@@ -293,7 +405,6 @@ def cmd_install(
After install, prompt "Enable now? [y/N]" unless *enable* is provided
(True = auto-enable without prompting, False = install disabled).
"""
import tempfile
from rich.console import Console
console = Console()
@@ -304,114 +415,41 @@ def cmd_install(
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
# Warn about insecure / local URL schemes
if git_url.startswith(("http://", "file://")):
console.print(
"[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
"Consider using https:// or git@ for production installs."
"Consider using https:// or git@ for production installs.",
)
plugins_dir = _plugins_dir()
console.print(f"[dim]Cloning {git_url}...[/dim]")
# Clone into a temp directory first so we can read plugin.yaml for the name
with tempfile.TemporaryDirectory() as tmp:
tmp_target = Path(tmp) / "plugin"
console.print(f"[dim]Cloning {git_url}...[/dim]")
try:
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
)
except PluginOperationError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
try:
result = subprocess.run(
["git", "clone", "--depth", "1", git_url, str(tmp_target)],
capture_output=True,
text=True,
timeout=60,
)
except FileNotFoundError:
console.print("[red]Error:[/red] git is not installed or not in PATH.")
sys.exit(1)
except subprocess.TimeoutExpired:
console.print("[red]Error:[/red] Git clone timed out after 60 seconds.")
sys.exit(1)
if result.returncode != 0:
console.print(
f"[red]Error:[/red] Git clone failed:\n{result.stderr.strip()}"
)
sys.exit(1)
# Read manifest
manifest = _read_manifest(tmp_target)
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
# Sanitize plugin name against path traversal
try:
target = _sanitize_plugin_name(plugin_name, plugins_dir)
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
# Check manifest_version compatibility
mv = manifest.get("manifest_version")
if mv is not None:
try:
mv_int = int(mv)
except (ValueError, TypeError):
console.print(
f"[red]Error:[/red] Plugin '{plugin_name}' has invalid "
f"manifest_version '{mv}' (expected an integer)."
)
sys.exit(1)
if mv_int > _SUPPORTED_MANIFEST_VERSION:
from hermes_cli.config import recommended_update_command
console.print(
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer."
)
sys.exit(1)
if target.exists():
if not force:
console.print(
f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n"
f"Use [bold]--force[/bold] to remove and reinstall, or "
f"[bold]hermes plugins update {plugin_name}[/bold] to pull latest."
)
sys.exit(1)
console.print(f"[dim] Removing existing {plugin_name}...[/dim]")
shutil.rmtree(target)
# Move from temp to final location
shutil.move(str(tmp_target), str(target))
# Validate it looks like a plugin
if not (target / "plugin.yaml").exists() and not (target / "__init__.py").exists():
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
target / "__init__.py"
).exists():
console.print(
f"[yellow]Warning:[/yellow] {plugin_name} doesn't contain plugin.yaml "
f"or __init__.py. It may not be a valid Hermes plugin."
f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml "
f"or __init__.py. It may not be a valid Hermes plugin.",
)
# Copy .example files to their real names (e.g. config.yaml.example → config.yaml)
_copy_example_files(target, console)
# Re-read manifest from installed location (for env var prompting)
installed_manifest = _read_manifest(target)
# Prompt for required environment variables before showing after-install docs
_prompt_plugin_env_vars(installed_manifest, console)
_display_after_install(target, identifier)
# Determine the canonical plugin name for enable-list bookkeeping.
installed_name = installed_manifest.get("name") or target.name
# Decide whether to enable: explicit flag > interactive prompt > default off
should_enable = enable
if should_enable is None:
# Interactive prompt unless stdin isn't a TTY (scripted install).
if sys.stdin.isatty() and sys.stdout.isatty():
try:
answer = input(
f" Enable '{installed_name}' now? [y/N]: "
f" Enable '{installed_name}' now? [y/N]: ",
).strip().lower()
should_enable = answer in ("y", "yes")
except (EOFError, KeyboardInterrupt):
@@ -427,12 +465,12 @@ def cmd_install(
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled."
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.",
)
else:
console.print(
f"[dim]Plugin installed but not enabled. "
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]"
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]",
)
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
@@ -462,36 +500,22 @@ def cmd_update(name: str) -> None:
console.print(f"[dim]Updating {name}...[/dim]")
try:
result = subprocess.run(
["git", "pull", "--ff-only"],
capture_output=True,
text=True,
timeout=60,
cwd=str(target),
)
except FileNotFoundError:
console.print("[red]Error:[/red] git is not installed or not in PATH.")
sys.exit(1)
except subprocess.TimeoutExpired:
console.print("[red]Error:[/red] Git pull timed out after 60 seconds.")
sys.exit(1)
if result.returncode != 0:
console.print(f"[red]Error:[/red] Git pull failed:\n{result.stderr.strip()}")
ok, output = _git_pull_plugin_dir(target)
if not ok:
console.print(f"[red]Error:[/red] {output}")
sys.exit(1)
# Copy any new .example files
_copy_example_files(target, console)
output = result.stdout.strip()
if "Already up to date" in output:
out = output.strip()
if "Already up to date" in out:
console.print(
f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date."
)
else:
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
console.print(f"[dim]{output}[/dim]")
console.print(f"[dim]{out}[/dim]")
def cmd_remove(name: str) -> None:
@@ -1244,6 +1268,247 @@ def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
print()
def dashboard_install_plugin(
identifier: str,
*,
force: bool,
enable: bool,
) -> dict[str, Any]:
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
warnings: list[str] = []
try:
git_url = _resolve_git_url(identifier)
if git_url.startswith(("http://", "file://")):
warnings.append(
"Insecure URL scheme; prefer https:// or git@ for production installs.",
)
except ValueError:
pass
try:
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
)
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
missing_env = _missing_requires_env_names(installed_manifest)
if enable:
en = _get_enabled_set()
dis = _get_disabled_set()
en.add(installed_name)
dis.discard(installed_name)
_save_enabled_set(en)
_save_disabled_set(dis)
hint: str | None = None
ap = target / "after-install.md"
if ap.exists():
hint = str(ap)
return {
"ok": True,
"plugin_name": installed_name,
"warnings": warnings,
"missing_env": missing_env,
"after_install_path": hint,
"enabled": enable,
}
def _get_plugin_toolset_key(name: str) -> Optional[str]:
"""Return the toolset key a plugin registers its tools under, or None.
Queries the live tool registry — the plugin must already be loaded.
Falls back to reading ``provides_tools`` from plugin.yaml and looking
up the toolset from the registry for the first tool name found.
"""
try:
from tools.registry import registry
except Exception:
return None
# Check the plugin manager for tools this plugin registered
try:
from hermes_cli.plugins import discover_plugins, get_plugin_manager
discover_plugins() # idempotent — ensures plugins are loaded
manager = get_plugin_manager()
for _key, loaded in manager._plugins.items():
if loaded.manifest.name == name or _key == name:
for tool_name in loaded.tools_registered:
entry = registry.get_entry(tool_name)
if entry and entry.toolset:
return entry.toolset
break
except Exception:
pass
# Fallback: read provides_tools from manifest on disk and query registry
try:
from hermes_cli.plugins import get_bundled_plugins_dir
for base in (get_bundled_plugins_dir(), _plugins_dir()):
if not base.is_dir():
continue
candidate = base / name
if candidate.is_dir():
manifest = _read_manifest(candidate)
for tool_name in manifest.get("provides_tools") or []:
entry = registry.get_entry(tool_name)
if entry and entry.toolset:
return entry.toolset
except Exception:
pass
return None
def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
"""Add or remove a plugin's toolset from platform_toolsets for all platforms.
Only acts if the plugin actually provides tools (has a toolset key).
"""
toolset_key = _get_plugin_toolset_key(name)
if not toolset_key:
return
from hermes_cli.config import load_config, save_config
config = load_config()
platform_toolsets = config.get("platform_toolsets")
if not isinstance(platform_toolsets, dict):
platform_toolsets = {}
config["platform_toolsets"] = platform_toolsets
changed = False
for platform, ts_list in platform_toolsets.items():
if not isinstance(ts_list, list):
continue
if enable:
if toolset_key not in ts_list:
ts_list.append(toolset_key)
changed = True
else:
if toolset_key in ts_list:
ts_list.remove(toolset_key)
changed = True
# If enabling and no platforms have toolset lists yet, add to "cli" at minimum
if enable and not changed and not platform_toolsets:
platform_toolsets["cli"] = [toolset_key]
changed = True
if changed:
save_config(config)
def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]:
"""Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists).
For plugins that provide tools (toolsets), also toggles the toolset in
``platform_toolsets`` so the agent actually sees the tools in sessions.
"""
if not _plugin_exists(name):
return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
en = _get_enabled_set()
dis = _get_disabled_set()
if enabled:
if name in en and name not in dis:
return {"ok": True, "name": name, "unchanged": True}
en.add(name)
dis.discard(name)
_save_enabled_set(en)
_save_disabled_set(dis)
_toggle_plugin_toolset(name, enable=True)
return {"ok": True, "name": name, "unchanged": False}
if name not in en and name in dis:
return {"ok": True, "name": name, "unchanged": True}
en.discard(name)
dis.add(name)
_save_enabled_set(en)
_save_disabled_set(dis)
_toggle_plugin_toolset(name, enable=False)
return {"ok": True, "name": name, "unchanged": False}
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
plugins_dir = _plugins_dir()
try:
target = _sanitize_plugin_name(name, plugins_dir)
except ValueError:
return None
return target if target.is_dir() else None
def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
"""``git pull`` inside ``~/.hermes/plugins/<name>``."""
target = _user_installed_plugin_dir(name)
if target is None:
return {
"ok": False,
"error": f"Plugin '{name}' was not found under {_plugins_dir()}.",
}
if not (target / ".git").exists():
return {
"ok": False,
"error": f"Plugin '{name}' is not a git checkout; cannot pull updates.",
}
ok, msg = _git_pull_plugin_dir(target)
if not ok:
return {"ok": False, "error": msg}
from rich.console import Console
_copy_example_files(target, Console())
unchanged = "Already up to date" in msg
return {"ok": True, "name": name, "output": msg, "unchanged": unchanged}
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
try:
result = subprocess.run(
["git", "pull", "--ff-only"],
capture_output=True,
text=True,
timeout=60,
cwd=str(target),
)
except FileNotFoundError:
return False, "git is not installed or not in PATH."
except subprocess.TimeoutExpired:
return False, "Git pull timed out after 60 seconds."
if result.returncode != 0:
err = (result.stderr or "").strip() or result.stdout.strip()
return False, err or "git pull failed."
return True, result.stdout.strip()
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
plugins_dir = _plugins_dir()
for n, _ver, _d, src, _path in _discover_all_plugins():
if n == name and src == "bundled":
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
target = _user_installed_plugin_dir(name)
if target is None:
return {
"ok": False,
"error": f"Plugin '{name}' was not found under {plugins_dir}.",
}
shutil.rmtree(target)
return {"ok": True, "name": name}
def plugins_command(args) -> None:
"""Dispatch hermes plugins subcommands."""
action = getattr(args, "plugins_action", None)

View File

@@ -3617,12 +3617,16 @@ def _get_dashboard_plugins(force_rescan: bool = False) -> list:
@app.get("/api/dashboard/plugins")
async def get_dashboard_plugins():
"""Return discovered dashboard plugins."""
"""Return discovered dashboard plugins (excludes user-hidden ones)."""
plugins = _get_dashboard_plugins()
# Strip internal fields before sending to frontend.
# Read user's hidden plugins list from config.
config = load_config()
hidden: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
# Strip internal fields before sending to frontend and filter out hidden.
return [
{k: v for k, v in p.items() if not k.startswith("_")}
for p in plugins
if p["name"] not in hidden
]
@@ -3633,6 +3637,268 @@ async def rescan_dashboard_plugins():
return {"ok": True, "count": len(plugins)}
class _AgentPluginInstallBody(BaseModel):
identifier: str
force: bool = False
enable: bool = True
def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in p.items() if not k.startswith("_")}
def _merged_plugins_hub() -> Dict[str, Any]:
"""Agent discovery + dashboard manifests + optional provider picker metadata."""
from hermes_cli.plugins_cmd import (
_discover_all_plugins,
_get_current_context_engine,
_get_current_memory_provider,
_discover_context_engines,
_discover_memory_providers,
_get_disabled_set,
_get_enabled_set,
_read_manifest as _read_plugin_manifest_at,
)
dashboard_list = _get_dashboard_plugins()
dash_by_name = {str(p["name"]): p for p in dashboard_list}
disabled_set = _get_disabled_set()
enabled_set = _get_enabled_set()
# Read user-hidden plugins from config for the user_hidden field.
config = load_config()
hidden_plugins: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
rows: List[Dict[str, Any]] = []
for name, version, description, source, dir_str in _discover_all_plugins():
if name in disabled_set:
runtime_status = "disabled"
elif name in enabled_set:
runtime_status = "enabled"
else:
runtime_status = "inactive"
dir_path = Path(dir_str)
dm = dash_by_name.get(name)
has_dash_manifest = dm is not None or (dir_path / "dashboard" / "manifest.json").exists()
under_user_tree = False
try:
dir_path.resolve().relative_to(plugins_root_resolved)
under_user_tree = True
except ValueError:
pass
can_remove_update = (
source in ("user", "git") and under_user_tree and Path(dir_str).is_dir()
)
# Check if this plugin provides tools that require auth
auth_required = False
auth_command = ""
manifest_data = _read_plugin_manifest_at(dir_path)
provides_tools = manifest_data.get("provides_tools") or []
if provides_tools:
try:
from tools.registry import registry
for tname in provides_tools:
entry = registry.get_entry(tname)
if entry and entry.check_fn and not entry.check_fn():
auth_required = True
auth_command = f"hermes auth {name}"
break
except Exception:
pass
rows.append({
"name": name,
"version": version or "",
"description": description or "",
"source": source,
"runtime_status": runtime_status,
"has_dashboard_manifest": has_dash_manifest,
"dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None,
"path": dir_str,
"can_remove": can_remove_update,
"can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(),
"auth_required": auth_required,
"auth_command": auth_command,
"user_hidden": name in hidden_plugins,
})
agent_names = {r["name"] for r in rows}
orphan_dashboard = [
_strip_dashboard_manifest(p)
for p in dashboard_list
if str(p["name"]) not in agent_names
]
memory_providers: List[Dict[str, str]] = []
try:
for n, desc in _discover_memory_providers():
memory_providers.append({"name": n, "description": desc})
except Exception:
memory_providers = []
context_engines: List[Dict[str, str]] = []
try:
for n, desc in _discover_context_engines():
context_engines.append({"name": n, "description": desc})
except Exception:
context_engines = []
return {
"plugins": rows,
"orphan_dashboard_plugins": orphan_dashboard,
"providers": {
"memory_provider": _get_current_memory_provider() or "",
"memory_options": memory_providers,
"context_engine": _get_current_context_engine(),
"context_options": context_engines,
},
}
@app.get("/api/dashboard/plugins/hub")
async def get_plugins_hub(request: Request):
"""Unified agent plugins + dashboard extension metadata (session protected)."""
_require_token(request)
try:
return _merged_plugins_hub()
except Exception as exc:
_log.warning("plugins/hub failed: %s", exc)
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
@app.post("/api/dashboard/agent-plugins/install")
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
_require_token(request)
from hermes_cli.plugins_cmd import dashboard_install_plugin
result = dashboard_install_plugin(
body.identifier.strip(),
force=body.force,
enable=body.enable,
)
if not result.get("ok"):
raise HTTPException(
status_code=400,
detail=result.get("error") or "Install failed.",
)
_get_dashboard_plugins(force_rescan=True)
# Strip internal paths from the response
result.pop("after_install_path", None)
return result
def _validate_plugin_name(name: str) -> str:
"""Reject path-traversal attempts in plugin name URL parameters."""
if not name or "/" in name or "\\" in name or ".." in name:
raise HTTPException(status_code=400, detail="Invalid plugin name.")
return name
@app.post("/api/dashboard/agent-plugins/{name}/enable")
async def post_agent_plugin_enable(request: Request, name: str):
_require_token(request)
name = _validate_plugin_name(name)
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
result = dashboard_set_agent_plugin_enabled(name, enabled=True)
if not result.get("ok"):
raise HTTPException(status_code=400, detail=result.get("error") or "Enable failed.")
return result
@app.post("/api/dashboard/agent-plugins/{name}/disable")
async def post_agent_plugin_disable(request: Request, name: str):
_require_token(request)
name = _validate_plugin_name(name)
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
result = dashboard_set_agent_plugin_enabled(name, enabled=False)
if not result.get("ok"):
raise HTTPException(status_code=400, detail=result.get("error") or "Disable failed.")
return result
@app.post("/api/dashboard/agent-plugins/{name}/update")
async def post_agent_plugin_update(request: Request, name: str):
_require_token(request)
name = _validate_plugin_name(name)
from hermes_cli.plugins_cmd import dashboard_update_user_plugin
result = dashboard_update_user_plugin(name)
if not result.get("ok"):
raise HTTPException(status_code=400, detail=result.get("error") or "Update failed.")
_get_dashboard_plugins(force_rescan=True)
return result
@app.delete("/api/dashboard/agent-plugins/{name}")
async def delete_agent_plugin(request: Request, name: str):
_require_token(request)
name = _validate_plugin_name(name)
from hermes_cli.plugins_cmd import dashboard_remove_user_plugin
result = dashboard_remove_user_plugin(name)
if not result.get("ok"):
raise HTTPException(status_code=400, detail=result.get("error") or "Remove failed.")
_get_dashboard_plugins(force_rescan=True)
return result
class _PluginProvidersPutBody(BaseModel):
memory_provider: Optional[str] = None
context_engine: Optional[str] = None
@app.put("/api/dashboard/plugin-providers")
async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody):
"""Persist memory provider / context engine selection (writes config.yaml)."""
_require_token(request)
from hermes_cli.plugins_cmd import (
_save_context_engine,
_save_memory_provider,
)
if body.memory_provider is not None:
_save_memory_provider(body.memory_provider)
if body.context_engine is not None:
_save_context_engine(body.context_engine)
return {"ok": True}
class _PluginVisibilityBody(BaseModel):
hidden: bool
@app.post("/api/dashboard/plugins/{name}/visibility")
async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody):
"""Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins)."""
_require_token(request)
name = _validate_plugin_name(name)
config = load_config()
if "dashboard" not in config or not isinstance(config.get("dashboard"), dict):
config["dashboard"] = {}
hidden_list: list = config["dashboard"].get("hidden_plugins") or []
if not isinstance(hidden_list, list):
hidden_list = []
if body.hidden and name not in hidden_list:
hidden_list.append(name)
elif not body.hidden and name in hidden_list:
hidden_list.remove(name)
config["dashboard"]["hidden_plugins"] = hidden_list
save_config(config)
return {"ok": True, "name": name, "hidden": body.hidden}
@app.get("/dashboard-plugins/{plugin_name}/{file_path:path}")
async def serve_plugin_asset(plugin_name: str, file_path: str):
"""Serve static assets from a dashboard plugin directory.

View File

@@ -4,7 +4,7 @@ let
src = ../ui-tui;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc=";
hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };

View File

@@ -124,6 +124,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -501,31 +502,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1700,6 +1676,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@@ -1710,6 +1687,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1720,6 +1698,7 @@
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.1",
@@ -1749,6 +1728,7 @@
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
@@ -2066,6 +2046,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2468,6 +2449,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3203,6 +3185,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3334,6 +3317,7 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -4242,6 +4226,7 @@
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^5.3.0",
"type-fest": "^4.18.2"
@@ -5678,6 +5663,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5787,6 +5773,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6611,6 +6598,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -6737,6 +6725,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6846,6 +6835,7 @@
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -7261,6 +7251,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -65,10 +65,12 @@ import ModelsPage from "@/pages/ModelsPage";
import CronPage from "@/pages/CronPage";
import ProfilesPage from "@/pages/ProfilesPage";
import SkillsPage from "@/pages/SkillsPage";
import PluginsPage from "@/pages/PluginsPage";
import ChatPage from "@/pages/ChatPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n";
import type { Translations } from "@/i18n/types";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
@@ -102,6 +104,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/logs": LogsPage,
"/cron": CronPage,
"/skills": SkillsPage,
"/plugins": PluginsPage,
"/profiles": ProfilesPage,
"/config": ConfigPage,
"/env": EnvPage,
@@ -138,6 +141,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
{ path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
@@ -213,6 +217,22 @@ function buildNavItems(
return items;
}
/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */
function partitionSidebarNav(
builtIn: NavItem[],
manifests: PluginManifest[],
): { coreItems: NavItem[]; pluginItems: NavItem[] } {
const merged = buildNavItems(builtIn, manifests);
const builtinPaths = new Set(builtIn.map((i) => i.path));
const coreItems: NavItem[] = [];
const pluginItems: NavItem[] = [];
for (const item of merged) {
if (builtinPaths.has(item.path)) coreItems.push(item);
else pluginItems.push(item);
}
return { coreItems, pluginItems };
}
function buildRoutes(
builtinRoutes: Record<string, ComponentType>,
manifests: PluginManifest[],
@@ -253,6 +273,7 @@ function buildRoutes(
for (const m of addons) {
if (m.tab.hidden) continue;
if (m.tab.path === "/plugins") continue;
if (builtinRoutes[m.tab.path]) continue;
routes.push({
key: `plugin:${m.name}`,
@@ -263,6 +284,7 @@ function buildRoutes(
for (const m of manifests) {
if (!m.tab.hidden) continue;
if (m.tab.path === "/plugins") continue;
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
routes.push({
key: `plugin:hidden:${m.name}`,
@@ -322,8 +344,8 @@ export default function App() {
[embeddedChat],
);
const navItems = useMemo(
() => buildNavItems(builtinNav, manifests),
const sidebarNav = useMemo(
() => partitionSidebarNav(builtinNav, manifests),
[builtinNav, manifests],
);
const routes = useMemo(
@@ -476,56 +498,44 @@ export default function App() {
aria-label={t.app.navigation}
>
<ul className="flex flex-col">
{navItems.map(({ path, label, labelKey, icon: Icon }) => {
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
return (
<li key={path}>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
? "text-midground"
: "opacity-60 hover:opacity-100",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{navLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</li>
);
})}
{sidebarNav.coreItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
item={item}
key={item.path}
t={t}
/>
))}
</ul>
{sidebarNav.pluginItems.length > 0 && (
<div
aria-labelledby="hermes-sidebar-plugin-nav-heading"
className="flex flex-col border-t border-current/10 pb-2"
role="group"
>
<span
className={cn(
"px-5 pt-2.5 pb-1",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
)}
id="hermes-sidebar-plugin-nav-heading"
>
{t.app.pluginNavSection}
</span>
<ul className="flex flex-col">
{sidebarNav.pluginItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
item={item}
key={item.path}
t={t}
/>
))}
</ul>
</div>
)}
</nav>
<SidebarSystemActions onNavigate={closeMobile} />
@@ -615,6 +625,57 @@ export default function App() {
);
}
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
const { path, label, labelKey, icon: Icon } = item;
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
return (
<li>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive ? "text-midground" : "opacity-60 hover:opacity-100",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{navLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</li>
);
}
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
const { t } = useI18n();
const navigate = useNavigate();
@@ -733,6 +794,12 @@ interface NavItem {
path: string;
}
interface SidebarNavLinkProps {
closeMobile: () => void;
item: NavItem;
t: Translations;
}
interface SystemActionItem {
action: SystemAction;
icon: ComponentType<{ className?: string }>;

View File

@@ -76,6 +76,7 @@ export const en: Translations = {
logs: "Logs",
models: "Models",
profiles: "profiles : multi agents",
plugins: "Plugins",
sessions: "Sessions",
skills: "Skills",
},
@@ -84,6 +85,7 @@ export const en: Translations = {
navigation: "Navigation",
openDocumentation: "Open documentation in a new tab",
openNavigation: "Open navigation",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} active",
statusOverview: "Status overview",
system: "System",
@@ -256,6 +258,47 @@ export const en: Translations = {
renamed: "Renamed",
},
pluginsPage: {
contextEngineLabel: "Context engine",
dashboardSlots: "Dashboard slots",
disableRuntime: "Disable",
enableAfterInstall: "Enable after install",
enableRuntime: "Enable",
forceReinstall: "Force reinstall (delete existing folder first)",
headline:
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
identifierLabel: "Git URL or owner/repo",
inactive: "inactive",
installBtn: "Install from Git",
installHeading: "Install from GitHub / Git URL",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
memoryProviderLabel: "Memory provider",
missingEnvWarn: "Set these in Keys before the plugin can run:",
noDashboardTab: "No dashboard tab",
openTab: "Open",
orphanHeading: "Dashboard-only extensions (no agent plugin.yaml match)",
pluginListHeading: "Installed plugins",
providerDefaults: "built-in / default",
providersHeading: "Runtime provider plugins",
providersHint:
"Writes memory.provider (empty = built-in) and context.engine to config.yaml. Takes effect next session.",
refreshDashboard: "Rescan dashboard extensions",
removeConfirm: "Remove this plugin from ~/.hermes/plugins/?",
removeHint: "Only user-installed plugins under ~/.hermes/plugins can be removed.",
rescanHeading: "SPA plugin registry",
rescanHint: "Rescan after adding files on disk so the dashboard sidebar picks up new manifests.",
runtimeHeading: "Gateway runtime (YAML plugins)",
saveProviders: "Save provider settings",
savedProviders: "Provider settings saved.",
sourceBadge: "Source",
authRequired: "Auth required",
authRequiredHint: "Run this command to authenticate:",
updateGit: "Git pull",
versionBadge: "Version",
showInSidebar: "Show in sidebar",
hideFromSidebar: "Hide from sidebar",
},
skills: {
title: "Skills",
searchPlaceholder: "Search skills and toolsets...",

View File

@@ -76,6 +76,7 @@ export interface Translations {
logs: string;
models: string;
profiles: string;
plugins: string;
sessions: string;
skills: string;
};
@@ -84,6 +85,7 @@ export interface Translations {
navigation: string;
openDocumentation: string;
openNavigation: string;
pluginNavSection: string;
sessionsActiveCount: string;
statusOverview: string;
system: string;
@@ -228,6 +230,46 @@ export interface Translations {
};
};
// ── Plugins page ──
pluginsPage: {
contextEngineLabel: string;
dashboardSlots: string;
disableRuntime: string;
enableAfterInstall: string;
enableRuntime: string;
forceReinstall: string;
headline: string;
identifierLabel: string;
inactive: string;
installBtn: string;
installHeading: string;
installHint: string;
memoryProviderLabel: string;
missingEnvWarn: string;
noDashboardTab: string;
openTab: string;
orphanHeading: string;
pluginListHeading: string;
providerDefaults: string;
providersHeading: string;
providersHint: string;
refreshDashboard: string;
removeConfirm: string;
removeHint: string;
rescanHeading: string;
rescanHint: string;
runtimeHeading: string;
saveProviders: string;
savedProviders: string;
sourceBadge: string;
authRequired: string;
authRequiredHint: string;
updateGit: string;
versionBadge: string;
showInSidebar: string;
hideFromSidebar: string;
};
// ── Profiles page ──
profiles: {
newProfile: string;

View File

@@ -75,6 +75,7 @@ export const zh: Translations = {
logs: "日志",
models: "模型",
profiles: "多Agent配置",
plugins: "插件管理",
sessions: "会话",
skills: "技能",
},
@@ -83,6 +84,7 @@ export const zh: Translations = {
navigation: "导航",
openDocumentation: "在新标签页中打开文档",
openNavigation: "打开导航",
pluginNavSection: "插件",
sessionsActiveCount: "{count} 个活跃",
statusOverview: "状态概览",
system: "系统",
@@ -253,6 +255,46 @@ export const zh: Translations = {
renamed: "已重命名",
},
pluginsPage: {
contextEngineLabel: "上下文引擎",
dashboardSlots: "面板插槽",
disableRuntime: "禁用",
enableAfterInstall: "安装后启用",
enableRuntime: "启用",
forceReinstall: "强制重装(先删除已有目录)",
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI。",
identifierLabel: "Git 地址或 owner/repo",
inactive: "未启用",
installBtn: "从 Git 安装",
installHeading: "从 GitHub / Git 地址安装",
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
memoryProviderLabel: "记忆提供方",
missingEnvWarn: "在「密钥」页面设置以下变量后再运行插件:",
noDashboardTab: "无仪表盘标签",
openTab: "打开",
orphanHeading: "仅仪表盘扩展(无匹配的 agent plugin.yaml",
pluginListHeading: "已安装插件",
providerDefaults: "内置 / 默认",
providersHeading: "运行时提供方插件",
providersHint:
"写入 config.yamlmemory.provider留空为内置、context.engine。下次会话生效。",
refreshDashboard: "重新扫描仪表盘扩展",
removeConfirm: "从 ~/.hermes/plugins/ 删除此插件?",
removeHint: "仅可移除用户安装在 ~/.hermes/plugins 下的插件。",
rescanHeading: "SPA 插件注册表",
rescanHint: "在磁盘新增文件后扫描,使侧边栏载入新 manifest。",
runtimeHeading: "网关运行时YAML 插件)",
saveProviders: "保存提供方设置",
savedProviders: "提供方设置已保存。",
sourceBadge: "来源",
authRequired: "需要认证",
authRequiredHint: "运行此命令以完成认证:",
updateGit: "git pull",
versionBadge: "版本",
showInSidebar: "在侧边栏显示",
hideFromSidebar: "从侧边栏隐藏",
},
skills: {
title: "技能",
searchPlaceholder: "搜索技能和工具集...",

View File

@@ -259,6 +259,56 @@ export const api = {
rescanPlugins: () =>
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
installAgentPlugin: (body: AgentPluginInstallRequest) =>
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body }),
}),
enableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
{ method: "POST" },
),
disableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
{ method: "POST" },
),
updateAgentPlugin: (name: string) =>
fetchJSON<AgentPluginUpdateResponse>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
{ method: "POST" },
),
removeAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
savePluginProviders: (body: PluginProvidersPutRequest) =>
fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
setPluginVisibility: (name: string, hidden: boolean) =>
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
`/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden }),
},
),
// Dashboard themes
getThemes: () =>
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
@@ -668,8 +718,67 @@ export interface PluginManifestResponse {
override?: string;
hidden?: boolean;
};
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}
export interface HubAgentPluginRow {
name: string;
version: string;
description: string;
source: string;
runtime_status: "disabled" | "enabled" | "inactive";
has_dashboard_manifest: boolean;
dashboard_manifest: PluginManifestResponse | null;
path: string;
can_remove: boolean;
can_update_git: boolean;
auth_required: boolean;
auth_command: string;
user_hidden: boolean;
}
export interface PluginsHubProviders {
memory_provider: string;
memory_options: Array<{ name: string; description: string }>;
context_engine: string;
context_options: Array<{ name: string; description: string }>;
}
export interface PluginsHubResponse {
plugins: HubAgentPluginRow[];
orphan_dashboard_plugins: PluginManifestResponse[];
providers: PluginsHubProviders;
}
export interface AgentPluginInstallRequest {
identifier: string;
force?: boolean;
enable?: boolean;
}
export interface AgentPluginInstallResponse {
ok: boolean;
plugin_name?: string;
warnings?: string[];
missing_env?: string[];
after_install_path?: string | null;
enabled?: boolean;
error?: string;
}
export interface AgentPluginUpdateResponse {
ok: boolean;
name?: string;
output?: string;
unchanged?: boolean;
error?: string;
}
export interface PluginProvidersPutRequest {
memory_provider?: string;
context_engine?: string;
}

View File

@@ -7,6 +7,7 @@ const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/logs": "logs",
"/cron": "cron",
"/skills": "skills",
"/plugins": "plugins",
"/config": "config",
"/env": "keys",
"/docs": "documentation",

View File

@@ -0,0 +1,581 @@
import { useCallback, useEffect, useState } from "react";
import { ExternalLink, RefreshCw, Puzzle, Trash2, Eye, EyeOff } from "lucide-react";
import type { Translations } from "@/i18n/types";
import { Link } from "react-router-dom";
import { api } from "@/lib/api";
import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
import { cn } from "@/lib/utils";
import { usePageHeader } from "@/contexts/usePageHeader";
/** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */
const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__";
export default function PluginsPage() {
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
const [loading, setLoading] = useState(true);
const [installId, setInstallId] = useState("");
const [installForce, setInstallForce] = useState(false);
const [installEnable, setInstallEnable] = useState(true);
const [installBusy, setInstallBusy] = useState(false);
const [rescanBusy, setRescanBusy] = useState(false);
const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN);
const [contextSel, setContextSel] = useState("compressor");
const [providerBusy, setProviderBusy] = useState(false);
const [rowBusy, setRowBusy] = useState<string | null>(null);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setEnd } = usePageHeader();
const loadHub = useCallback(() => {
return api
.getPluginsHub()
.then((h) => {
setHub(h);
const p = h.providers;
setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN);
setContextSel(p.context_engine || "compressor");
})
.catch(() => showToast(t.common.loading, "error"));
}, [showToast, t.common.loading]);
useEffect(() => {
setLoading(true);
void loadHub().finally(() => setLoading(false));
}, [loadHub]);
useEffect(() => {
setEnd(
<Button
ghost
size="sm"
className="shrink-0 gap-2"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
>
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
{t.pluginsPage.refreshDashboard}
</Button>,
);
return () => setEnd(null);
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
const onInstall = async () => {
const id = installId.trim();
if (!id) {
showToast(t.pluginsPage.installHint, "error");
return;
}
setInstallBusy(true);
try {
const r = await api.installAgentPlugin({
identifier: id,
force: installForce,
enable: installEnable,
});
showToast(`${r.plugin_name ?? id} installed`, "success");
if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error");
if ((r.missing_env?.length ?? 0) > 0)
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
setInstallId("");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Install failed", "error");
} finally {
setInstallBusy(false);
}
};
const onRescan = async () => {
setRescanBusy(true);
try {
const rc = await api.rescanPlugins();
showToast(
`${t.pluginsPage.refreshDashboard} (${rc.count})`,
"success",
);
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Rescan failed", "error");
} finally {
setRescanBusy(false);
}
};
const onSaveProviders = async () => {
setProviderBusy(true);
try {
await api.savePluginProviders({
memory_provider:
memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel,
context_engine: contextSel,
});
showToast(t.pluginsPage.savedProviders, "success");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Save failed", "error");
} finally {
setProviderBusy(false);
}
};
const setRuntimeLoading = async (name: string, fn: () => Promise<unknown>) => {
setRowBusy(name);
try {
await fn();
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed", "error");
} finally {
setRowBusy(null);
}
};
const rows = hub?.plugins ?? [];
const providers = hub?.providers;
return (
<div className="flex flex-col gap-4">
<PluginSlot name="plugins:top" />
<div className={cn("flex w-full flex-col gap-8")}>
{providers && (
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
{t.pluginsPage.providersHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid gap-6 sm:grid-cols-2 max-w-full">
<div className="grid gap-2 min-w-0">
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
<Select
id="mem-provider"
className="w-full"
value={memorySel}
onValueChange={setMemorySel}
>
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
{`(${t.pluginsPage.providerDefaults})`}
</SelectOption>
{providers.memory_options.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2 min-w-0">
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
<Select
id="ctx-engine"
className="w-full"
value={contextSel}
onValueChange={setContextSel}
>
<SelectOption value="compressor">compressor</SelectOption>
{providers.context_options
.filter((o) => o.name !== "compressor")
.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
</div>
<Button
className="w-fit gap-2"
size="sm"
disabled={providerBusy}
onClick={() => void onSaveProviders()}
>
{providerBusy ? <Spinner /> : null}
{t.pluginsPage.saveProviders}
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
{t.pluginsPage.installHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
<Input
className="normal-case font-sans lowercase"
id="install-url"
placeholder="owner/repo or https://..."
spellCheck={false}
value={installId}
onChange={(e) => setInstallId(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-center gap-8">
<div className="flex items-center gap-3">
<Switch checked={installForce} onCheckedChange={setInstallForce} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
{t.pluginsPage.forceReinstall}
</span>
</div>
<div className="flex items-center gap-3">
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
{t.pluginsPage.enableAfterInstall}
</span>
</div>
</div>
<Button
className="w-fit gap-2"
size="sm"
disabled={installBusy}
onClick={() => void onInstall()}
>
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
{t.pluginsPage.installBtn}
</Button>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
{t.pluginsPage.rescanHint}
</p>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
{t.pluginsPage.removeHint}
</p>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85">
{t.pluginsPage.pluginListHeading}
</h3>
{loading ? (
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65">
<Spinner />
<span>{t.common.loading}</span>
</div>
) : rows.length === 0 ? (
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p>
) : (
<ul className="flex flex-col gap-3">
{rows.map((row: HubAgentPluginRow) => (
<li key={row.name}>
<PluginRowCard
{...{ row, rowBusy, setRuntimeLoading, showToast, t }}
/>
</li>
))}
</ul>
)}
</div>
{(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-3 opacity-95">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85">
{t.pluginsPage.orphanHeading}
</h3>
<ul className="flex flex-col gap-2 rounded border border-current/15 p-4">
{hub!.orphan_dashboard_plugins.map((m) => (
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}>
{m.label ?? m.name} {m.description || m.tab?.path}
{!m.tab?.hidden ? (
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
<ExternalLink className="h-3 w-3 opacity-65" />
{t.pluginsPage.openTab}
</Link>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
<Toast toast={toast} />
<PluginSlot name="plugins:bottom" />
</div>
);
}
interface PluginRowCardProps {
row: HubAgentPluginRow;
rowBusy: string | null;
setRuntimeLoading: (
name: string,
fn: () => Promise<unknown>,
) => Promise<void>;
showToast: (msg: string, variant: "success" | "error") => void;
t: Translations;
}
function PluginRowCard(props: PluginRowCardProps) {
const {
row,
rowBusy,
setRuntimeLoading,
showToast,
t,
} = props;
const dm = row.dashboard_manifest;
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
const busy = rowBusy === row.name;
const badgeTone =
row.runtime_status === "enabled"
? "success"
: row.runtime_status === "disabled"
? "destructive"
: "outline";
return (
<Card className={cn(busy ? "opacity-70" : undefined)}>
<CardContent className="flex flex-col gap-4 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<span className="truncate font-semibold">{row.name}</span>
<Badge tone="outline">
{t.pluginsPage.sourceBadge}: {row.source}
</Badge>
<Badge tone="outline">v{row.version || "—"}</Badge>
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
</div>
{row.description ? (
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
{row.description}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Button
disabled={busy || row.runtime_status === "enabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.enableAgentPlugin(row.name);
showToast(t.pluginsPage.enableRuntime, "success");
});
}}
>
{t.pluginsPage.enableRuntime}
</Button>
<Button
disabled={busy || row.runtime_status === "disabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.disableAgentPlugin(row.name);
showToast(t.pluginsPage.disableRuntime, "success");
});
}}
>
{t.pluginsPage.disableRuntime}
</Button>
{tabPath ? (
<Link
className={cn(
"inline-flex items-center rounded-none px-3 py-1.5",
"border border-current/25 hover:bg-current/10",
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase",
)}
to={tabPath}
>
{t.pluginsPage.openTab}
</Link>
) : null}
{row.can_update_git ? (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.updateAgentPlugin(row.name);
showToast(t.pluginsPage.updateGit, "success");
});
}}
>
{busy ? <Spinner /> : null}
{t.pluginsPage.updateGit}
</Button>
) : null}
{row.has_dashboard_manifest ? (
<Button
disabled={busy}
ghost
size="sm"
title={row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.setPluginVisibility(row.name, !row.user_hidden);
});
}}
>
{row.user_hidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
{row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
</Button>
) : null}
{row.can_remove ? (
<Button
destructive
disabled={busy}
ghost
size="sm"
onClick={() => {
const ok =
typeof window !== "undefined"
? window.confirm(t.pluginsPage.removeConfirm)
: false;
if (!ok) return;
void setRuntimeLoading(row.name, async () => {
await api.removeAgentPlugin(row.name);
showToast(`${row.name} removed`, "success");
});
}}
>
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
) : null}
</div>
</div>
{dm?.slots?.length ? (
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
</p>
) : null}
{row.auth_required ? (
<CommandBlock
label={t.pluginsPage.authRequiredHint}
code={row.auth_command}
/>
) : null}
{!row.has_dashboard_manifest && !dm ? (
<p className="text-[0.65rem] italic text-midforeground/45 normal-case">
{t.pluginsPage.noDashboardTab}
</p>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -46,6 +46,8 @@ import React, { Fragment, useEffect, useState } from "react";
* - `cron:bottom` — bottom of /cron page
* - `skills:top` — top of /skills page
* - `skills:bottom` — bottom of /skills page
* - `plugins:top` — top of /plugins page
* - `plugins:bottom` — bottom of /plugins page
* - `config:top` — top of /config page
* - `config:bottom` — bottom of /config page
* - `env:top` — top of /env (Keys) page
@@ -78,6 +80,8 @@ export const KNOWN_SLOT_NAMES = [
"cron:bottom",
"skills:top",
"skills:bottom",
"plugins:top",
"plugins:bottom",
"config:top",
"config:bottom",
"env:top",