"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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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) => `
  • #${e.index} ${escapeHtml(e.raw.replace(/\s+←\s*$/, ""))} ${e.identity ? `` : ""}
  • `).join("") credsHtml = `` } else if (p.id === "deepseek") { credsHtml = `
    ${data.deepseekConfigured ? "── KEY SET ──" : "── NO KEY ──"}
    ` } else { credsHtml = `
    ── EMPTY POOL ──
    ` } // Actions let actionsHtml = "" if (p.oauth) { actionsHtml = ` ` } else { actionsHtml = ` Get key ↗ ` } card.innerHTML = `
    ${p.mark}
    ${p.kind}

    ${p.label}

    ${p.id}${count ? ` · ${count}` : ""}
    ${escapeHtml(authState.label)}
    ${credsHtml}
    ${actionsHtml}
    ` 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 `${clean}${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 = ` ` if (provider === "anthropic") { input.hidden = false input.innerHTML = `
    ` } else if (provider === "google-gemini-cli") { input.hidden = false input.innerHTML = `
    ` } 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) => `` ).join("") li.innerHTML = ` ${String(idx + 1).padStart(2, "0")}
    ` 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 = `
    ── no skills match ──
    `; return } grid.innerHTML = filtered.map(renderSkillCard).join("") } function renderSkillCard(s) { const tags = (s.tags || []).slice(0, 4).map((t) => `${escapeHtml(t)}`).join("") const plats = (s.platforms || []).map((p) => `${escapeHtml(p)}`).join("") return `
    ${escapeHtml(s.category || "skill")}

    ${escapeHtml(s.name)}

    v${escapeHtml(s.version || "—")} ${s.author ? `· ${escapeHtml(s.author)}` : ""} ${s.license ? `· ${escapeHtml(s.license)}` : ""}

    ${escapeHtml(s.description || "(no description)")}

    ${(tags || plats) ? `
    ${tags}${plats}
    ` : ""}
    ` } 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 = `
    searching ${escapeHtml(source)}
    ` const r = await api(`/api/skills/discover?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}`) const results = r.results || [] if (!results.length) { grid.innerHTML = `
    ── no results ──
    `; 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" ? `★ official` : `${escapeHtml(trust)}` return `
    ${escapeHtml(s.source || "hub")}

    ${escapeHtml(s.name || id)}

    ${escapeHtml(id)}

    ${escapeHtml(s.description || "")}

    ${trustChip}
    ` } // ── §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 = `
    ── no plugins match ──
    `; 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) => `${escapeHtml(h)}`).join("") const statusLabel = p.status || "unknown" const isUserGit = p.source && /^(git|user)/i.test(p.source) const actions = [] if (p.status === "enabled") actions.push(``) else actions.push(``) if (isUserGit) { actions.push(``) actions.push(``) } return `
    ${escapeHtml(statusLabel)}

    ${escapeHtml(p.name)}

    v${escapeHtml(p.version || "—")} · ${escapeHtml(p.source || "—")} ${p.author ? `· ${escapeHtml(p.author)}` : ""}

    ${escapeHtml(p.description || "")}

    ${hooks ? `
    ${hooks}
    ` : ""}
    ${actions.join("")}
    ` } async function pluginsDiscover() { const q = $("#plugins-search").value.trim() const grid = $("#plugins-discover-grid") grid.innerHTML = `
    searching GitHub…
    ` const r = await api(`/api/plugins/discover?q=${encodeURIComponent(q)}`) if (r.error && !r.results?.length) { grid.innerHTML = `
    ⚠ ${escapeHtml(r.error)}
    ` return } const items = r.results || [] if (!items.length) { grid.innerHTML = `
    ── no plugins found ──
    `; 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) => `${escapeHtml(t)}`).join("") const lang = p.language ? `${escapeHtml(p.language)}` : "" return `
    ${p.stars}

    ${escapeHtml(p.full_name)}

    ${escapeHtml(p.html_url)}

    ${escapeHtml(p.description || "(no description)")}

    ${(topics || lang) ? `
    ${topics}${lang}
    ` : ""}
    View ↗
    ` } 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 = `
    ── no custom bundles yet ──
    Create one below to wire several skills into a single /slash command.
    ` } else { userGrid.innerHTML = ub.map((b) => `
    custom

    /${escapeHtml(b.name)}

    ${escapeHtml(b._dir || b._file || "")}
    `).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 = `
    ── no matches ──
    `; return } grid.innerHTML = filtered.slice(0, 200).map((b) => `
    ${escapeHtml(b.category || "—")}

    ${escapeHtml(b.name)}

    ${escapeHtml(b.hash)}

    ${escapeHtml(b.description || "(no description on disk)")}

    `).join("") + (filtered.length > 200 ? `
    … ${filtered.length - 200} more · refine filter to see
    ` : "") } 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) => `
    ${escapeHtml(t.label)}
    ${escapeHtml(String(t.value))}
    ${t.sub ? `
    ${escapeHtml(t.sub)}
    ` : ""}
    `).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]) => `
    ${escapeHtml(k)}
    ${escapeHtml(v)}
    `).join("") const tbody = $("#curator-recent tbody") tbody.innerHTML = (r.leastRecent || []).map((s) => ` ${escapeHtml(s.name)} ${s.activity} ${s.uses} ${escapeHtml(s.lastActivity)} `).join("") || `── no recent data ──` const archived = $("#curator-archived") archived.innerHTML = (r.archived || []).length ? (r.archived.map((a) => `
  • ${escapeHtml(a)}
  • `).join("")) : `
  • ── none archived ──
  • ` } // ── MCP ──────────────────────────────────────────────────────────────────── async function loadMcp() { const r = await api("/api/mcp") const list = $("#mcp-list") list.innerHTML = "" if (!r.servers?.length) { list.innerHTML = `
  • ── no servers connected ──
  • ` return } for (const s of r.servers) { const li = document.createElement("li") li.innerHTML = ` ${escapeHtml(s.name)} · ${escapeHtml(s.detail || "")} ` 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 = `
  • ── no scheduled tasks ──
  • ` return } for (const j of r.jobs) { const li = document.createElement("li") const paused = j.status.toLowerCase().includes("paused") li.innerHTML = ` ${j.id.slice(0, 8)}   ${escapeHtml(j.schedule)}  ·  ${escapeHtml(j.prompt)}  ${escapeHtml(j.status)} ` 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 = `FILE${escapeHtml(r.path)}` } 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 = 'No API users found.' 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 ? 'pre' : "" const postBadge = u.access_post ? 'post' : "" 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" ? `` : `` return ` ${escapeHtml(u.display_name || u.id)}
    ${escapeHtml(u.status || "active")} ${maskedKey} ${preBadge}${postBadge} ${escapeHtml(rate)}
    ${escapeHtml(monthly)} ${escapeHtml(lastUsed)} ${escapeHtml(expires)} ${revokeOrReactivate} ` }).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()