feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)

* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats

Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
  /api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
  with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
  list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
  uptime/process, stdlib fallback

All gated by dashboard auth; secrets never returned.

* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats

- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
  env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
  shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats

* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle

Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.

* docs(dashboard): document the comprehensive admin pass + fresh screenshots

Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.

* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops

Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
  /api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
  (backgrounded, tailed via action status)

Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.

* feat(dashboard): curator + portal + diagnostics UI, tests

- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
  Skill curator card (status + pause/resume + run now), and three new
  Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
  coverage for the new GET endpoints (31 tests total)

* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots

Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.

* feat(dashboard): session stats/export/prune + skills hub search endpoints

Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
  POST /api/sessions/prune. /stats is registered before /{session_id} so the
  literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
  returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)

* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit

Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
  prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
  install by identifier with a live install log, and 'Update all' (mirrors
  hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
  updateCronJob, searchSkillsHub + types

Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.

* test(dashboard): cover session stats/rename/export/prune + skills hub search

Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.

* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit

Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
This commit is contained in:
Teknium
2026-06-02 00:16:11 -04:00
committed by GitHub
parent 40ae170647
commit bd8e2ec1a6
20 changed files with 2597 additions and 210 deletions
+637 -28
View File
@@ -753,6 +753,225 @@ async def get_status():
}
@app.get("/api/system/stats")
async def get_system_stats():
"""Host + process system stats for the System page.
OS / Python / host identity from stdlib; CPU / memory / disk / uptime from
psutil when available, with graceful degradation when it isn't. Read-only
and non-sensitive (no env values, no paths beyond the hermes home root).
"""
import platform as _platform
info: Dict[str, Any] = {
"os": _platform.system(),
"os_release": _platform.release(),
"os_version": _platform.version(),
"platform": _platform.platform(),
"arch": _platform.machine(),
"hostname": _platform.node(),
"python_version": _platform.python_version(),
"python_impl": _platform.python_implementation(),
"hermes_version": __version__,
"cpu_count": os.cpu_count(),
}
# psutil enriches the picture when present; everything below is optional.
try:
import psutil # type: ignore
vm = psutil.virtual_memory()
info["memory"] = {
"total": vm.total,
"available": vm.available,
"used": vm.used,
"percent": vm.percent,
}
try:
du = psutil.disk_usage(str(get_hermes_home()))
info["disk"] = {
"total": du.total,
"used": du.used,
"free": du.free,
"percent": du.percent,
}
except Exception:
pass
try:
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
la = getattr(psutil, "getloadavg", None)
if la:
info["load_avg"] = list(la())
except Exception:
pass
try:
boot = psutil.boot_time()
info["uptime_seconds"] = int(time.time() - boot)
except Exception:
pass
try:
proc = psutil.Process()
info["process"] = {
"pid": proc.pid,
"rss": proc.memory_info().rss,
"create_time": int(proc.create_time()),
"num_threads": proc.num_threads(),
}
except Exception:
pass
info["psutil"] = True
except Exception:
info["psutil"] = False
# stdlib-only fallbacks for load average + uptime where the kernel
# exposes them.
try:
info["load_avg"] = list(os.getloadavg())
except (OSError, AttributeError):
pass
return info
# ---------------------------------------------------------------------------
# Curator endpoints — background skill-maintenance status + controls.
#
# The curator periodically reviews skills (archive stale, prune, pin). The
# dashboard surfaces its state and the pause/resume/run-now controls that
# `hermes curator` exposes.
# ---------------------------------------------------------------------------
@app.get("/api/curator")
async def get_curator_status():
try:
from agent import curator
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}")
try:
state = curator.load_state()
except Exception:
state = {}
return {
"enabled": _safe_call(curator, "is_enabled", True),
"paused": _safe_call(curator, "is_paused", False),
"interval_hours": _safe_call(curator, "get_interval_hours", None),
"last_run_at": state.get("last_run_at"),
"min_idle_hours": _safe_call(curator, "get_min_idle_hours", None),
"stale_after_days": _safe_call(curator, "get_stale_after_days", None),
"archive_after_days": _safe_call(curator, "get_archive_after_days", None),
}
class CuratorPause(BaseModel):
paused: bool
@app.put("/api/curator/paused")
async def set_curator_paused(body: CuratorPause):
from agent import curator
curator.set_paused(bool(body.paused))
return {"ok": True, "paused": bool(body.paused)}
@app.post("/api/curator/run")
async def run_curator():
"""Trigger a curator review now (backgrounded; tail via action status)."""
try:
proc = _spawn_hermes_action(["curator", "run"], "curator-run")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}")
return {"ok": True, "pid": proc.pid, "name": "curator-run"}
def _safe_call(mod, fn_name: str, default):
try:
fn = getattr(mod, fn_name, None)
return fn() if callable(fn) else default
except Exception:
return default
# ---------------------------------------------------------------------------
# Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only).
# ---------------------------------------------------------------------------
@app.get("/api/portal")
async def get_portal_status():
cfg = load_config() or {}
auth: Dict[str, Any] = {}
try:
from hermes_cli.auth import get_nous_auth_status
auth = get_nous_auth_status() or {}
except Exception:
auth = {}
features = []
try:
from hermes_cli.nous_subscription import get_nous_subscription_features
feats = get_nous_subscription_features(cfg)
if feats is not None:
for feat in feats.items():
if getattr(feat, "managed_by_nous", False):
state = "via Nous Portal"
elif getattr(feat, "active", False) and getattr(feat, "current_provider", None):
state = feat.current_provider
elif getattr(feat, "active", False):
state = "active"
else:
state = "not configured"
features.append({"label": getattr(feat, "label", ""), "state": state})
except Exception:
_log.exception("portal features failed")
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
return {
"logged_in": bool(auth.get("logged_in")),
"portal_url": auth.get("portal_base_url"),
"inference_url": auth.get("inference_base_url"),
"provider": str((model_cfg or {}).get("provider") or ""),
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
"features": features,
}
# ---------------------------------------------------------------------------
# Diagnostics: prompt-size, support dump, debug upload, config migrate.
# All produce text output, so they spawn background actions tailed via
# /api/actions/<name>/status.
# ---------------------------------------------------------------------------
@app.post("/api/ops/prompt-size")
async def run_prompt_size():
try:
proc = _spawn_hermes_action(["prompt-size"], "prompt-size")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "prompt-size"}
@app.post("/api/ops/dump")
async def run_dump():
try:
proc = _spawn_hermes_action(["dump"], "dump")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "dump"}
@app.post("/api/ops/config-migrate")
async def run_config_migrate():
try:
proc = _spawn_hermes_action(["config", "migrate"], "config-migrate")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "config-migrate"}
# ---------------------------------------------------------------------------
# Gateway + update actions (invoked from the Status page).
#
@@ -779,6 +998,10 @@ _ACTION_LOG_FILES: Dict[str, str] = {
"skills-install": "action-skills-install.log",
"skills-uninstall": "action-skills-uninstall.log",
"skills-update": "action-skills-update.log",
"curator-run": "action-curator-run.log",
"prompt-size": "action-prompt-size.log",
"dump": "action-dump.log",
"config-migrate": "action-config-migrate.log",
}
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
@@ -3690,6 +3913,38 @@ def _session_latest_descendant(session_id: str):
finally:
db.close()
@app.get("/api/sessions/stats")
async def get_session_stats():
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
archived = db.session_count(archived_only=True)
messages = db.message_count()
by_source: Dict[str, int] = {}
try:
for s in db.list_sessions_rich(limit=10000, include_archived=True):
src = str(s.get("source") or "cli")
by_source[src] = by_source.get(src, 0) + 1
except Exception:
pass
return {
"total": total,
"active_store": active_store,
"archived": archived,
"messages": messages,
"by_source": by_source,
}
finally:
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
@@ -3782,6 +4037,49 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
db.close()
@app.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str):
"""Export a single session (metadata + messages) as JSON."""
from hermes_state import SessionDB
db = SessionDB()
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
data = db.export_session(sid)
if data is None:
raise HTTPException(status_code=404, detail="Session not found")
return data
finally:
db.close()
class SessionPrune(BaseModel):
older_than_days: int = 90
source: Optional[str] = None
@app.post("/api/sessions/prune")
async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
if body.older_than_days < 1:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
from hermes_state import SessionDB
db = SessionDB()
try:
sessions_dir = get_hermes_home() / "sessions"
removed = db.prune_sessions(
older_than_days=body.older_than_days,
source=(body.source or None),
sessions_dir=sessions_dir if sessions_dir.exists() else None,
)
return {"ok": True, "removed": removed}
finally:
db.close()
# ---------------------------------------------------------------------------
# Log viewer endpoint
# ---------------------------------------------------------------------------
@@ -4172,6 +4470,129 @@ async def test_mcp_server(name: str):
}
class MCPEnabledToggle(BaseModel):
enabled: bool
@app.put("/api/mcp/servers/{name}/enabled")
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
"""Enable or disable an MCP server (takes effect on next session/gateway).
Toggles the ``enabled`` key on the server's config.yaml entry — the same
flag the agent reads at startup. Disabled servers stay in config so they
can be re-enabled without re-entering their settings.
"""
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
@app.get("/api/mcp/catalog")
async def list_mcp_catalog():
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
Each entry reports whether it's already installed and enabled so the UI
can show install / enabled state inline. This is the same catalog
`hermes mcp catalog` / `hermes mcp install` read.
"""
try:
from hermes_cli import mcp_catalog
except Exception as exc:
_log.exception("mcp_catalog import failed")
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
entries = []
try:
for entry in mcp_catalog.list_catalog():
auth = entry.auth
entries.append({
"name": entry.name,
"description": entry.description,
"source": entry.source,
"transport": entry.transport.type,
"auth_type": getattr(auth, "type", "none"),
# Env vars the user must supply (names + prompts only, never values).
"required_env": [
{"name": e.name, "prompt": e.prompt, "required": e.required}
for e in getattr(auth, "env", []) or []
],
"needs_install": entry.install is not None,
"installed": mcp_catalog.is_installed(entry.name),
"enabled": mcp_catalog.is_enabled(entry.name),
})
except Exception:
_log.exception("list_mcp_catalog failed")
diagnostics = []
try:
diagnostics = [
{"name": n, "kind": k, "message": m}
for (n, k, m) in mcp_catalog.catalog_diagnostics()
]
except Exception:
pass
return {"entries": entries, "diagnostics": diagnostics}
class MCPCatalogInstall(BaseModel):
name: str
# env: KEY=VALUE map for catalog entries that declare required env vars.
env: Dict[str, str] = {}
enable: bool = True
@app.post("/api/mcp/catalog/install")
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
"""Install a catalog MCP into config.yaml.
For HTTP/stdio entries with required env vars, those are written to .env
via the standard env path so the agent can read them at session start.
Entries that need a git bootstrap (``needs_install``) are installed via
the CLI action path because the clone can take time.
"""
from hermes_cli import mcp_catalog
name = (body.name or "").strip()
entry = mcp_catalog.get_entry(name)
if entry is None:
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
# Persist any supplied env vars first (catalog entries declare which names
# they need; we only write the ones the user provided).
if body.env:
for k, v in body.env.items():
if v:
save_env_value(k, v)
# Git-bootstrap entries can take a while to clone — run via the background
# action path so the request returns immediately and the UI can tail logs.
if entry.install is not None:
try:
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
# No git step — install synchronously via the catalog API.
try:
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
except Exception as exc:
_log.exception("install_mcp_catalog_entry failed")
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True, "name": name, "background": False}
# Register the mcp-install action log so /api/actions/mcp-install/status works.
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
# ---------------------------------------------------------------------------
# Pairing endpoints — approve / revoke / list messaging pairing codes.
#
@@ -4283,6 +4704,8 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D
"url": f"{base_url}/webhooks/{name}",
# Secret is masked on read; full value only returned on create.
"secret_set": bool(route.get("secret")),
# Default-enabled; only an explicit enabled:false turns a route off.
"enabled": route.get("enabled", True) is not False,
}
@@ -4367,6 +4790,30 @@ async def delete_webhook(name: str):
return {"ok": True}
class WebhookEnabledToggle(BaseModel):
enabled: bool
@app.put("/api/webhooks/{name}/enabled")
async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
"""Enable or disable a webhook route.
Disabled routes stay in the subscriptions file (so they can be
re-enabled) but the gateway rejects incoming events with 403. The
gateway hot-reloads the subscriptions file, so this takes effect on the
next event without a restart.
"""
import hermes_cli.webhook as wh
key = (name or "").strip().lower()
subs = wh._load_subscriptions()
if key not in subs:
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
subs[key]["enabled"] = bool(body.enabled)
wh._save_subscriptions(subs)
return {"ok": True, "name": key, "enabled": bool(body.enabled)}
# ---------------------------------------------------------------------------
# Gateway lifecycle endpoints — start / stop.
#
@@ -4688,38 +5135,160 @@ async def run_import(body: ImportRequest):
@app.get("/api/ops/hooks")
async def list_hooks():
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
"""List configured shell hooks from config.yaml with consent + health.
Reports each hook's allowlist (consent) status and whether the script is
currently executable, plus the set of valid hook events so the create
form can offer them.
"""
from hermes_cli.config import load_config as _load_config
from agent import shell_hooks
try:
from hermes_cli.plugins import VALID_HOOKS
valid_events = sorted(VALID_HOOKS)
except Exception:
valid_events = []
specs = []
try:
specs = shell_hooks.iter_configured_hooks(_load_config())
except Exception:
_log.exception("iter_configured_hooks failed")
out = []
for spec in specs:
entry = None
try:
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
except Exception:
pass
executable = False
try:
executable = shell_hooks.script_is_executable(spec.command)
except Exception:
pass
out.append({
"event": spec.event,
"matcher": spec.matcher,
"command": spec.command,
"timeout": spec.timeout,
"allowed": entry is not None,
"approved_at": (entry or {}).get("approved_at"),
"executable": executable,
})
return {"hooks": out, "valid_events": valid_events}
class HookCreate(BaseModel):
event: str
command: str
matcher: Optional[str] = None
timeout: Optional[int] = None
# approve: write the consent allowlist entry too (the operator using the
# authenticated dashboard is giving consent). Without it the hook is
# configured but won't fire until approved.
approve: bool = True
@app.post("/api/ops/hooks")
async def create_hook(body: HookCreate):
"""Add a shell hook to config.yaml (and optionally approve it).
Shell hooks run arbitrary commands, so this is a privileged action: it
writes to the ``hooks:`` config block and, when ``approve`` is set, records
consent in the allowlist so the hook actually fires. Takes effect on the
next session / gateway restart.
"""
from agent import shell_hooks
event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")
try:
from hermes_cli.plugins import VALID_HOOKS
if event not in VALID_HOOKS:
raise HTTPException(
status_code=400,
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
)
except HTTPException:
raise
except Exception:
pass
cfg = load_config()
hooks_cfg = cfg.get("hooks")
out = []
if isinstance(hooks_cfg, dict):
for event, entries in hooks_cfg.items():
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
out.append({
"event": event,
"matcher": entry.get("matcher"),
"command": entry.get("command"),
"timeout": entry.get("timeout"),
})
# Consent allowlist status (which commands have been approved for run).
allowlist: List[str] = []
if not isinstance(hooks_cfg, dict):
hooks_cfg = {}
cfg["hooks"] = hooks_cfg
entries = hooks_cfg.get(event)
if not isinstance(entries, list):
entries = []
hooks_cfg[event] = entries
new_entry: Dict[str, Any] = {"command": command}
if body.matcher:
new_entry["matcher"] = body.matcher
if body.timeout is not None:
new_entry["timeout"] = int(body.timeout)
entries.append(new_entry)
save_config(cfg)
approved = False
if body.approve:
try:
shell_hooks._record_approval(event, command)
approved = True
except Exception:
_log.exception("hook consent record failed")
return {"ok": True, "event": event, "command": command, "approved": approved}
class HookDelete(BaseModel):
event: str
command: str
@app.delete("/api/ops/hooks")
async def delete_hook(body: HookDelete):
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
from agent import shell_hooks
event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")
cfg = load_config()
hooks_cfg = cfg.get("hooks")
removed = False
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
before = len(hooks_cfg[event])
hooks_cfg[event] = [
e for e in hooks_cfg[event]
if not (isinstance(e, dict) and e.get("command") == command)
]
removed = len(hooks_cfg[event]) < before
if not hooks_cfg[event]:
del hooks_cfg[event]
if not hooks_cfg:
cfg.pop("hooks", None)
save_config(cfg)
# Revoke consent regardless so a re-add re-prompts.
try:
allow_path = get_hermes_home() / "shell-hooks-allowlist.json"
if allow_path.exists():
data = json.loads(allow_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
allowlist = list(data.keys())
elif isinstance(data, list):
allowlist = [str(x) for x in data]
shell_hooks.revoke(command)
except Exception:
_log.exception("Failed to read shell-hooks allowlist")
for h in out:
h["allowed"] = h.get("command") in allowlist
return {"hooks": out, "allowlist": allowlist}
pass
if not removed:
raise HTTPException(status_code=404, detail="No matching hook found")
return {"ok": True}
@app.get("/api/ops/checkpoints")
@@ -4818,6 +5387,46 @@ async def update_skills_hub():
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
@app.get("/api/skills/hub/search")
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
"""Search the skill hub across all configured sources.
Network-bound (parallel source search); runs in a thread so the FastAPI
loop isn't blocked. Returns structured results the UI installs by
identifier via POST /api/skills/hub/install.
"""
query = (q or "").strip()
if not query:
return {"results": []}
def _run():
from tools.skills_hub import create_source_router, unified_search
sources = create_source_router()
metas = unified_search(
query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50)
)
return [
{
"name": m.name,
"description": m.description,
"source": m.source,
"identifier": m.identifier,
"trust_level": m.trust_level,
"repo": m.repo,
"tags": list(m.tags or []),
}
for m in metas
]
try:
results = await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub search failed")
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
return {"results": results}
# ---------------------------------------------------------------------------
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
# ---------------------------------------------------------------------------