Files
ZachariahSharma f473be4033 feat: add authenticated URL-mode MCP servers from Control Panel
Add an optional auth-token (bearer) field and a custom-headers textarea to
the MCP 'Add server' pane. URL-mode servers are now registered by writing
hermes config directly via the venv's config helpers (save_env_value +
save_config) instead of shelling into the interactive, network-probing
'hermes mcp add --url' flow that hung/504'd on auth challenges.

- Token is stored in ~/.hermes/.env (mode 600) and referenced in config.yaml
  as 'Authorization: Bearer ${MCP_<NAME>_API_KEY}' — never plaintext config.
- Registration is non-blocking: no live probe, so a slow/unreachable server
  can't hang the request.
- Add a per-server 'Test' button (POST /api/mcp/test -> hermes mcp test).
- Contract tests for the new fields, payload, no-probe path, and test route.
2026-06-10 21:19:05 -06:00

1532 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict"
// ── Constants ──────────────────────────────────────────────────────────────
const PROVIDERS = [
{ id: "anthropic", label: "Claude", kind: "OAuth Pool", mark: "A1", default_model: "claude-sonnet-4.6", oauth: true },
{ id: "openai-codex", label: "Codex", kind: "OAuth Pool", mark: "B2", default_model: "gpt-5.4", oauth: true },
{ id: "google-gemini-cli", label: "Gemini", kind: "OAuth Pool", mark: "C3", default_model: "gemini-3.5-flash", oauth: true },
{ id: "deepseek", label: "DeepSeek", kind: "API Key", mark: "D4", default_model: "deepseek-chat", oauth: false }
]
const ALL_PROVIDERS = [
...PROVIDERS,
{ id: "openrouter", label: "OpenRouter", default_model: "anthropic/claude-opus-4" },
{ id: "nous", label: "Nous Portal", default_model: "nous-hermes-3" },
{ id: "gemini", label: "Gemini API", default_model: "gemini-3-pro-preview" },
{ id: "zai", label: "z.ai / GLM", default_model: "glm-4.7" },
{ id: "kimi-coding", label: "Kimi", default_model: "kimi-k2.5" },
{ id: "minimax", label: "MiniMax", default_model: "minimax-text-01" },
{ id: "nvidia", label: "NVIDIA NIM", default_model: "" },
{ id: "huggingface", label: "Hugging Face", default_model: "" },
{ id: "xai", label: "xAI / Grok", default_model: "grok-4" },
{ id: "ollama-cloud", label: "Ollama Cloud", default_model: "" },
{ id: "azure-foundry", label: "Azure Foundry", default_model: "gpt-4o" },
{ id: "lmstudio", label: "LM Studio", default_model: "" },
{ id: "custom", label: "Custom", default_model: "" }
]
const $ = (s, r = document) => r.querySelector(s)
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s))
// ── Net ────────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts
})
const ct = res.headers.get("content-type") || ""
return ct.includes("json") ? res.json() : res.text()
}
function setMsg(text, kind = "") {
const el = $("#bar-msg")
el.textContent = text || ""
el.style.color = kind === "ok" ? "#a8d27a" : kind === "err" ? "#e88a8a" : "#f6f1e3"
const bar = $(".readout")
bar.classList.remove("busy", "error")
if (kind === "err") bar.classList.add("error")
if (text) setTimeout(() => {
if (el.textContent === text) { el.textContent = ""; bar.classList.remove("error") }
}, 4500)
}
function setBusy(on) {
$(".readout").classList.toggle("busy", !!on)
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c])
}
// ── Nav ────────────────────────────────────────────────────────────────────
function setRoute(name) {
$$(".dial-item").forEach((el) => el.toggleAttribute("data-active", el.dataset.route === name))
$$(".pane").forEach((el) => el.toggleAttribute("data-active", el.dataset.pane === name))
// Lazy loads
if (name === "providers") loadProviders()
if (name === "routing") loadRouting()
if (name === "tools") loadTools()
if (name === "skills") loadSkillsRich()
if (name === "mcp") loadMcp()
if (name === "cron") loadCron()
if (name === "sessions") loadSessions()
if (name === "hooks") loadHooks()
if (name === "plugins") loadPluginsRich()
if (name === "bundles") loadBundlesRich()
if (name === "curator") loadCuratorRich()
if (name === "memory") loadMemory()
if (name === "kanban") loadKanban()
if (name === "webhooks") loadWebhooks()
if (name === "profiles") loadProfiles()
if (name === "config") loadConfig()
if (name === "security") {} // on-demand only
if (name === "storage") loadStorage()
if (name === "insights") {} // on-demand only
if (name === "system") loadSystemStatus()
if (name === "api-users") loadApiUsers()
}
document.addEventListener("click", (e) => {
const nav = e.target.closest(".dial-item")
if (nav) return setRoute(nav.dataset.route)
// Tab switching
const tab = e.target.closest(".tab")
if (tab) {
const group = tab.closest(".tabs").dataset.tabGroup
const which = tab.dataset.tab
document.querySelectorAll(`.tabs[data-tab-group="${group}"] .tab`).forEach((t) =>
t.toggleAttribute("data-active", t.dataset.tab === which))
document.querySelectorAll(`.tab-body[data-tab-pane^="${group}:"]`).forEach((tb) =>
tb.toggleAttribute("data-active", tb.dataset.tabPane === `${group}:${which}`))
return
}
const btn = e.target.closest("button")
if (!btn) return
const action = btn.dataset.action
if (!action) return
switch (action) {
case "refresh": refreshCurrent(); return
case "save-deepseek": saveDeepseek(); return
case "set-primary": setPrimary(); return
case "save-chain": saveChain(); return
case "reset-chain": loadRouting(); return
case "add-fallback-row": addChainRow(); return
case "add-mcp": addMcp(); return
case "prune-sessions": pruneSessions(); return
case "run-doctor": runDoctor(); return
case "tail-logs": tailLogs(); return
// hooks
case "hooks-doctor": runHooksDoctor(); return
case "hooks-test": runHooksTest(); return
// skills (rich)
case "skills-discover": skillsDiscover(); return
// plugins (rich)
case "plugins-discover": pluginsDiscover(); return
case "plugin-install-manual": pluginInstallManual(); return
// bundles (rich)
case "bundle-create": bundleCreateRich(); return
// curator
case "curator-run": curatorAction("run"); return
case "curator-pause": curatorAction("pause"); return
case "curator-resume": curatorAction("resume"); return
case "curator-prune": curatorAction("prune"); return
// memory
case "save-memory-md": return saveMemoryMd(btn.dataset.file)
case "memory-off": return memoryOff()
// kanban
case "kanban-create": return kanbanCreate()
// webhooks
case "webhook-add": return webhookAdd()
case "webhook-test": return webhookTest()
// profiles
case "profile-create": return profileAction("create")
case "profile-use": return profileAction("use")
case "profile-delete": return profileAction("delete")
case "pairing-approve": return pairingAction("approve")
case "pairing-revoke": return pairingAction("revoke")
case "pairing-clear": return pairingAction("clear-pending")
// config
case "config-check": return runConfigCheck()
case "config-migrate": return runConfigMigrate()
case "config-set": return configSet()
// security
case "security-run": return runSecurity()
// storage
case "backup-full": return runBackup(false)
case "backup-quick": return runBackup(true)
case "checkpoints-prune": return checkpointsAction("prune")
case "checkpoints-clear": return checkpointsAction("clear")
// insights
case "insights-run": return runInsights()
// system extras
case "prompt-size": return runPromptSize()
case "dump": return runDump()
case "portal-info": return runPortalInfo()
case "run-update": return runUpdate()
}
})
function refreshCurrent() {
const active = $(".dial-item[data-active]")
setRoute(active?.dataset.route || "providers")
}
// ── Bootstrap ──────────────────────────────────────────────────────────────
async function bootstrap() {
try {
const paths = await api("/api/paths")
$("#foot-home").textContent = paths.hermesHome.replace(/^.*\\Local\\/, "…\\")
$("#foot-home").title = paths.hermesHome
} catch {}
await loadProviders()
}
async function loadProviders() {
const data = await api("/api/status")
const ver = (data.version || "").replace(/^Hermes Agent\s*/i, "").replace(/^v/, "")
$("#masthead-version").textContent = ver || "—"
$("#bar-version").textContent = ver || "—"
$("#bar-primary").textContent = data.primary?.provider
? `${data.primary.model}${data.primary.provider}`
: "—"
$("#bar-chain").textContent = `${data.fallback?.entries?.length ?? 0} hop`
+ ((data.fallback?.entries?.length ?? 0) === 1 ? "" : "s")
renderProviderCards(data)
renderDeepseek(data.deepseekConfigured)
}
function renderProviderCards(data) {
const grid = $("#provider-grid")
grid.innerHTML = ""
const poolByProvider = Object.fromEntries((data.pools || []).map((p) => [p.provider, p]))
for (const p of PROVIDERS) {
const pool = poolByProvider[p.id]
const count = pool?.count ?? 0
const authState = pool?.authState || (p.id === "deepseek"
? (data.deepseekConfigured ? { state: "authenticated", label: "Key set" } : { state: "unauthenticated", label: "No key" })
: { state: "unauthenticated", label: "Unauthenticated" })
const card = document.createElement("div")
card.className = "pcard"
card.dataset.provider = p.id
// Cred list
let credsHtml = ""
if (pool && pool.entries.length) {
credsHtml = pool.entries.map((e) => `
<li class="${e.active ? "active" : ""}">
<span class="ix">#${e.index}</span>
<span class="label">
<span>${escapeHtml(e.raw.replace(/\s+←\s*$/, ""))}</span>
${e.identity ? `<span class="account-id">${escapeHtml(e.identity)}</span>` : ""}
</span>
<button class="x" title="Remove" data-remove="${e.index}" data-remove-source="${escapeHtml(e.source || "")}" data-remove-file="${escapeHtml(e.file || "")}">×</button>
</li>`).join("")
credsHtml = `<ul class="pcard-creds">${credsHtml}</ul>`
} else if (p.id === "deepseek") {
credsHtml = `<div class="pcard-empty">${data.deepseekConfigured ? "── KEY SET ──" : "── NO KEY ──"}</div>`
} else {
credsHtml = `<div class="pcard-empty">── EMPTY POOL ──</div>`
}
// Actions
let actionsHtml = ""
if (p.oauth) {
actionsHtml = `
<button class="btn-primary grow" data-add-oauth="${p.id}">+ Add OAuth</button>
<button class="btn-mini" data-reset="${p.id}" title="Clear cooldowns">↺</button>
`
} else {
actionsHtml = `
<a class="btn-mini grow" style="text-align:center;text-decoration:none"
href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener">Get key ↗</a>
`
}
card.innerHTML = `
<div class="pcard-mark">${p.mark}</div>
<div>
<div class="pcard-kind">${p.kind}</div>
<h3 class="pcard-title">${p.label}</h3>
<div class="pcard-sub">${p.id}${count ? ` · ${count}` : ""}</div>
<div class="auth-pill" data-state="${escapeHtml(authState.state)}">${escapeHtml(authState.label)}</div>
</div>
${credsHtml}
<div class="pcard-actions">${actionsHtml}</div>
<pre class="pcard-log" hidden></pre>
<div class="oauth-input" hidden></div>
`
card.addEventListener("click", (e) => {
const t = e.target.closest("button")
if (!t) return
if (t.dataset.addOauth) startOauth(t.dataset.addOauth, card)
else if (t.dataset.abort) cancelOauth(t.dataset.abort, card)
else if (t.dataset.oauthSubmit) submitOauthInput(t.dataset.oauthSubmit, card)
else if (t.dataset.remove) removeCred(p.id, Number(t.dataset.remove), t.dataset.removeSource || "", t.dataset.removeFile || "")
else if (t.dataset.reset) resetPool(t.dataset.reset)
})
grid.appendChild(card)
}
}
function renderDeepseek(set) {
const el = $("#deepseek-status")
if (set) {
el.textContent = "Key written to .env — restart Hermes to load"
el.className = "status ok"
} else {
el.textContent = ""
el.className = "status"
}
}
function renderOauthLog(log, output) {
const escaped = escapeHtml(output || "")
log.innerHTML = escaped.replace(/https?:\/\/[^\s<>"'`]+/g, (url) => {
const clean = url.replace(/[),.;]+$/, "")
const tail = url.slice(clean.length)
return `<a href="${clean}" target="_blank" rel="noopener">${clean}</a>${escapeHtml(tail)}`
})
log.scrollTop = log.scrollHeight
}
async function startOauth(provider, card) {
const log = card.querySelector(".pcard-log")
const actions = card.querySelector(".pcard-actions")
const input = card.querySelector(".oauth-input")
card.classList.add("busy")
setBusy(true)
log.hidden = false
renderOauthLog(log, `▷ Starting OAuth for ${provider}…\nIf no browser opens automatically, use the authorization link printed below.\n`)
actions.innerHTML = `
<button class="btn grow" disabled>Awaiting browser…</button>
<button class="btn-danger" data-abort="${provider}">Abort</button>
`
if (provider === "anthropic") {
input.hidden = false
input.innerHTML = `
<label>After authorizing Claude, paste the authorization code:</label>
<div><input data-oauth-input type="text" autocomplete="off" placeholder="Authorization code"><button class="btn-mini" data-oauth-submit="${provider}">Submit</button></div>
`
} else if (provider === "google-gemini-cli") {
input.hidden = false
input.innerHTML = `
<label>After Google redirects to localhost and fails, paste the full URL:</label>
<div><input data-oauth-input type="text" autocomplete="off" placeholder="http://127.0.0.1:8085/oauth2callback?..."><button class="btn-mini" data-oauth-submit="${provider}">Submit</button></div>
`
} else {
input.hidden = true
input.innerHTML = ""
}
const start = await api("/api/auth/add-oauth", {
method: "POST", body: JSON.stringify({ provider })
})
if (start.error) {
log.textContent += "✗ " + start.error + "\n"
card.classList.remove("busy")
setBusy(false)
setTimeout(loadProviders, 500)
return
}
pollOauth(provider, card)
}
let pollers = {}
async function pollOauth(provider, card) {
clearTimeout(pollers[provider])
const log = card.querySelector(".pcard-log")
const tick = async () => {
const p = await api(`/api/auth/progress?provider=${encodeURIComponent(provider)}`)
if (p.error) return
if (p.output) renderOauthLog(log, p.output)
if (!p.done) { pollers[provider] = setTimeout(tick, 900); return }
card.classList.remove("busy")
setBusy(false)
if (p.aborted) setMsg(`OAuth aborted ▸ ${provider}`, "err")
else if (p.exitCode === 0) setMsg(`OAuth committed ▸ ${provider}`, "ok")
else setMsg(`OAuth failed ▸ ${provider} (code ${p.exitCode})`, "err")
await loadProviders()
}
pollers[provider] = setTimeout(tick, 600)
}
async function cancelOauth(provider, card) {
const log = card.querySelector(".pcard-log")
log.textContent += "\n▷ Aborting…\n"
await api("/api/auth/cancel-oauth", { method: "POST", body: JSON.stringify({ provider }) })
}
async function submitOauthInput(provider, card) {
const input = card.querySelector("[data-oauth-input]")
const value = input?.value.trim() || ""
if (!value) return setMsg("Paste the OAuth callback or code first", "err")
const result = await api("/api/auth/submit-input", {
method: "POST",
body: JSON.stringify({ provider, value }),
})
if (result.error) return setMsg(result.error, "err")
input.value = ""
input.disabled = true
card.querySelector("[data-oauth-submit]").disabled = true
setMsg(`OAuth response submitted ▸ ${provider}`, "ok")
}
async function removeCred(provider, index, source = "", file = "") {
const label = source === "mounted-auth" && file ? `${file} from ${provider}` : `credential #${index} from ${provider}`
const prompt = source === "mounted-auth" && file
? `Remove ${label}?\n\nThis moves the mounted auth file into .hermes-control-plane-deleted-auth.`
: `Remove ${label}?`
if (!confirm(prompt)) return
const r = await api("/api/auth/remove", { method: "POST", body: JSON.stringify({ provider, index, source, file }) })
if (r.exitCode === 0) setMsg(source === "mounted-auth" && file ? `Removed ▸ ${file}` : `Removed ▸ ${provider} #${index}`, "ok")
else setMsg(r.error || "Remove failed", "err")
loadProviders()
}
async function resetPool(provider) {
const r = await api("/api/auth/reset", { method: "POST", body: JSON.stringify({ provider }) })
if (r.exitCode === 0) setMsg(`Cooldowns cleared ▸ ${provider}`, "ok")
else setMsg("Reset failed", "err")
loadProviders()
}
async function saveDeepseek() {
const input = $("#deepseek-key")
const key = input.value.trim()
if (!key) { setMsg("Paste a DeepSeek key first", "err"); return }
const r = await api("/api/auth/deepseek", { method: "POST", body: JSON.stringify({ key }) })
if (r.error) { setMsg(r.error, "err"); return }
input.value = ""
setMsg("DeepSeek key saved to .env", "ok")
loadProviders()
}
// ── Routing ────────────────────────────────────────────────────────────────
async function loadRouting() {
const sel = $("#primary-provider")
if (sel && !sel.options.length) {
for (const p of ALL_PROVIDERS) {
const opt = document.createElement("option")
opt.value = p.id
opt.textContent = `${p.label} · ${p.id}`
sel.appendChild(opt)
}
}
const status = await api("/api/status")
if (status.primary?.provider) sel.value = status.primary.provider
$("#primary-model").value = status.primary?.model || ""
const fb = await api("/api/fallback")
const list = $("#chain-edit")
list.innerHTML = ""
const entries = fb.file?.length
? fb.file
: (fb.cli?.entries || []).map((e) => ({ provider: e.provider, model: e.model }))
entries.forEach((e, i) => list.appendChild(chainRow(i, e)))
$("#chain-status").textContent = ""
$("#chain-status").className = "status"
}
function chainRow(idx, entry = {}) {
const li = document.createElement("li")
li.dataset.idx = String(idx)
const providerOpts = ALL_PROVIDERS.map((p) =>
`<option value="${p.id}" ${p.id === entry.provider ? "selected" : ""}>${p.label}</option>`
).join("")
li.innerHTML = `
<span class="idx">${String(idx + 1).padStart(2, "0")}</span>
<select data-field="provider">${providerOpts}</select>
<input data-field="model" value="${escapeHtml(entry.model || "")}" placeholder="model id" />
<div class="moves">
<button data-move="up" title="Move up">↑</button>
<button data-move="down" title="Move down">↓</button>
</div>
<button class="btn-danger btn-mini" data-remove-row title="Remove">×</button>
`
li.addEventListener("click", (e) => {
const b = e.target.closest("button")
if (!b) return
if (b.dataset.move === "up") moveRow(li, -1)
else if (b.dataset.move === "down") moveRow(li, 1)
else if (b.hasAttribute("data-remove-row")) li.remove()
reindex()
})
return li
}
function moveRow(li, dir) {
const sib = dir === -1 ? li.previousElementSibling : li.nextElementSibling
if (!sib) return
if (dir === -1) li.parentElement.insertBefore(li, sib)
else li.parentElement.insertBefore(sib, li)
}
function reindex() {
$$("#chain-edit li").forEach((li, i) => {
li.querySelector(".idx").textContent = String(i + 1).padStart(2, "0")
})
}
function addChainRow() {
const list = $("#chain-edit")
list.appendChild(chainRow(list.children.length, { provider: "openai-codex", model: "" }))
}
async function setPrimary() {
const provider = $("#primary-provider").value
const model = $("#primary-model").value.trim()
if (!provider || !model) { setMsg("Pick provider + model", "err"); return }
const r = await api("/api/model/set", { method: "POST", body: JSON.stringify({ provider, model }) })
if (r.ok) setMsg(`Primary ▸ ${model} via ${provider}`, "ok")
else setMsg(r.error || "Failed", "err")
loadProviders()
}
async function saveChain() {
const entries = $$("#chain-edit li").map((li) => ({
provider: li.querySelector('[data-field="provider"]').value,
model: li.querySelector('[data-field="model"]').value.trim()
})).filter((e) => e.provider && e.model)
const r = await api("/api/fallback/set", { method: "POST", body: JSON.stringify({ entries }) })
if (r.ok) {
setMsg(`Saved ${r.count} fallback entries`, "ok")
$("#chain-status").textContent = `Wrote ${r.count} entries · config.yaml`
$("#chain-status").className = "status ok"
loadProviders()
} else {
setMsg(r.error || "Failed", "err")
$("#chain-status").textContent = r.error || "Failed"
$("#chain-status").className = "status err"
}
}
// ── Tools ──────────────────────────────────────────────────────────────────
async function loadTools() {
const r = await api("/api/tools")
$("#tools-raw").textContent = (r.raw || "(no output)").trim()
}
// ── §03 Skills (rich, card-based + hub marketplace) ────────────────────────
let SKILLS_CACHE = []
async function loadSkillsRich() {
const r = await api("/api/skills/rich")
SKILLS_CACHE = r.skills || []
$("#skills-count").textContent = SKILLS_CACHE.length
// Populate category filter
const cats = [...new Set(SKILLS_CACHE.map((s) => s.category).filter(Boolean))].sort()
const sel = $("#skills-cat-filter")
if (sel && sel.options.length <= 1) {
for (const c of cats) {
const o = document.createElement("option"); o.value = c; o.textContent = c
sel.appendChild(o)
}
}
renderSkillsGrid()
$("#skills-filter").oninput = renderSkillsGrid
$("#skills-cat-filter").onchange = renderSkillsGrid
}
function renderSkillsGrid() {
const grid = $("#skills-grid")
const q = ($("#skills-filter").value || "").trim().toLowerCase()
const cat = $("#skills-cat-filter").value
const filtered = SKILLS_CACHE.filter((s) => {
if (cat && s.category !== cat) return false
if (!q) return true
const hay = `${s.name} ${s.description} ${s.category} ${(s.tags || []).join(" ")}`.toLowerCase()
return hay.includes(q)
})
if (!filtered.length) { grid.innerHTML = `<div class="empty-pane">── no skills match ──</div>`; return }
grid.innerHTML = filtered.map(renderSkillCard).join("")
}
function renderSkillCard(s) {
const tags = (s.tags || []).slice(0, 4).map((t) => `<span class="chip">${escapeHtml(t)}</span>`).join("")
const plats = (s.platforms || []).map((p) => `<span class="chip platform">${escapeHtml(p)}</span>`).join("")
return `
<article class="scard" data-skill="${escapeHtml(s.name)}">
<div class="scard-tag">${escapeHtml(s.category || "skill")}</div>
<h3 class="scard-title">${escapeHtml(s.name)}</h3>
<div class="scard-sub">
<span><strong>v${escapeHtml(s.version || "—")}</strong></span>
${s.author ? `<span>· ${escapeHtml(s.author)}</span>` : ""}
${s.license ? `<span>· ${escapeHtml(s.license)}</span>` : ""}
</div>
<p class="scard-desc">${escapeHtml(s.description || "(no description)")}</p>
${(tags || plats) ? `<div class="scard-chips">${tags}${plats}</div>` : ""}
</article>`
}
async function skillsDiscover() {
const q = $("#skills-search").value.trim()
const source = $("#skills-source").value
const grid = $("#skills-discover-grid")
if (!q) { setMsg("Enter a search term", "err"); return }
grid.innerHTML = `<div class="empty-pane">searching <code>${escapeHtml(source)}</code>…</div>`
const r = await api(`/api/skills/discover?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}`)
const results = r.results || []
if (!results.length) { grid.innerHTML = `<div class="empty-pane">── no results ──</div>`; return }
grid.innerHTML = results.map(renderDiscoverSkillCard).join("")
grid.querySelectorAll("[data-install-skill]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.installSkill
btn.disabled = true; btn.textContent = "Installing…"
setBusy(true)
const ir = await api("/api/skills/install", { method: "POST", body: JSON.stringify({ identifier: id }) })
setBusy(false)
btn.textContent = ir.exitCode === 0 ? "✓ Installed" : `✗ (${ir.exitCode})`
setMsg(`skill install ${id}${ir.exitCode}`, ir.exitCode === 0 ? "ok" : "err")
if (ir.exitCode === 0) loadSkillsRich()
})
})
}
function renderDiscoverSkillCard(s) {
const id = s.identifier || s.id || s.name
const trust = (s.trust || "").includes("official") ? "official" : (s.trust || "community")
const trustChip = trust === "official" ? `<span class="chip" style="color:var(--hazard);border-color:var(--hazard-line);background:var(--hazard-soft)">★ official</span>` : `<span class="chip">${escapeHtml(trust)}</span>`
return `
<article class="scard">
<div class="scard-tag">${escapeHtml(s.source || "hub")}</div>
<h3 class="scard-title">${escapeHtml(s.name || id)}</h3>
<div class="scard-sub"><span>${escapeHtml(id)}</span></div>
<p class="scard-desc">${escapeHtml(s.description || "")}</p>
<div class="scard-chips">${trustChip}</div>
<div class="scard-actions">
<button class="btn-primary grow" data-install-skill="${escapeHtml(id)}">+ Install</button>
</div>
</article>`
}
// ── §04 Plugins (rich) ─────────────────────────────────────────────────────
let PLUGINS_CACHE = []
async function loadPluginsRich() {
const r = await api("/api/plugins/rich")
PLUGINS_CACHE = r.plugins || []
$("#plugins-count").textContent = PLUGINS_CACHE.length
renderPluginsGrid()
$("#plugins-filter").oninput = renderPluginsGrid
$("#plugins-status-filter").onchange = renderPluginsGrid
}
function renderPluginsGrid() {
const grid = $("#plugins-grid")
const q = ($("#plugins-filter").value || "").trim().toLowerCase()
const status = $("#plugins-status-filter").value
const filtered = PLUGINS_CACHE.filter((p) => {
if (status && p.status !== status) return false
if (!q) return true
return `${p.name} ${p.description} ${(p.hooks || []).join(" ")} ${p.source}`.toLowerCase().includes(q)
})
if (!filtered.length) { grid.innerHTML = `<div class="empty-pane">── no plugins match ──</div>`; return }
grid.innerHTML = filtered.map(renderPluginCard).join("")
grid.querySelectorAll("button[data-plugin-action]").forEach((btn) => {
btn.addEventListener("click", async () => {
const action = btn.dataset.pluginAction
const name = btn.dataset.pluginName
if (action === "remove" && !confirm(`Remove plugin "${name}"?`)) return
btn.disabled = true; btn.textContent = `${action}…`
const r = await api("/api/plugins/action", { method: "POST", body: JSON.stringify({ action, name }) })
setMsg(`plugin ${action} ${name}${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadPluginsRich()
})
})
}
function renderPluginCard(p) {
const hooks = (p.hooks || []).map((h) => `<span class="chip hook">${escapeHtml(h)}</span>`).join("")
const statusLabel = p.status || "unknown"
const isUserGit = p.source && /^(git|user)/i.test(p.source)
const actions = []
if (p.status === "enabled") actions.push(`<button class="btn-mini" data-plugin-action="disable" data-plugin-name="${escapeHtml(p.name)}">Disable</button>`)
else actions.push(`<button class="btn-mini" data-plugin-action="enable" data-plugin-name="${escapeHtml(p.name)}">Enable</button>`)
if (isUserGit) {
actions.push(`<button class="btn-mini" data-plugin-action="update" data-plugin-name="${escapeHtml(p.name)}">Update</button>`)
actions.push(`<button class="btn-danger btn-mini" data-plugin-action="remove" data-plugin-name="${escapeHtml(p.name)}">Remove</button>`)
}
return `
<article class="scard" data-status="${escapeHtml(statusLabel)}">
<div class="scard-tag">${escapeHtml(statusLabel)}</div>
<h3 class="scard-title">${escapeHtml(p.name)}</h3>
<div class="scard-sub">
<span><strong>v${escapeHtml(p.version || "—")}</strong></span>
<span>· ${escapeHtml(p.source || "—")}</span>
${p.author ? `<span>· ${escapeHtml(p.author)}</span>` : ""}
</div>
<p class="scard-desc">${escapeHtml(p.description || "")}</p>
${hooks ? `<div class="scard-chips">${hooks}</div>` : ""}
<div class="scard-actions">${actions.join("")}</div>
</article>`
}
async function pluginsDiscover() {
const q = $("#plugins-search").value.trim()
const grid = $("#plugins-discover-grid")
grid.innerHTML = `<div class="empty-pane">searching GitHub…</div>`
const r = await api(`/api/plugins/discover?q=${encodeURIComponent(q)}`)
if (r.error && !r.results?.length) {
grid.innerHTML = `<div class="empty-pane">⚠ ${escapeHtml(r.error)}</div>`
return
}
const items = r.results || []
if (!items.length) { grid.innerHTML = `<div class="empty-pane">── no plugins found ──</div>`; return }
grid.innerHTML = items.map(renderPluginDiscoverCard).join("")
grid.querySelectorAll("[data-install-plugin]").forEach((btn) => {
btn.addEventListener("click", async () => {
const target = btn.dataset.installPlugin
btn.disabled = true; btn.textContent = "Installing…"
setBusy(true)
const ir = await api("/api/plugins/install", { method: "POST", body: JSON.stringify({ target }) })
setBusy(false)
btn.textContent = ir.exitCode === 0 ? "✓ Installed" : `✗ (${ir.exitCode})`
const log = $("#plugins-log"); log.hidden = false
log.textContent = ir.output || ""
setMsg(`plugin install ${target}${ir.exitCode}`, ir.exitCode === 0 ? "ok" : "err")
if (ir.exitCode === 0) loadPluginsRich()
})
})
}
function renderPluginDiscoverCard(p) {
const topics = (p.topics || []).slice(0, 4).map((t) => `<span class="chip">${escapeHtml(t)}</span>`).join("")
const lang = p.language ? `<span class="chip lang">${escapeHtml(p.language)}</span>` : ""
return `
<article class="scard">
<div class="scard-tag scard-stars">${p.stars}</div>
<h3 class="scard-title">${escapeHtml(p.full_name)}</h3>
<div class="scard-sub"><a href="${escapeHtml(p.html_url)}" target="_blank" rel="noopener" style="color:var(--ink-3)">${escapeHtml(p.html_url)}</a></div>
<p class="scard-desc">${escapeHtml(p.description || "(no description)")}</p>
${(topics || lang) ? `<div class="scard-chips">${topics}${lang}</div>` : ""}
<div class="scard-actions">
<button class="btn-primary grow" data-install-plugin="${escapeHtml(p.full_name)}">+ Install</button>
<a class="btn-mini" href="${escapeHtml(p.html_url)}" target="_blank" rel="noopener" style="text-decoration:none">View ↗</a>
</div>
</article>`
}
async function pluginInstallManual() {
const target = $("#plugin-install-target").value.trim()
if (!target) { setMsg("target required", "err"); return }
const log = $("#plugins-log"); log.hidden = false; log.textContent = `▷ installing ${target}…\n`
setBusy(true)
const r = await api("/api/plugins/install", { method: "POST", body: JSON.stringify({ target }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`plugin install → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#plugin-install-target").value = ""; loadPluginsRich() }
}
// ── §05 Bundles (rich) ─────────────────────────────────────────────────────
let BUNDLED_CACHE = []
async function loadBundlesRich() {
const r = await api("/api/bundles/rich")
BUNDLED_CACHE = r.bundledOfficialSkills || []
$("#bundled-count").textContent = `${r.bundledOfficialTotal || 0} skills`
// User bundles
const userGrid = $("#user-bundles")
const ub = r.userBundles || []
if (!ub.length) {
userGrid.innerHTML = `<div class="empty-pane">── no custom bundles yet ──<br>Create one below to wire several skills into a single <code>/slash</code> command.</div>`
} else {
userGrid.innerHTML = ub.map((b) => `
<article class="scard user">
<div class="scard-tag">custom</div>
<h3 class="scard-title">/${escapeHtml(b.name)}</h3>
<div class="scard-sub"><span>${escapeHtml(b._dir || b._file || "")}</span></div>
<div class="scard-actions">
<button class="btn-danger btn-mini grow" data-bundle-delete="${escapeHtml(b.name)}">Delete</button>
</div>
</article>`).join("")
userGrid.querySelectorAll("[data-bundle-delete]").forEach((btn) => {
btn.addEventListener("click", async () => {
const name = btn.dataset.bundleDelete
if (!confirm(`Delete bundle "${name}"?`)) return
const dr = await api("/api/bundles/delete", { method: "POST", body: JSON.stringify({ name }) })
setMsg(`bundle delete ${name}${dr.exitCode}`, dr.exitCode === 0 ? "ok" : "err")
loadBundlesRich()
})
})
}
// Bundled official skills — populate cat filter
const cats = [...new Set(BUNDLED_CACHE.map((b) => b.category).filter(Boolean))].sort()
const sel = $("#bundles-cat-filter")
if (sel && sel.options.length <= 1) {
for (const c of cats) {
const o = document.createElement("option"); o.value = c; o.textContent = c
sel.appendChild(o)
}
}
renderBundledGrid()
$("#bundles-filter").oninput = renderBundledGrid
$("#bundles-cat-filter").onchange = renderBundledGrid
}
function renderBundledGrid() {
const grid = $("#bundled-grid")
const q = ($("#bundles-filter").value || "").trim().toLowerCase()
const cat = $("#bundles-cat-filter").value
const filtered = BUNDLED_CACHE.filter((b) => {
if (cat && b.category !== cat) return false
if (!q) return true
return `${b.name} ${b.category} ${b.description}`.toLowerCase().includes(q)
})
if (!filtered.length) { grid.innerHTML = `<div class="empty-pane">── no matches ──</div>`; return }
grid.innerHTML = filtered.slice(0, 200).map((b) => `
<article class="scard">
<div class="scard-tag">${escapeHtml(b.category || "—")}</div>
<h3 class="scard-title">${escapeHtml(b.name)}</h3>
<div class="scard-sub"><span>${escapeHtml(b.hash)}</span></div>
<p class="scard-desc">${escapeHtml(b.description || "(no description on disk)")}</p>
</article>`).join("") + (filtered.length > 200 ? `<div class="empty-pane">… ${filtered.length - 200} more · refine filter to see</div>` : "")
}
async function bundleCreateRich() {
const name = $("#bundle-name").value.trim()
const skillsStr = $("#bundle-skills").value.trim()
if (!name || !skillsStr) { setMsg("name + skills required", "err"); return }
const skills = skillsStr.split(/[,\s]+/).filter(Boolean)
const log = $("#bundles-log"); log.hidden = false; log.textContent = `▷ creating bundle ${name}…\n`
const r = await api("/api/bundles/create", { method: "POST", body: JSON.stringify({ name, skills }) })
log.textContent += r.output || ""
setMsg(`bundle create ${name}${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) {
$("#bundle-name").value = ""; $("#bundle-skills").value = ""
loadBundlesRich()
}
}
// ── §06 Curator (rich dashboard) ───────────────────────────────────────────
async function loadCuratorRich() {
const r = await api("/api/curator/rich")
const stats = r.stats || {}
const counts = r.counts || {}
const tiles = [
{ label: "State", value: stats.state || "—", sub: stats.interval ? `interval · ${stats.interval}` : "", cls: stats.state === "ENABLED" ? "ok" : "warn" },
{ label: "Total skills", value: counts.total ?? "—", sub: "agent-created", cls: "" },
{ label: "Active", value: counts.active ?? "—", sub: stats["stale after"] ? `stale after ${stats["stale after"]}` : "", cls: "ok" },
{ label: "Archived", value: counts.archived ?? "—", sub: stats["archive after"] ? `auto after ${stats["archive after"]}` : "", cls: counts.archived > 0 ? "warn" : "" }
]
$("#curator-stats").innerHTML = tiles.map((t) => `
<div class="stat-tile ${t.cls}">
<div class="stat-label">${escapeHtml(t.label)}</div>
<div class="stat-value">${escapeHtml(String(t.value))}</div>
${t.sub ? `<div class="stat-sub">${escapeHtml(t.sub)}</div>` : ""}
</div>`).join("")
const meta = $("#curator-meta")
const metaPairs = [
["runs", stats.runs],
["last run", stats["last run"]],
["last summary", stats["last summary"]],
["interval", stats.interval],
["stale threshold", stats["stale after"]],
["archive threshold", stats["archive after"]]
].filter(([, v]) => v != null && v !== "")
meta.innerHTML = metaPairs.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`).join("")
const tbody = $("#curator-recent tbody")
tbody.innerHTML = (r.leastRecent || []).map((s) => `
<tr>
<td>${escapeHtml(s.name)}</td>
<td style="font-family:var(--ff-mono);color:var(--ink-3)">${s.activity}</td>
<td style="font-family:var(--ff-mono);color:var(--ink-3)">${s.uses}</td>
<td style="font-family:var(--ff-mono);color:var(--ink-3)">${escapeHtml(s.lastActivity)}</td>
</tr>`).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--ink-4)">── no recent data ──</td></tr>`
const archived = $("#curator-archived")
archived.innerHTML = (r.archived || []).length
? (r.archived.map((a) => `<li><span class="id">${escapeHtml(a)}</span></li>`).join(""))
: `<li class="empty">── none archived ──</li>`
}
// ── MCP ────────────────────────────────────────────────────────────────────
async function loadMcp() {
const r = await api("/api/mcp")
const list = $("#mcp-list")
list.innerHTML = ""
if (!r.servers?.length) {
list.innerHTML = `<li class="empty">── no servers connected ──</li>`
return
}
for (const s of r.servers) {
const li = document.createElement("li")
li.innerHTML = `
<span>
<strong>${escapeHtml(s.name)}</strong>
<span class="id"> · ${escapeHtml(s.detail || "")}</span>
</span>
<span class="row-actions">
<button class="btn-ghost btn-mini" data-mcp-test="${escapeHtml(s.name)}">Test</button>
<button class="btn-danger btn-mini" data-mcp-remove="${escapeHtml(s.name)}">Remove</button>
</span>
`
li.querySelector("[data-mcp-test]").addEventListener("click", () => {
testMcp(s.name, $("#mcp-log"))
})
li.querySelector("[data-mcp-remove]").addEventListener("click", async () => {
if (!confirm(`Remove MCP server "${s.name}"?`)) return
await api("/api/mcp/remove", { method: "POST", body: JSON.stringify({ name: s.name }) })
setMsg(`Removed ▸ ${s.name}`, "ok")
loadMcp()
})
list.appendChild(li)
}
}
async function addMcp() {
const name = $("#mcp-name").value.trim()
const url = $("#mcp-url").value.trim()
const token = $("#mcp-token").value.trim()
const headers = $("#mcp-headers").value.trim()
const command = $("#mcp-command").value.trim()
const args = $("#mcp-args").value.trim().split(/\s+/).filter(Boolean)
if (!name) { setMsg("Name required", "err"); return }
if (!url && !command) { setMsg("URL or command required", "err"); return }
const log = $("#mcp-log")
log.hidden = false
log.textContent = "▷ Adding…\n"
const r = await api("/api/mcp/add", {
method: "POST", body: JSON.stringify({ name, url, token, headers, command, args })
})
log.textContent += r.output || ""
if (r.exitCode === 0) {
setMsg(`Added ▸ ${name}`, "ok")
$("#mcp-name").value = $("#mcp-url").value = $("#mcp-token").value = ""
$("#mcp-headers").value = $("#mcp-command").value = $("#mcp-args").value = ""
loadMcp()
} else setMsg(`Add failed (${r.exitCode})`, "err")
}
async function testMcp(name, logEl) {
logEl.hidden = false
logEl.textContent = `▷ Testing ${name}…\n`
const r = await api("/api/mcp/test", {
method: "POST", body: JSON.stringify({ name })
})
logEl.textContent += r.output || ""
setMsg(r.exitCode === 0 ? `Connected ▸ ${name}` : `Test failed ▸ ${name}`, r.exitCode === 0 ? "ok" : "err")
}
// ── Cron ───────────────────────────────────────────────────────────────────
async function loadCron() {
const r = await api("/api/cron")
const list = $("#cron-list")
list.innerHTML = ""
if (!r.jobs?.length) {
list.innerHTML = `<li class="empty">── no scheduled tasks ──</li>`
return
}
for (const j of r.jobs) {
const li = document.createElement("li")
const paused = j.status.toLowerCase().includes("paused")
li.innerHTML = `
<span>
<span class="id">${j.id.slice(0, 8)}</span>
&nbsp;&nbsp;<strong>${escapeHtml(j.schedule)}</strong>
&nbsp;·&nbsp; ${escapeHtml(j.prompt)}
&nbsp;<span class="badge">${escapeHtml(j.status)}</span>
</span>
<span class="row">
<button class="btn-mini" data-cron-action="run">Run</button>
<button class="btn-mini" data-cron-action="${paused ? "resume" : "pause"}">${paused ? "Resume" : "Pause"}</button>
<button class="btn-danger btn-mini" data-cron-action="remove">Remove</button>
</span>
`
li.addEventListener("click", async (e) => {
const b = e.target.closest("button")
if (!b) return
if (b.dataset.cronAction === "remove" && !confirm(`Remove cron ${j.id.slice(0, 8)}?`)) return
await api("/api/cron/action", {
method: "POST", body: JSON.stringify({ id: j.id, action: b.dataset.cronAction })
})
setMsg(`${b.dataset.cronAction}${j.id.slice(0, 8)}`, "ok")
loadCron()
})
list.appendChild(li)
}
}
// ── Sessions ───────────────────────────────────────────────────────────────
async function loadSessions() {
const r = await api("/api/sessions")
$("#sessions-count").textContent = `${r.sessions?.length || 0} sessions`
$("#sessions-raw").textContent = (r.raw || "(no output)").trim()
}
async function pruneSessions() {
const days = Number($("#prune-days").value || 30)
if (!confirm(`Prune sessions older than ${days} days?`)) return
const r = await api("/api/sessions/prune", { method: "POST", body: JSON.stringify({ days }) })
if (r.exitCode === 0) setMsg(`Pruned ▸ > ${days}d`, "ok")
else setMsg("Prune failed", "err")
loadSessions()
}
// ── System ─────────────────────────────────────────────────────────────────
async function loadSystemStatus() {
$("#system-raw").textContent = "▷ querying…"
const r = await api("/api/system")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runDoctor() {
$("#system-raw").textContent = "▷ running doctor…"
const r = await api("/api/doctor")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function tailLogs() {
$("#system-raw").textContent = "▷ fetching logs…"
const r = await api("/api/logs")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
// ── §08 Hooks ──────────────────────────────────────────────────────────────
async function loadHooks() {
const r = await api("/api/hooks")
$("#hooks-raw").textContent = (r.raw || "(no output)").trim()
}
async function runHooksDoctor() {
$("#hooks-raw").textContent = "▷ running hooks doctor…"
const r = await api("/api/hooks/doctor")
$("#hooks-raw").textContent = (r.raw || "(no output)").trim()
}
async function runHooksTest() {
const event = $("#hook-event").value
const log = $("#hooks-log")
log.hidden = false; log.textContent = `▷ firing ${event}…\n`
const r = await api("/api/hooks/test", { method: "POST", body: JSON.stringify({ event }) })
log.textContent += r.output || ""
setMsg(`hooks test ${event} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §09 Plugins / Bundles / Curator ────────────────────────────────────────
async function loadPlugins() {
const r = await api("/api/plugins")
$("#plugins-raw").textContent = (r.plugins || "").trim() || "(no plugins installed)"
$("#bundles-raw").textContent = (r.bundles || "").trim() || "(no bundles)"
$("#curator-raw").textContent = (r.curator || "").trim() || "(curator status unavailable)"
}
async function pluginInstall() {
const target = $("#plugin-target").value.trim()
if (!target) { setMsg("Provide a plugin target", "err"); return }
const log = $("#plugins-log")
log.hidden = false; log.textContent = `▷ installing ${target}…\n`
setBusy(true)
const r = await api("/api/plugins/install", { method: "POST", body: JSON.stringify({ target }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`plugin install ${target} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#plugin-target").value = ""; loadPlugins() }
}
async function pluginAction(action, name) {
const log = $("#plugins-log")
log.hidden = false; log.textContent = `▷ ${action} ${name || "(all)"}…\n`
setBusy(true)
const r = await api("/api/plugins/action", { method: "POST", body: JSON.stringify({ action, name }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`plugin ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadPlugins()
}
async function curatorAction(action) {
$("#curator-raw").textContent = `▷ curator ${action}…`
const r = await api("/api/curator/action", { method: "POST", body: JSON.stringify({ action }) })
$("#curator-raw").textContent = r.output || "(no output)"
setMsg(`curator ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §10 Memory ─────────────────────────────────────────────────────────────
async function loadMemory() {
const r = await api("/api/memory")
$("#memory-status-raw").textContent = (r.status || "(no provider info)").trim()
$("#memory-md").value = r.memory?.content ?? ""
$("#user-md").value = r.user?.content ?? ""
}
async function saveMemoryMd(file) {
const id = file === "MEMORY.md" ? "memory-md" : "user-md"
const content = $("#" + id).value
const r = await api("/api/memory/save", { method: "POST", body: JSON.stringify({ file, content }) })
if (r.ok) setMsg(`${file} saved`, "ok")
else setMsg("Save failed", "err")
}
async function memoryOff() {
if (!confirm("Disable external memory provider?")) return
const r = await api("/api/memory/off", { method: "POST" })
setMsg(`memory off → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadMemory()
}
// ── §11 Kanban ─────────────────────────────────────────────────────────────
async function loadKanban() {
const r = await api("/api/kanban")
$("#kanban-boards-raw").textContent = (r.boards || "(no boards)").trim()
$("#kanban-tasks-raw").textContent = (r.tasks || "(no tasks)").trim()
}
async function kanbanCreate() {
const title = $("#kanban-title").value.trim()
const board = $("#kanban-board").value.trim()
if (!title) { setMsg("title required", "err"); return }
const log = $("#kanban-log")
log.hidden = false; log.textContent = "▷ creating…\n"
const r = await api("/api/kanban/create", { method: "POST", body: JSON.stringify({ title, board }) })
log.textContent += r.output || ""
setMsg(`kanban create → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#kanban-title").value = ""; loadKanban() }
}
// ── §12 Webhooks ───────────────────────────────────────────────────────────
async function loadWebhooks() {
const r = await api("/api/webhooks")
$("#webhooks-raw").textContent = (r.raw || "(no webhooks)").trim()
}
async function webhookAdd() {
const url = $("#webhook-url").value.trim()
const event = $("#webhook-event").value.trim()
if (!url) { setMsg("url required", "err"); return }
const log = $("#webhooks-log")
log.hidden = false; log.textContent = "▷ subscribing…\n"
const r = await api("/api/webhooks/add", { method: "POST", body: JSON.stringify({ url, event }) })
log.textContent += r.output || ""
setMsg(`webhook add → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#webhook-url").value = ""; $("#webhook-event").value = ""; loadWebhooks() }
}
async function webhookTest() {
const target = $("#webhook-test-target").value.trim()
if (!target) { setMsg("target required", "err"); return }
const log = $("#webhooks-log")
log.hidden = false; log.textContent = "▷ posting test…\n"
const r = await api("/api/webhooks/test", { method: "POST", body: JSON.stringify({ target }) })
log.textContent += r.output || ""
setMsg(`webhook test → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §13 Profiles / Pairing ─────────────────────────────────────────────────
async function loadProfiles() {
const r = await api("/api/profiles")
$("#profiles-raw").textContent = (r.profiles || "(no profiles)").trim()
$("#pairing-raw").textContent = (r.pairing || "(no pairing entries)").trim()
}
async function profileAction(action) {
const name = $("#profile-name").value.trim()
if (!name) { setMsg("profile name required", "err"); return }
if (action === "delete" && !confirm(`Delete profile "${name}"?`)) return
const log = $("#profiles-log")
log.hidden = false; log.textContent = `▷ profile ${action} ${name}…\n`
const r = await api("/api/profiles/action", { method: "POST", body: JSON.stringify({ action, name }) })
log.textContent += r.output || ""
setMsg(`profile ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadProfiles()
}
async function pairingAction(action) {
const subject = $("#pairing-subject").value.trim()
if (action !== "clear-pending" && !subject) { setMsg("code/user required", "err"); return }
const r = await api("/api/pairing/action", { method: "POST", body: JSON.stringify({ action, subject }) })
setMsg(`pairing ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadProfiles()
}
// ── §14 Config ─────────────────────────────────────────────────────────────
async function loadConfig() {
const r = await api("/api/config/show")
$("#config-raw").textContent = (r.raw || "(no output)").trim()
$("#config-path-row").innerHTML =
`<span class="kv-k">FILE</span><span class="kv-v" style="max-width:none">${escapeHtml(r.path)}</span>`
}
async function runConfigCheck() {
$("#config-raw").textContent = "▷ checking…"
const r = await api("/api/config/check")
$("#config-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`config check → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
async function runConfigMigrate() {
if (!confirm("Run config migrate? Writes changes to config.yaml.")) return
$("#config-raw").textContent = "▷ migrating…"
const r = await api("/api/config/migrate", { method: "POST" })
$("#config-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`config migrate → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadConfig()
}
async function configSet() {
const key = $("#config-key").value.trim()
const value = $("#config-value").value
if (!key) { setMsg("key required", "err"); return }
const log = $("#config-log")
log.hidden = false; log.textContent = `▷ set ${key} = ${value}…\n`
const r = await api("/api/config/set", { method: "POST", body: JSON.stringify({ key, value }) })
log.textContent += r.output || ""
setMsg(`config set → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#config-key").value = $("#config-value").value = ""; loadConfig() }
}
// ── §15 Security ───────────────────────────────────────────────────────────
async function runSecurity() {
$("#security-raw").textContent = "▷ scanning OSV.dev (this can take a minute)…"
setBusy(true)
const r = await api("/api/security")
setBusy(false)
$("#security-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`security audit → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §16 Storage ────────────────────────────────────────────────────────────
async function loadStorage() {
const r = await api("/api/checkpoints")
$("#checkpoints-raw").textContent = (r.raw || "(no output)").trim()
}
async function runBackup(quick) {
const label = $("#backup-label").value.trim()
const log = $("#backup-log")
log.hidden = false; log.textContent = `▷ ${quick ? "quick" : "full"} backup…\n`
setBusy(true)
const r = await api("/api/backup", { method: "POST", body: JSON.stringify({ quick, label }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`backup → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
async function checkpointsAction(action) {
if (action === "clear" && !confirm("Clear ALL checkpoints? This wipes rollback history.")) return
$("#checkpoints-raw").textContent = `▷ ${action}…`
const r = await api("/api/checkpoints/action", { method: "POST", body: JSON.stringify({ action }) })
$("#checkpoints-raw").textContent = (r.output || "(no output)").trim()
setMsg(`checkpoints ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadStorage()
}
// ── §17 Insights ───────────────────────────────────────────────────────────
async function runInsights() {
const days = Number($("#insights-days").value || 30)
$("#insights-raw").textContent = `▷ analyzing past ${days} days…`
const r = await api(`/api/insights?days=${days}`)
$("#insights-raw").textContent = (r.raw || "(no output)").trim()
}
// ── §18 System extras ──────────────────────────────────────────────────────
async function runPromptSize() {
$("#system-raw").textContent = "▷ measuring prompt size (cli)…"
const r = await api("/api/prompt-size?platform=cli")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runDump() {
$("#system-raw").textContent = "▷ generating setup dump…"
const r = await api("/api/dump")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runPortalInfo() {
$("#system-raw").textContent = "▷ querying Nous Portal…"
const r = await api("/api/portal")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runUpdate() {
if (!confirm("Run hermes update? This will modify the install.")) return
$("#system-raw").textContent = "▷ updating (this may take a few minutes)…"
setBusy(true)
const r = await api("/api/update", { method: "POST" })
setBusy(false)
$("#system-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`update → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── API Users ──────────────────────────────────────────────────────────────
async function apiUserFetch(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts
})
if (res.status === 401) {
window.location.href = "/login"
return null
}
const ct = res.headers.get("content-type") || ""
return ct.includes("json") ? res.json() : res.text()
}
function fmtTokens(n) {
if (!n) return "—"
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1) + "M tokens/mo"
if (n >= 1_000) return (n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1) + "K tokens/mo"
return n + " tokens/mo"
}
function fmtDate(val) {
if (!val) return "—"
const d = new Date(val)
if (isNaN(d)) return "—"
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })
}
function renderApiUsersRows(users) {
const tbody = $("#api-users-tbody")
if (!tbody) return
if (!users || users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">No API users found.</td></tr>'
return
}
tbody.innerHTML = users.map((u) => {
const statusClass = u.status === "active" ? "badge-active" : u.status === "revoked" ? "badge-revoked" : "badge-deleted"
const maskedKey = u.key_suffix ? `hms_····${escapeHtml(u.key_suffix)}` : "hms_············"
const preBadge = u.access_pre ? '<span class="badge badge-pre">pre</span>' : ""
const postBadge = u.access_post ? '<span class="badge badge-post">post</span>' : ""
const rate = u.requests_per_minute ? `${u.requests_per_minute} req/min` : "—"
const monthly = fmtTokens(u.monthly_token_limit)
const lastUsed = fmtDate(u.last_used_at)
const expires = fmtDate(u.expires_at)
const revokeOrReactivate = u.status === "active"
? `<button class="btn-mini" data-api-user-action="revoke" data-id="${escapeHtml(u.id)}">Revoke</button>`
: `<button class="btn-mini" data-api-user-action="reactivate" data-id="${escapeHtml(u.id)}">Reactivate</button>`
return `<tr>
<td><span class="api-user-name">${escapeHtml(u.display_name || u.id)}</span><br><span class="badge ${statusClass}">${escapeHtml(u.status || "active")}</span></td>
<td><code class="key-mask">${maskedKey}</code></td>
<td>${preBadge}${postBadge}</td>
<td>${escapeHtml(rate)}<br><span class="cell-sub">${escapeHtml(monthly)}</span></td>
<td>${escapeHtml(lastUsed)}</td>
<td>${escapeHtml(expires)}</td>
<td class="action-cell">
<button class="btn-mini" data-api-user-action="edit" data-id="${escapeHtml(u.id)}">Edit</button>
<button class="btn-mini" data-api-user-action="rotate" data-id="${escapeHtml(u.id)}">Rotate Key</button>
<button class="btn-mini" data-api-user-action="logs" data-id="${escapeHtml(u.id)}">Logs</button>
${revokeOrReactivate}
<button class="btn-mini btn-danger" data-api-user-action="delete" data-id="${escapeHtml(u.id)}">Delete</button>
</td>
</tr>`
}).join("")
}
async function loadApiUsers() {
const data = await apiUserFetch("/api/admin/api-users")
if (!data) return
renderApiUsersRows(Array.isArray(data) ? data : (data.users || []))
}
async function createApiUser(data) {
return apiUserFetch("/api/admin/api-users", {
method: "POST",
body: JSON.stringify(data)
})
}
async function updateApiUser(id, patch) {
return apiUserFetch(`/api/admin/api-users/${id}`, {
method: "PATCH",
body: JSON.stringify(patch)
})
}
async function rotateApiUserKey(id) {
return apiUserFetch(`/api/admin/api-users/${id}/rotate`, { method: "POST" })
}
async function revokeApiUser(id) {
return apiUserFetch(`/api/admin/api-users/${id}/revoke`, { method: "POST" })
}
async function reactivateApiUser(id) {
return apiUserFetch(`/api/admin/api-users/${id}/reactivate`, { method: "POST" })
}
async function deleteApiUser(id) {
return apiUserFetch(`/api/admin/api-users/${id}`, { method: "DELETE" })
}
function downloadApiUserLogs(id) {
const a = document.createElement("a")
a.href = `/api/admin/logs/download?api_user_id=${encodeURIComponent(id)}`
a.download = ""
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function openApiUserDialog(mode, existing) {
const dialog = $("#api-user-dialog")
const errorEl = $("#api-user-dialog-error")
errorEl.textContent = ""
$("#api-user-dialog-title").textContent = mode === "edit" ? "Edit API User" : "Create API User"
// DB rows use allow_pre/allow_post; single-user fetch uses same column names
const pre = existing ? (existing.allow_pre ?? false) : true
const post = existing ? (existing.allow_post ?? false) : true
$("#api-user-name").value = existing ? (existing.display_name || "") : ""
$("#api-user-pre").checked = pre
$("#api-user-post").checked = post
$("#api-user-rpm").value = existing ? (existing.requests_per_minute || 60) : 60
$("#api-user-monthly").value = existing ? (existing.monthly_token_limit || 1000000) : 1000000
if (existing && existing.expires_at) {
const d = new Date(existing.expires_at)
$("#api-user-expires").value = isNaN(d) ? "" : d.toISOString().slice(0, 16)
} else {
$("#api-user-expires").value = ""
}
dialog.dataset.editId = existing ? existing.id : ""
dialog.showModal()
}
function openRevealDialog(key) {
const dialog = $("#api-key-reveal-dialog")
$("#reveal-key-text").textContent = key
dialog.showModal()
}
document.addEventListener("click", async (e) => {
if (e.target.id === "new-api-user-btn") {
openApiUserDialog("create", null)
return
}
if (e.target.id === "api-user-cancel-btn") {
$("#api-user-dialog").close()
return
}
if (e.target.id === "reveal-done-btn") {
$("#api-key-reveal-dialog").close()
return
}
if (e.target.id === "reveal-copy-btn") {
const key = $("#reveal-key-text").textContent
if (navigator.clipboard) {
await navigator.clipboard.writeText(key)
setMsg("API key copied to clipboard", "ok")
}
return
}
// Expiration preset buttons inside the dialog
const preset = e.target.dataset.expiresPreset
if (preset !== undefined) {
const input = $("#api-user-expires")
if (preset === "never") {
input.value = ""
} else {
const d = new Date()
if (preset === "30d") d.setDate(d.getDate() + 30)
else if (preset === "90d") d.setDate(d.getDate() + 90)
else if (preset === "1y") d.setFullYear(d.getFullYear() + 1)
input.value = d.toISOString().slice(0, 16)
}
return
}
const action = e.target.dataset.apiUserAction
const id = e.target.dataset.id
if (!action || !id) return
if (action === "edit") {
setBusy(true)
const data = await apiUserFetch(`/api/admin/api-users/${id}`)
setBusy(false)
if (!data || data.error) {
setMsg(data?.error || "Failed to load user", "err")
return
}
openApiUserDialog("edit", data)
return
}
if (action === "rotate") {
if (!confirm("Rotate API key? The current key will be invalidated immediately.")) return
setBusy(true)
const res = await rotateApiUserKey(id)
setBusy(false)
if (!res) return
if (res.error) { setMsg(res.error, "err"); return }
if (res.plaintextKey) openRevealDialog(res.plaintextKey)
setMsg("Key rotated", "ok")
loadApiUsers()
return
}
if (action === "logs") {
downloadApiUserLogs(id)
return
}
if (action === "revoke") {
if (!confirm("Revoke this API user? They will lose access immediately.")) return
setBusy(true)
const res = await revokeApiUser(id)
setBusy(false)
if (res?.error) { setMsg(res.error, "err"); return }
setMsg("User revoked", "ok")
loadApiUsers()
return
}
if (action === "reactivate") {
setBusy(true)
const res = await reactivateApiUser(id)
setBusy(false)
if (res?.error) { setMsg(res.error, "err"); return }
setMsg("User reactivated", "ok")
loadApiUsers()
return
}
if (action === "delete") {
if (!confirm("Permanently delete this API user? This cannot be undone.")) return
setBusy(true)
const res = await deleteApiUser(id)
setBusy(false)
if (res?.error) { setMsg(res.error, "err"); return }
setMsg("User deleted", "ok")
loadApiUsers()
return
}
}, true)
document.addEventListener("submit", async (e) => {
if (e.target.id !== "api-user-form") return
e.preventDefault()
const dialog = $("#api-user-dialog")
const errorEl = $("#api-user-dialog-error")
const editId = dialog.dataset.editId
const rpmRaw = $("#api-user-rpm").value
const monthlyRaw = $("#api-user-monthly").value
// Validate required numeric fields before hitting the server
const rpm = rpmRaw ? Number(rpmRaw) : null
const monthly = monthlyRaw ? Number(monthlyRaw) : null
if (!rpm || rpm <= 0) {
errorEl.textContent = "Requests per minute is required and must be a positive number."
return
}
if (!monthly || monthly <= 0) {
errorEl.textContent = "Monthly token limit is required and must be a positive number."
return
}
errorEl.textContent = ""
const payload = {
displayName: $("#api-user-name").value.trim(),
allowPre: $("#api-user-pre").checked,
allowPost: $("#api-user-post").checked,
requestsPerMinute: rpm,
monthlyTokenLimit: monthly,
expiresAt: $("#api-user-expires").value ? new Date($("#api-user-expires").value).toISOString() : null,
}
setBusy(true)
let res
if (editId) {
res = await updateApiUser(editId, payload)
} else {
res = await createApiUser(payload)
}
setBusy(false)
if (!res) return
if (res.error) {
errorEl.textContent = res.error
return
}
dialog.close()
if (!editId && res.plaintextKey) openRevealDialog(res.plaintextKey)
setMsg(editId ? "API user updated" : "API user created", "ok")
loadApiUsers()
}, true)
// ── Boot ───────────────────────────────────────────────────────────────────
bootstrap()