mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 08:21:50 +08:00
Compare commits
6 Commits
opencode-p
...
austin/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e455de97b3 | ||
|
|
19667c8740 | ||
|
|
08955d59aa | ||
|
|
1deeb06fca | ||
|
|
dcf99b55a3 | ||
|
|
7947b89710 |
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"; };
|
||||
|
||||
41
ui-tui/package-lock.json
generated
41
ui-tui/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
169
web/src/App.tsx
169
web/src/App.tsx
@@ -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 }>;
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.yaml:memory.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: "搜索技能和工具集...",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
581
web/src/pages/PluginsPage.tsx
Normal file
581
web/src/pages/PluginsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user