@mrrlin-dev/external-agents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui.js ADDED
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ import http from "node:http";
3
+ import url from "node:url";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { loadRegistry } from "./lib/registry.js";
8
+ import { readState, writeState, probeInstalled } from "./lib/state.js";
9
+
10
+ // UI-set persisted keys β€” MUST use the same file that server.js reads at
11
+ // startup, otherwise the operator's saved key is invisible on next boot.
12
+ const KEYS_FILE = path.join(os.homedir(), ".local/state/external-agents/keys.env");
13
+ function loadKeysFile() {
14
+ try {
15
+ if (!fs.existsSync(KEYS_FILE)) return {};
16
+ const out = {};
17
+ for (const line of fs.readFileSync(KEYS_FILE, "utf-8").split(/\r?\n/)) {
18
+ const t = line.trim();
19
+ if (!t || t.startsWith("#")) continue;
20
+ const eq = t.indexOf("=");
21
+ if (eq > 0) out[t.slice(0, eq).trim()] = t.slice(eq + 1);
22
+ }
23
+ return out;
24
+ } catch { return {}; }
25
+ }
26
+ function saveKeysFile(kv) {
27
+ const dir = path.dirname(KEYS_FILE);
28
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ const body = Object.entries(kv)
30
+ .filter(([k, v]) => k && typeof v === "string")
31
+ .map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
32
+ const tmp = KEYS_FILE + ".tmp." + process.pid + "." + Date.now();
33
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
34
+ fs.renameSync(tmp, KEYS_FILE);
35
+ }
36
+
37
+ const REGISTRY = loadRegistry("./agents.yaml");
38
+ const HOST = "127.0.0.1";
39
+ const PORT = 4711;
40
+
41
+ function stateRows() {
42
+ const state = readState();
43
+ return REGISTRY.agents.map((entry) => ({
44
+ ...entry,
45
+ ...(state[entry.id] || { state: "healthy" }),
46
+ }));
47
+ }
48
+
49
+ function findAgent(id) {
50
+ return REGISTRY.agents.find((a) => a.id === id);
51
+ }
52
+
53
+ const PAGE = `<!doctype html>
54
+ <html>
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <title>external-agents</title>
58
+ <style>
59
+ body { font: 14px -apple-system, system-ui, sans-serif; margin: 20px; color: #222; background: #fafafa; }
60
+ h1 { margin: 0 0 6px 0; }
61
+ p.hint { color: #666; margin: 0 0 16px 0; }
62
+ button { font: inherit; padding: 6px 12px; border: 1px solid #ccc; background: #fff; border-radius: 4px; cursor: pointer; }
63
+ button:hover { background: #f0f0f0; }
64
+ button.primary { background: #4a90e2; color: #fff; border-color: #3878c0; }
65
+ button.primary:hover { background: #3878c0; }
66
+ table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
67
+ th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
68
+ th { background: #f4f4f4; font-weight: 600; }
69
+ td.state { font-weight: 600; }
70
+ tr.healthy td.state { background: #dcf5e0; color: #1a6b31; }
71
+ tr.quota_exhausted td.state { background: #fff2cc; color: #7a5300; }
72
+ tr.needs_auth td.state, tr.not_installed td.state { background: #fbdad3; color: #9a2b1c; }
73
+ tr.errored_transient td.state { background: #e6e6e6; color: #555; }
74
+ td.note { color: #666; font-size: 12px; }
75
+ td.time { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #666; font-size: 12px; }
76
+ .row-controls { display: flex; gap: 16px; align-items: center; margin-bottom: 12px; }
77
+ .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #e0e0e0; font-size: 11px; color: #444; margin-right: 4px; }
78
+ .badge.free { background: #d4f7d4; color: #216d2c; font-weight: 600; }
79
+ .badge.free::before { content: "$0 "; opacity: 0.8; }
80
+
81
+ .unlock {
82
+ background: linear-gradient(135deg, #fff8dc 0%, #fdf2c4 100%);
83
+ border: 1px solid #d4b942;
84
+ border-radius: 6px;
85
+ padding: 16px 18px;
86
+ margin-bottom: 20px;
87
+ box-shadow: 0 2px 8px rgba(212, 185, 66, 0.15);
88
+ }
89
+ .unlock h2 { margin: 0 0 6px 0; font-size: 16px; color: #6b5a12; }
90
+ .unlock h2::before { content: "πŸ’° "; }
91
+ .unlock p.tag { margin: 0 0 12px 0; color: #7a682a; font-size: 13px; }
92
+ .unlock .row {
93
+ display: grid;
94
+ grid-template-columns: minmax(140px, 180px) 1fr auto minmax(220px, 260px) auto;
95
+ gap: 10px;
96
+ align-items: center;
97
+ padding: 10px 0;
98
+ border-top: 1px solid #e5cd6c;
99
+ }
100
+ .unlock input.keyinput {
101
+ padding: 5px 8px;
102
+ border: 1px solid #d4b942;
103
+ border-radius: 3px;
104
+ font: inherit;
105
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
106
+ font-size: 12px;
107
+ background: #fff;
108
+ }
109
+ .unlock button.save {
110
+ padding: 5px 12px;
111
+ background: #216d2c;
112
+ color: #fff;
113
+ border: 1px solid #1a5623;
114
+ border-radius: 3px;
115
+ cursor: pointer;
116
+ font: inherit;
117
+ font-size: 13px;
118
+ font-weight: 600;
119
+ }
120
+ .unlock button.save:hover { background: #1a5623; }
121
+ .unlock button.save:disabled { background: #999; cursor: default; }
122
+ .unlock .status {
123
+ grid-column: 1 / -1;
124
+ color: #216d2c;
125
+ font-size: 12px;
126
+ padding-top: 4px;
127
+ min-height: 16px;
128
+ }
129
+ .unlock .status.err { color: #a33; }
130
+ .unlock .row:first-of-type { border-top: none; padding-top: 4px; }
131
+ .unlock .row .prov { font-weight: 600; color: #4a3d0e; }
132
+ .unlock .row .desc { color: #6b5a12; font-size: 13px; }
133
+ .unlock .row .env {
134
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
135
+ font-size: 12px;
136
+ background: rgba(255,255,255,.6);
137
+ padding: 3px 6px;
138
+ border-radius: 3px;
139
+ color: #4a3d0e;
140
+ }
141
+ .unlock .row a.signup {
142
+ color: #216d2c;
143
+ background: #d4f7d4;
144
+ padding: 5px 11px;
145
+ border-radius: 4px;
146
+ text-decoration: none;
147
+ font-size: 13px;
148
+ font-weight: 600;
149
+ white-space: nowrap;
150
+ }
151
+ .unlock .row a.signup:hover { background: #b8ebba; }
152
+ .unlock .footer { margin-top: 10px; color: #7a682a; font-size: 12px; font-style: italic; }
153
+ </style>
154
+ </head>
155
+ <body>
156
+ <h1>External agents</h1>
157
+ <p class="hint">Loopback dashboard for <code>external-agents-spike</code>. Refresh to update state; click <em>Verify</em> per row to run an install-check.</p>
158
+
159
+ <div id="unlock" class="unlock" style="display:none"></div>
160
+ <div class="row-controls">
161
+ <button class="primary" onclick="refresh()">Refresh</button>
162
+ <span id="stamp" style="color:#666;font-size:12px;"></span>
163
+ </div>
164
+ <table>
165
+ <thead>
166
+ <tr>
167
+ <th>ID</th><th>Provider</th><th>Model</th><th>Tier</th><th>Tags</th>
168
+ <th>State</th><th>Note</th><th>Last used</th><th>Usage</th><th>Actions</th>
169
+ </tr>
170
+ </thead>
171
+ <tbody id="rows"></tbody>
172
+ </table>
173
+
174
+ <div style="margin-top: 32px; padding: 16px; background: #fff; border: 1px dashed #ccc; border-radius: 4px;">
175
+ <h3 style="margin: 0 0 4px 0; font-size: 15px;">Missing your model?</h3>
176
+ <p style="margin: 0 0 12px 0; color: #666; font-size: 13px;">Suggest a new model or provider β€” we&rsquo;ll pick it up.</p>
177
+ <div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
178
+ <input id="suggest-name" placeholder="Model or provider name (e.g. anthropic/haiku-4-5)" style="flex: 1; min-width: 260px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font: inherit;">
179
+ <input id="suggest-url" placeholder="Docs / setup URL (optional)" style="flex: 1; min-width: 260px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font: inherit;">
180
+ <button class="primary" onclick="submitSuggest()">Suggest</button>
181
+ </div>
182
+ <p id="suggest-result" style="margin: 8px 0 0 0; color: #4a8; font-size: 13px; min-height: 18px;"></p>
183
+ </div>
184
+ <script>
185
+ function fmtTime(ts) {
186
+ if (!ts) return "β€”";
187
+ const d = new Date(ts * 1000);
188
+ return d.toLocaleTimeString() + " Β· " + d.toLocaleDateString();
189
+ }
190
+ function renderRows(agents) {
191
+ const tbody = document.getElementById("rows");
192
+ tbody.innerHTML = "";
193
+ for (const a of agents) {
194
+ const tr = document.createElement("tr");
195
+ tr.className = a.state || "healthy";
196
+ const tags = (a.tags || []).map(t => '<span class="badge '+(t==='free'?'free':'')+'">'+t+'</span>').join("");
197
+ const usage = a.usage_url
198
+ ? '<a href="'+a.usage_url+'" target="_blank" rel="noopener" title="'+a.usage_url+'">β†— usage</a>'
199
+ : 'β€”';
200
+ tr.innerHTML =
201
+ "<td>"+a.id+"</td>" +
202
+ "<td>"+(a.provider||"β€”")+"</td>" +
203
+ "<td>"+(a.model||"β€”")+"</td>" +
204
+ "<td>"+(a.tier||"β€”")+"</td>" +
205
+ "<td>"+tags+"</td>" +
206
+ '<td class="state">'+(a.state||"healthy")+"</td>" +
207
+ '<td class="note">'+(a.note||"β€”")+"</td>" +
208
+ '<td class="time">'+fmtTime(a.last_used_at)+"</td>" +
209
+ '<td>'+usage+'</td>' +
210
+ '<td><button onclick="verify(\\''+a.id+'\\')">Verify</button></td>';
211
+ tbody.appendChild(tr);
212
+ }
213
+ document.getElementById("stamp").textContent = "Loaded " + new Date().toLocaleTimeString();
214
+ }
215
+ async function submitSuggest() {
216
+ const name = document.getElementById("suggest-name").value.trim();
217
+ const url = document.getElementById("suggest-url").value.trim();
218
+ const out = document.getElementById("suggest-result");
219
+ if (!name) { out.textContent = "please enter a model or provider name"; out.style.color = "#a33"; return; }
220
+ out.textContent = "sending...";
221
+ out.style.color = "#666";
222
+ try {
223
+ const r = await fetch("/api/suggest", {
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/json" },
226
+ body: JSON.stringify({ name, url })
227
+ });
228
+ const j = await r.json();
229
+ if (r.ok) {
230
+ out.textContent = "thanks β€” recorded (id " + (j.id || "?") + "). We’ll pick it up.";
231
+ out.style.color = "#4a8";
232
+ document.getElementById("suggest-name").value = "";
233
+ document.getElementById("suggest-url").value = "";
234
+ } else {
235
+ out.textContent = "error: " + (j.error || r.statusText);
236
+ out.style.color = "#a33";
237
+ }
238
+ } catch (e) {
239
+ out.textContent = "network error: " + e.message;
240
+ out.style.color = "#a33";
241
+ }
242
+ }
243
+ // Per-provider signup metadata for the unlock banner. Only providers whose
244
+ // entries carry the "free" tag AND may show up in needs_auth appear here.
245
+ const PROVIDER_META = {
246
+ groq: {
247
+ label: "Groq",
248
+ pitch: "Fastest inference on the market β€” ~500-800 tok/s",
249
+ signup: "https://console.groq.com/keys",
250
+ env: "GROQ_API_KEY",
251
+ },
252
+ openrouter: {
253
+ label: "OpenRouter",
254
+ pitch: "One key, 50+ free models (DeepSeek R1, Qwen-Coder, Llama, …)",
255
+ signup: "https://openrouter.ai/settings/keys",
256
+ env: "OPENROUTER_API_KEY",
257
+ },
258
+ cerebras: {
259
+ label: "Cerebras",
260
+ pitch: "~2000 tok/s β€” fastest on the planet, 30 rpm free",
261
+ signup: "https://cloud.cerebras.ai/platform/keys",
262
+ env: "CEREBRAS_API_KEY",
263
+ },
264
+ google: {
265
+ label: "Google AI Studio",
266
+ pitch: "7 Gemini variants, each with its own free quota bucket",
267
+ signup: "https://aistudio.google.com/apikey",
268
+ env: "GEMINI_API_KEY",
269
+ },
270
+ zai: {
271
+ label: "Z.ai (GLM)",
272
+ pitch: "Free tier for GLM-4.7-flash β€” a solid Chinese frontier model",
273
+ signup: "https://z.ai/manage-apikey/apikey-list",
274
+ env: "ZAI_API_KEY",
275
+ },
276
+ "ollama-cloud": {
277
+ label: "Ollama Cloud",
278
+ pitch: "gpt-oss 20B/120B via your Ollama account",
279
+ signup: "https://ollama.com/download",
280
+ env: "(configured via the ollama CLI)",
281
+ },
282
+ };
283
+ function renderUnlock(agents) {
284
+ const box = document.getElementById("unlock");
285
+ // Find free-tagged entries currently in needs_auth
286
+ const missing = agents.filter(a =>
287
+ (a.tags || []).includes("free") && a.state === "needs_auth"
288
+ );
289
+ // Group by provider (unique)
290
+ const providers = [...new Set(missing.map(a => a.provider))];
291
+ if (providers.length === 0) { box.style.display = "none"; return; }
292
+ const rows = providers.map(p => {
293
+ const m = PROVIDER_META[p] || { label: p, pitch: "", signup: "#", env: "?" };
294
+ const count = missing.filter(a => a.provider === p).length;
295
+ return '<div class="row">' +
296
+ '<div><div class="prov">' + m.label + '</div>' +
297
+ '<div style="font-size:11px;color:#8a7532">+' + count + ' model' + (count>1?"s":"") + ' waiting</div></div>' +
298
+ '<div class="desc">' + m.pitch + '</div>' +
299
+ '<div class="env">' + m.env + '</div>' +
300
+ '<a class="signup" href="' + m.signup + '" target="_blank" rel="noopener">Get free key β†—</a>' +
301
+ '</div>';
302
+ }).join("");
303
+ box.innerHTML =
304
+ '<h2>Unlock ' + missing.length + ' more free-tier voice' + (missing.length>1?"s":"") + '</h2>' +
305
+ '<p class="tag">These providers all offer generous free tiers β€” sign up (60 s, usually no card), set the env var, restart your MCP client. Your dispatch pool gets bigger, round-robin gets deeper, and your bill stays at $0.</p>' +
306
+ rows +
307
+ '<p class="footer">Providers with a paid-per-token model (DeepSeek direct API) are excluded here β€” they don’t have a free tier.</p>';
308
+ box.style.display = "block";
309
+ }
310
+ async function saveKey(envName) {
311
+ const inp = document.getElementById("k-" + envName);
312
+ const stat = document.getElementById("s-" + envName);
313
+ const val = (inp.value || "").trim();
314
+ if (!val) { stat.textContent = "empty value"; stat.className = "status err"; return; }
315
+ stat.textContent = "saving..."; stat.className = "status";
316
+ try {
317
+ const r = await fetch("/api/set_credential", {
318
+ method: "POST",
319
+ headers: { "Content-Type": "application/json" },
320
+ body: JSON.stringify({ env_name: envName, value: val })
321
+ });
322
+ const j = await r.json();
323
+ if (r.ok) {
324
+ stat.textContent = "βœ“ persisted to " + j.persisted_to + ". Restart your MCP client to pick it up.";
325
+ stat.className = "status";
326
+ inp.value = "";
327
+ } else {
328
+ stat.textContent = "error: " + (j.error || r.statusText);
329
+ stat.className = "status err";
330
+ }
331
+ } catch (e) {
332
+ stat.textContent = "network error: " + e.message;
333
+ stat.className = "status err";
334
+ }
335
+ }
336
+ async function refresh() {
337
+ const r = await fetch("/api/state").then(r => r.json());
338
+ renderUnlock(r.agents);
339
+ renderRows(r.agents);
340
+ }
341
+ async function verify(id) {
342
+ await fetch("/api/probe?id="+encodeURIComponent(id), { method: "POST" });
343
+ await refresh();
344
+ }
345
+ refresh();
346
+ </script>
347
+ </body>
348
+ </html>`;
349
+
350
+ function json(res, status, body) {
351
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
352
+ res.end(JSON.stringify(body));
353
+ }
354
+
355
+ const server = http.createServer(async (req, res) => {
356
+ const parsed = url.parse(req.url, true);
357
+ const p = parsed.pathname;
358
+
359
+ if (req.method === "GET" && p === "/") {
360
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
361
+ return res.end(PAGE);
362
+ }
363
+
364
+ if (req.method === "GET" && p === "/api/state") {
365
+ return json(res, 200, {
366
+ schema_version: REGISTRY.schema_version,
367
+ agents: stateRows(),
368
+ });
369
+ }
370
+
371
+ if (req.method === "POST" && p === "/api/set_credential") {
372
+ let body = "";
373
+ req.on("data", (c) => { body += c.toString(); });
374
+ req.on("end", () => {
375
+ try {
376
+ const { env_name, value } = JSON.parse(body || "{}");
377
+ if (!env_name || typeof env_name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(env_name)) {
378
+ return json(res, 400, { error: "invalid env_name" });
379
+ }
380
+ if (!value || typeof value !== "string") return json(res, 400, { error: "missing value" });
381
+ const persisted = loadKeysFile();
382
+ persisted[env_name] = value;
383
+ saveKeysFile(persisted);
384
+ // The RUNNING UI process's env is updated too, though the MCP server
385
+ // is a separate node process β€” the operator's next MCP call needs a
386
+ // restart to see it. We tell them so in the response.
387
+ process.env[env_name] = value;
388
+ console.error(`external-agents ui: credential persisted for ${env_name} (${value.length} chars)`);
389
+ return json(res, 200, {
390
+ ok: true,
391
+ env_name,
392
+ persisted_to: KEYS_FILE,
393
+ restart_required: "Restart your MCP client (Claude Code / Codex) to pick up the new key.",
394
+ });
395
+ } catch (e) {
396
+ return json(res, 400, { error: "invalid json: " + e.message });
397
+ }
398
+ });
399
+ return;
400
+ }
401
+
402
+ if (req.method === "POST" && p === "/api/suggest") {
403
+ let body = "";
404
+ req.on("data", (c) => { body += c.toString(); });
405
+ req.on("end", () => {
406
+ try {
407
+ const { name, url: docUrl } = JSON.parse(body || "{}");
408
+ if (!name || typeof name !== "string") {
409
+ return json(res, 400, { error: "missing 'name'" });
410
+ }
411
+ // In the standalone spike, we persist locally + log to stderr so operators
412
+ // can see the request. Mrrlin consumers wire this endpoint into the
413
+ // report-issue mechanism (see ADR 0021 Β§ Consumer-side UX affordances).
414
+ const id = "sug-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6);
415
+ const entry = { id, ts: new Date().toISOString(), name: name.trim(), url: (docUrl || "").trim() };
416
+ try {
417
+ const dir = path.join(os.homedir(), ".local/state/external-agents");
418
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
419
+ fs.appendFileSync(path.join(dir, "suggestions.jsonl"), JSON.stringify(entry) + "\n", { mode: 0o600 });
420
+ } catch (e) {
421
+ console.error(`external-agents ui: WARN β€” could not persist suggestion: ${e.message}`);
422
+ }
423
+ console.error(`external-agents ui: suggestion recorded β†’ id=${id} name="${entry.name}"${entry.url ? " url=" + entry.url : ""}`);
424
+ return json(res, 200, { ok: true, id });
425
+ } catch (e) {
426
+ return json(res, 400, { error: "invalid json: " + e.message });
427
+ }
428
+ });
429
+ return;
430
+ }
431
+
432
+ if ((req.method === "GET" || req.method === "POST") && p === "/api/probe") {
433
+ const id = parsed.query.id;
434
+ if (!id || typeof id !== "string") return json(res, 400, { error: "missing id" });
435
+ const entry = findAgent(id);
436
+ if (!entry) return json(res, 404, { error: `unknown agent: ${id}` });
437
+ const result = probeInstalled(entry);
438
+ const checked = Math.floor(Date.now() / 1000);
439
+ writeState({ [id]: { ...result, checked } });
440
+ return json(res, 200, { id, ...result, checked });
441
+ }
442
+
443
+ res.writeHead(404, { "Content-Type": "text/plain" });
444
+ res.end("not found");
445
+ });
446
+
447
+ server.listen(PORT, HOST, () => {
448
+ console.error(`external-agents ui: http://${HOST}:${PORT}`);
449
+ });