From 288b21cec98421fda57028a0c8c9d835cfbb14b0 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 22 May 2026 17:07:31 +0900 Subject: feat: add/remove keys from UI, backend URL setting, user service, Docker fix - Add POST /models/add-key and POST /models/remove-key API endpoints - Add 'Add New Key' modal (page-level) with provider selection - Add remove button per key in Model Status view - Add configurable backend URL setting in Settings panel with localStorage persistence - Convert systemd service from system to user service (systemctl --user) - Fix Docker entrypoint to chown all nested node_modules dirs - Update dispatch.toml credential paths to use -pro/-max naming - Make API port configurable via PORT env var (default 3000, prod 18390) --- dispatch.toml | 4 +- docker/entrypoint.dev.sh | 6 +- packages/api/src/routes/models.ts | 124 +++++++++++++++++++++ packages/frontend/src/App.svelte | 80 +++++++++++++ .../frontend/src/lib/components/ModelStatus.svelte | 49 +++++++- .../src/lib/components/SettingsPanel.svelte | 46 ++++++++ .../src/lib/components/SidebarPanel.svelte | 4 +- packages/frontend/src/lib/config.ts | 36 +++++- packaging/PKGBUILD | 8 +- packaging/dispatch-api.service | 20 +--- packaging/dispatch.install | 4 +- 11 files changed, 342 insertions(+), 39 deletions(-) diff --git a/dispatch.toml b/dispatch.toml index 4332462..72ff9bb 100644 --- a/dispatch.toml +++ b/dispatch.toml @@ -8,13 +8,13 @@ id = "claude-pro" provider = "anthropic" base_url = "https://api.anthropic.com/v1" -credentials_file = "/home/tradam/.claude/.credentials-1.json" +credentials_file = "/home/tradam/.claude/.credentials-pro.json" [[keys]] id = "claude-max" provider = "anthropic" base_url = "https://api.anthropic.com/v1" -credentials_file = "/home/tradam/.claude/.credentials-2.json" +credentials_file = "/home/tradam/.claude/.credentials-max.json" [[keys]] id = "opencode-1" diff --git a/docker/entrypoint.dev.sh b/docker/entrypoint.dev.sh index bbde09a..dd0d423 100644 --- a/docker/entrypoint.dev.sh +++ b/docker/entrypoint.dev.sh @@ -35,10 +35,8 @@ if [ -d "$USER_HOME/.claude" ]; then chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.claude" 2>/dev/null || true fi -# Ensure node_modules is writable (created as root during build) -if [ -d /app/node_modules ]; then - chown -R "$HOST_UID:$HOST_GID" /app/node_modules -fi +# Ensure all node_modules are writable (created as root during build) +find /app -name node_modules -type d -maxdepth 3 -exec chown -R "$HOST_UID:$HOST_GID" {} + 2>/dev/null || true # Install/update dependencies as the target user (skip with SKIP_INSTALL=1) if [ "${SKIP_INSTALL:-}" != "1" ]; then diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 1daf37e..d6b82c2 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,3 +1,5 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import type { ModelRegistry } from "@dispatch/core"; import { ANTHROPIC_MODELS_FALLBACK, @@ -396,6 +398,128 @@ modelsRoutes.get("/credentials-status", (c) => { return c.json({ credentials: status }); }); +// ─── Add key to dispatch.toml ───────────────────────────────── + +const VALID_PROVIDERS = ["anthropic", "opencode-go", "github-copilot"] as const; +type SupportedProvider = (typeof VALID_PROVIDERS)[number]; + +const PROVIDER_BASE_URLS: Record = { + anthropic: "https://api.anthropic.com/v1", + "opencode-go": "https://opencode.ai/zen/go/v1", + "github-copilot": "https://api.githubcopilot.com", +}; + +modelsRoutes.post("/add-key", async (c) => { + const body = await c.req.json<{ id?: unknown; provider?: unknown }>(); + + // Validate id + if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) { + return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400); + } + const id = body.id.trim(); + + // Validate provider + if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) { + return c.json( + { error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, + 400, + ); + } + const provider = body.provider as SupportedProvider; + const base_url = PROVIDER_BASE_URLS[provider]; + + // Read current dispatch.toml + const tomlPath = `${process.cwd()}/dispatch.toml`; + let tomlContent: string; + try { + tomlContent = readFileSync(tomlPath, "utf-8"); + } catch (err) { + return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); + } + + // Check for duplicate key id + const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m"); + if (idPattern.test(tomlContent)) { + return c.json({ error: `key with id "${id}" already exists` }, 409); + } + + // Build the new [[keys]] block + let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`; + if (provider === "anthropic") { + const credPath = `${homedir()}/.claude/.credentials-${id}.json`; + newBlock += `\ncredentials_file = "${credPath}"`; + } + newBlock += "\n"; + + // Insert before the # ─── Permissions section if it exists, otherwise at end + const permissionsMarker = /\n# [─\-]+ Permissions/; + let newContent: string; + const permMatch = permissionsMarker.exec(tomlContent); + if (permMatch) { + const insertAt = permMatch.index; + newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt); + } else { + newContent = tomlContent + newBlock; + } + + try { + writeFileSync(tomlPath, newContent, "utf-8"); + } catch (err) { + return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); + } + + const key: { id: string; provider: string; base_url: string; credentials_file?: string } = { + id, + provider, + base_url, + }; + if (provider === "anthropic") { + key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`; + } + + return c.json({ success: true, key }); +}); + +// ─── Remove key from dispatch.toml ──────────────────────────── + +modelsRoutes.post("/remove-key", async (c) => { + const body = await c.req.json<{ id?: unknown }>(); + + if (typeof body.id !== "string" || !body.id.trim()) { + return c.json({ error: "id is required" }, 400); + } + const id = body.id.trim(); + + const tomlPath = `${process.cwd()}/dispatch.toml`; + let tomlContent: string; + try { + tomlContent = readFileSync(tomlPath, "utf-8"); + } catch (err) { + return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); + } + + // Match the [[keys]] block containing this id and remove it. + // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF. + const blockPattern = new RegExp( + `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`, + "s", + ); + const match = blockPattern.exec(tomlContent); + if (!match) { + return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404); + } + + const newContent = tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length); + + try { + writeFileSync(tomlPath, newContent, "utf-8"); + } catch (err) { + return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); + } + + return c.json({ success: true }); +}); + // ─── Shared wake function ───────────────────────────────────── async function wakeAllClaudeAccounts(): Promise< diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 33415bd..d96cb63 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -22,6 +22,40 @@ let modelsData = $state<{ keys: KeyInfo[] }>({ let sidebarOpen = $state(true); +// Add Key modal state (rendered at page level to escape sidebar transform) +let showAddKeyModal = $state(false); +let addKeyProvider = $state("anthropic"); +let addKeyId = $state(""); +let addKeyError = $state(null); +let addKeySaving = $state(false); + +async function addNewKey(): Promise { + if (!addKeyId.trim()) return; + addKeySaving = true; + addKeyError = null; + try { + const res = await fetch(`${config.apiBase}/models/add-key`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: addKeyId.trim(), provider: addKeyProvider }), + }); + const data = (await res.json()) as { success?: boolean; error?: string }; + if (!res.ok || !data.success) { + addKeyError = data.error ?? "Failed to add key"; + } else { + showAddKeyModal = false; + addKeyId = ""; + addKeyProvider = "anthropic"; + await new Promise((r) => setTimeout(r, 500)); + window.location.reload(); + } + } catch (e) { + addKeyError = e instanceof Error ? e.message : "Network error"; + } finally { + addKeySaving = false; + } +} + async function fetchModels() { try { const res = await fetch(`${config.apiBase}/models`); @@ -112,6 +146,7 @@ onMount(() => { }} onAgentChange={(agent) => tabStore.setAgent(agent)} onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)} + onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }} /> @@ -144,3 +179,48 @@ onMount(() => {
+ + +{#if showAddKeyModal} + +{/if} diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte index 1270fcc..d6ff0f5 100644 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -20,10 +20,12 @@ const { keys = [], currentModel, apiBase = "", + onAddKey = () => {}, }: { keys?: KeyInfo[]; currentModel?: string; apiBase?: string; + onAddKey?: () => void; } = $props(); const activeKeys = $derived(keys.filter((k) => k.status === "active").length); @@ -44,6 +46,9 @@ let showKeyModal = $state(null); // keyId or null let keyModalValue = $state(""); let keyModalError = $state(null); let keyModalSaving = $state(false); +let removingKey = $state(null); + + async function loadCredentialStatus(): Promise { try { @@ -129,6 +134,27 @@ async function saveApiKey(): Promise { } } +async function removeKey(keyId: string): Promise { + if (!confirm(`Remove key "${keyId}" from config?`)) return; + removingKey = keyId; + try { + const res = await fetch(`${apiBase}/models/remove-key`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: keyId }), + }); + const data = (await res.json()) as { success?: boolean; error?: string }; + if (res.ok && data.success) { + await new Promise((r) => setTimeout(r, 500)); + window.location.reload(); + } + } catch { + // ignore + } finally { + removingKey = null; + } +} + $effect(() => { void loadCredentialStatus(); void loadApiKeyStatus(); @@ -209,8 +235,21 @@ function truncate(str: string | null, max: number): string { > {key.status} - {key.id} + {key.id} {key.provider} + {#if key.status === "exhausted"}
@@ -270,6 +309,13 @@ function truncate(str: string | null, max: number): string {
{/if} {/if} + {#if showKeyModal} {/if} + diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 79574d2..c19fe45 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -1,4 +1,5 @@