@mcpbridgeapp/cli 0.0.0-stage → 0.1.1

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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +345 -2
  3. package/dist/agents/broken.d.ts +24 -0
  4. package/dist/agents/broken.js +31 -0
  5. package/dist/agents/broken.js.map +1 -0
  6. package/dist/agents/cli.d.ts +47 -0
  7. package/dist/agents/cli.js +270 -0
  8. package/dist/agents/cli.js.map +1 -0
  9. package/dist/agents/health.d.ts +38 -0
  10. package/dist/agents/health.js +53 -0
  11. package/dist/agents/health.js.map +1 -0
  12. package/dist/agents/ollama.d.ts +27 -0
  13. package/dist/agents/ollama.js +118 -0
  14. package/dist/agents/ollama.js.map +1 -0
  15. package/dist/agents/openai.d.ts +35 -0
  16. package/dist/agents/openai.js +168 -0
  17. package/dist/agents/openai.js.map +1 -0
  18. package/dist/agents/outcome.d.ts +34 -0
  19. package/dist/agents/outcome.js +96 -0
  20. package/dist/agents/outcome.js.map +1 -0
  21. package/dist/agents/permissions.d.ts +44 -0
  22. package/dist/agents/permissions.js +68 -0
  23. package/dist/agents/permissions.js.map +1 -0
  24. package/dist/agents/registry.d.ts +56 -0
  25. package/dist/agents/registry.js +167 -0
  26. package/dist/agents/registry.js.map +1 -0
  27. package/dist/agents/shim.d.ts +35 -0
  28. package/dist/agents/shim.js +53 -0
  29. package/dist/agents/shim.js.map +1 -0
  30. package/dist/agents/types.d.ts +69 -0
  31. package/dist/agents/types.js +28 -0
  32. package/dist/agents/types.js.map +1 -0
  33. package/dist/config.d.ts +99 -0
  34. package/dist/config.js +78 -0
  35. package/dist/config.js.map +1 -0
  36. package/dist/connect.d.ts +35 -0
  37. package/dist/connect.js +124 -0
  38. package/dist/connect.js.map +1 -0
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.js +269 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/limit.d.ts +14 -0
  43. package/dist/limit.js +33 -0
  44. package/dist/limit.js.map +1 -0
  45. package/dist/protocol.d.ts +175 -0
  46. package/dist/protocol.js +86 -0
  47. package/dist/protocol.js.map +1 -0
  48. package/dist/secrets.d.ts +27 -0
  49. package/dist/secrets.js +53 -0
  50. package/dist/secrets.js.map +1 -0
  51. package/dist/server.d.ts +5 -0
  52. package/dist/server.js +97 -0
  53. package/dist/server.js.map +1 -0
  54. package/dist/tasks.d.ts +48 -0
  55. package/dist/tasks.js +103 -0
  56. package/dist/tasks.js.map +1 -0
  57. package/dist/tools.d.ts +25 -0
  58. package/dist/tools.js +71 -0
  59. package/dist/tools.js.map +1 -0
  60. package/dist/ui/page.d.ts +7 -0
  61. package/dist/ui/page.js +428 -0
  62. package/dist/ui/page.js.map +1 -0
  63. package/dist/ui/presets.d.ts +22 -0
  64. package/dist/ui/presets.js +57 -0
  65. package/dist/ui/presets.js.map +1 -0
  66. package/dist/ui/server.d.ts +38 -0
  67. package/dist/ui/server.js +567 -0
  68. package/dist/ui/server.js.map +1 -0
  69. package/package.json +93 -4
@@ -0,0 +1,428 @@
1
+ /**
2
+ * The control panel, as one self-contained page: no build step, no external requests
3
+ * (the Content-Security-Policy forbids them), and every value from the server is set with
4
+ * textContent, never innerHTML, so an agent's output or a provider's error text cannot
5
+ * become markup.
6
+ */
7
+ export function uiPage() {
8
+ return `<!doctype html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8">
12
+ <meta name="viewport" content="width=device-width,initial-scale=1">
13
+ <title>mcpbridgeapp · this computer</title>
14
+ <style>${STYLE}</style>
15
+ </head>
16
+ <body>
17
+ <main>
18
+ <header>
19
+ <div><h1>mcpbridgeapp</h1><div class="sub">on this computer · <span id="configFile"></span></div></div>
20
+ <button class="ghost" id="refresh" type="button">Refresh</button>
21
+ </header>
22
+ <div id="configError" class="banner bad" hidden></div>
23
+
24
+ <section class="panel">
25
+ <div class="row-head"><h2>Relay connection</h2><span id="connPill" class="pill">…</span></div>
26
+ <p class="muted" id="connDetail"></p>
27
+ <form id="relayForm" autocomplete="off">
28
+ <div class="grid3">
29
+ <label>Relay address<input name="url" placeholder="https://mcpbridge.app" required></label>
30
+ <label>Agent token <a href="#" id="tokenHelp" class="hint">where is it?</a><input name="token" type="password" placeholder="mba_…"></label>
31
+ <label>Device name <span class="hint">optional</span><input name="device" placeholder="this computer's name"></label>
32
+ </div>
33
+ <div class="actions">
34
+ <button type="submit">Save &amp; connect</button>
35
+ <button type="button" class="ghost" id="connectBtn">Connect</button>
36
+ <button type="button" class="ghost" id="disconnectBtn">Disconnect</button>
37
+ <span class="err" id="relayErr"></span>
38
+ </div>
39
+ </form>
40
+ <details><summary>Connection log</summary><pre id="connLog"></pre></details>
41
+ </section>
42
+
43
+ <section class="panel">
44
+ <h2>Agents</h2>
45
+ <p class="muted">What the relay offers your AI apps. <b>Test</b> sends a one-word prompt, so it spends a tiny amount of a paid key.</p>
46
+ <div class="table-wrap"><table>
47
+ <thead><tr><th>Agent</th><th>Status</th><th>Detail</th><th></th></tr></thead>
48
+ <tbody id="agents"></tbody>
49
+ </table></div>
50
+ </section>
51
+
52
+ <section class="panel">
53
+ <h2 id="formTitle">Add an agent</h2>
54
+ <form id="agentForm" autocomplete="off">
55
+ <label>Start from<select name="preset" id="preset"></select></label>
56
+ <p class="note" id="presetNote" hidden></p>
57
+ <div class="grid2">
58
+ <label>Name <span class="hint">how your AI apps will call it</span><input name="name" required pattern="[a-z0-9][a-z0-9_\\-]{0,31}"></label>
59
+ <label>Description<input name="description"></label>
60
+ </div>
61
+ <div data-kind="openai ollama">
62
+ <div class="grid2">
63
+ <label data-kind="openai ollama">Base URL<input name="baseUrl"></label>
64
+ <label data-kind="openai">API key <a id="keyLink" class="hint" target="_blank" rel="noopener noreferrer" hidden>get a key</a>
65
+ <input name="apiKey" type="password" placeholder="paste it here; stored on this computer only"></label>
66
+ </div>
67
+ <div class="grid2">
68
+ <label>Model
69
+ <input name="model" placeholder="type an id, or list the models and pick one"></label>
70
+ <label id="modelPick" hidden>Models this key can reach
71
+ <select id="modelSelect"></select></label>
72
+ </div>
73
+ <div class="grid2">
74
+ <div class="inline-actions" data-kind="openai"><button type="button" class="ghost" id="fetchModels">List models</button><span class="muted" id="modelsInfo"></span></div>
75
+ </div>
76
+ <div id="modelMulti" class="multi" hidden>
77
+ <div class="row-head"><b>Add several models at once</b><span class="muted" id="multiCount"></span></div>
78
+ <p class="hint">Each ticked model becomes its own agent with the same key, named after the model, so adding one never replaces another.</p>
79
+ <input id="multiFilter" placeholder="filter, e.g. deepseek, gemma, coder">
80
+ <div id="multiList" class="checks"></div>
81
+ <div class="actions"><button type="button" id="saveMany" disabled>Add ticked models</button><span class="err" id="multiErr"></span><span class="ok" id="multiOk"></span></div>
82
+ </div>
83
+ <label data-kind="openai">Key variable <span class="hint">the name the key is saved under</span><input name="apiKeyEnv" placeholder="e.g. NVIDIA_API_KEY"></label>
84
+ </div>
85
+ <div data-kind="cli">
86
+ <label>Command <span class="hint">one argument per line; {prompt} marks where the prompt goes</span><textarea name="command" rows="4"></textarea></label>
87
+ <label>Prompt goes in<select name="promptVia"><option value="argv">the command line ({prompt})</option><option value="stdin">standard input</option></select></label>
88
+ <fieldset id="allowBox"><legend>What it may do when a client asks it — off means it reads and reports only</legend>
89
+ <label class="check"><input type="checkbox" name="allowEdits"> Change files in the working directory</label>
90
+ <label class="check"><input type="checkbox" name="allowCommands"> Run commands there, such as tests and builds</label>
91
+ <label class="check" id="trustRow"><input type="checkbox" name="allowTrust"> Trust the working directory it is sent to</label>
92
+ <p class="hint" id="allowNote"></p>
93
+ <p class="hint">Any app connected to your relay can ask this agent for work. Allow only what you are happy for those apps to have done on this computer.</p>
94
+ </fieldset>
95
+ </div>
96
+ <label>Timeout, seconds <span class="hint">optional</span><input name="timeoutSeconds" type="number" min="1" max="3600"></label>
97
+ <div class="actions">
98
+ <button type="submit">Save agent</button>
99
+ <button type="button" class="ghost" id="resetForm">Clear</button>
100
+ <span class="err" id="agentErr"></span><span class="ok" id="agentOk"></span>
101
+ </div>
102
+ </form>
103
+ </section>
104
+
105
+ <p class="foot muted">Keys and the agent token are saved in <code id="secretsFile"></code>, never in config.json and never sent to the relay. Close this terminal window and the page stops; the connection stops with it.</p>
106
+ </main>
107
+ <script>${SCRIPT}</script>
108
+ </body>
109
+ </html>`;
110
+ }
111
+ const STYLE = `
112
+ :root{color-scheme:dark;--bg:#0f1115;--panel:#171a21;--line:#2a2f3a;--text:#e6e8ee;--muted:#9aa3b2;--accent:#7aa2ff;--ok:#5ad38a;--bad:#ff7a7a;--warn:#ffcc66}
113
+ [hidden]{display:none!important}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
114
+ main{max-width:980px;margin:0 auto;padding:28px 16px 48px}header{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px}
115
+ h1{font-size:24px;margin:0}h2{font-size:17px;margin:0 0 8px}.sub,.muted,.hint{color:var(--muted)}.sub{font-size:13px;word-break:break-all}.hint{font-size:12px;font-weight:400}
116
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin:14px 0}
117
+ .row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
118
+ label{display:block;font-size:13px;color:var(--muted);margin:10px 0 0}input,select,textarea{display:block;width:100%;margin-top:4px;padding:9px 11px;border:1px solid var(--line);border-radius:8px;background:#0b0d12;color:var(--text);font:14px system-ui,sans-serif}
119
+ textarea{font:13px ui-monospace,Menlo,Consolas,monospace}
120
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}.grid3{display:grid;grid-template-columns:1.3fr 1fr 1fr;gap:0 14px}
121
+ @media(max-width:720px){.grid2,.grid3{grid-template-columns:1fr}}
122
+ button{padding:8px 14px;border:0;border-radius:8px;background:var(--accent);color:#0b0d12;font-weight:600;cursor:pointer;font-size:14px}
123
+ button.ghost{background:transparent;color:var(--text);border:1px solid var(--line)}button.danger{background:transparent;color:var(--bad);border:1px solid var(--bad)}
124
+ button:disabled{opacity:.5;cursor:wait}button.small{padding:4px 10px;font-size:13px}
125
+ .actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:14px}.inline-actions{display:flex;align-items:flex-end;gap:8px;margin-top:10px}
126
+ .err{color:var(--bad);font-size:14px}.ok{color:var(--ok);font-size:14px}
127
+ .pill{font-size:13px;padding:3px 10px;border-radius:999px;border:1px solid var(--line);white-space:nowrap}.pill.on{color:var(--ok);border-color:var(--ok)}.pill.off{color:var(--bad);border-color:var(--bad)}.pill.wait{color:var(--warn);border-color:var(--warn)}
128
+ .table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:8px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-weight:500}
129
+ td.detail{color:var(--muted);word-break:break-word;font-size:13px}td.btns{white-space:nowrap;text-align:right}td.btns button{margin-left:4px}
130
+ .tag{white-space:nowrap;font-size:11px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:0 5px;margin-left:6px}
131
+ .result{margin-top:6px;padding:8px 10px;border-radius:8px;background:#0b0d12;border:1px solid var(--line);white-space:pre-wrap;font:13px ui-monospace,Menlo,Consolas,monospace;color:var(--text)}
132
+ .result.bad{border-color:var(--bad)}.result.good{border-color:var(--ok)}
133
+ fieldset{border:1px solid var(--line);border-radius:8px;margin:12px 0 0;padding:8px 12px}legend{color:var(--muted);font-size:13px;padding:0 4px}
134
+ .multi{border:1px solid var(--line);border-radius:8px;margin:12px 0 0;padding:10px 12px}
135
+ .checks{max-height:260px;overflow:auto;margin-top:8px;border:1px solid var(--line);border-radius:8px;padding:4px 8px}
136
+ .checks label.check{justify-content:space-between;padding:3px 0}.checks .hint{margin-left:auto;text-align:right}
137
+ label.check{display:flex;align-items:center;gap:8px;color:var(--text);font-size:14px}label.check input{width:auto;margin:0}
138
+ .note{background:#1d2230;border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;color:var(--muted);margin:10px 0 0}
139
+ .banner{padding:10px 14px;border-radius:8px;margin:10px 0}.banner.bad{background:#2a1414;border:1px solid var(--bad);color:var(--bad)}
140
+ pre{background:#0b0d12;border:1px solid var(--line);border-radius:8px;padding:10px;white-space:pre-wrap;font:12px ui-monospace,Menlo,Consolas,monospace;max-height:220px;overflow:auto}
141
+ details{margin-top:12px}summary{cursor:pointer;color:var(--muted);font-size:13px}a{color:var(--accent)}code{font:12px ui-monospace,Menlo,Consolas,monospace;word-break:break-all}.foot{font-size:13px;margin-top:20px}
142
+ `;
143
+ const SCRIPT = `
144
+ const $ = (id) => document.getElementById(id);
145
+ let state = null;
146
+ let presets = [];
147
+ // test results outlive the periodic refresh that rebuilds the table
148
+ const results = {};
149
+
150
+ async function api(path, body) {
151
+ const init = { headers: { 'X-MCPBridge-UI': '1' } };
152
+ if (body !== undefined) { init.method = 'POST'; init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
153
+ const res = await fetch(path, init);
154
+ const data = await res.json().catch(() => ({}));
155
+ if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
156
+ return data;
157
+ }
158
+
159
+ function el(tag, props, ...children) {
160
+ const node = document.createElement(tag);
161
+ for (const [k, v] of Object.entries(props || {})) {
162
+ if (k === 'class') node.className = v; else if (k === 'text') node.textContent = v;
163
+ else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else node.setAttribute(k, v);
164
+ }
165
+ for (const c of children) if (c != null) node.append(c);
166
+ return node;
167
+ }
168
+
169
+ async function load() {
170
+ try { state = await api('/api/state'); } catch (e) { $('configError').hidden = false; $('configError').textContent = e.message; return; }
171
+ presets = state.presets;
172
+ $('configFile').textContent = state.configFile;
173
+ $('secretsFile').textContent = state.secretsFile;
174
+ $('configError').hidden = !state.configError;
175
+ $('configError').textContent = state.configError || '';
176
+ renderConnection();
177
+ renderAgents();
178
+ if (!$('preset').options.length) renderPresets();
179
+ }
180
+
181
+ function renderConnection() {
182
+ const c = state.connection, f = $('relayForm');
183
+ const pill = $('connPill');
184
+ const labels = { connected: ['Online', 'on'], connecting: ['Connecting…', 'wait'], reconnecting: ['Reconnecting…', 'wait'], stopped: ['Offline', 'off'] };
185
+ const [text, cls] = labels[c.state];
186
+ pill.textContent = text; pill.className = 'pill ' + cls;
187
+ $('connDetail').textContent =
188
+ c.state === 'connected' ? 'Connected as "' + c.device + '" since ' + new Date(c.since).toLocaleTimeString() + '. Your AI apps can delegate to the agents below.'
189
+ : c.state === 'reconnecting' ? 'Lost the connection (' + c.reason + '); trying again by itself.'
190
+ : c.state === 'connecting' ? 'Opening the connection…'
191
+ : state.relay ? 'Not connected' + (c.reason ? ' (' + c.reason + ')' : '') + '. Press Connect.' : 'Enter the relay address and the agent token from its dashboard, then Save & connect.';
192
+ if (state.relay && document.activeElement?.form !== f) {
193
+ f.url.value = state.relay.url; f.device.value = state.relay.device;
194
+ f.token.placeholder = 'saved ' + state.relay.token + ' — leave empty to keep it';
195
+ } else if (!state.relay && !f.url.value) { f.url.value = 'https://mcpbridge.app'; }
196
+ $('connectBtn').hidden = !state.relay || c.state !== 'stopped';
197
+ $('disconnectBtn').hidden = c.state === 'stopped';
198
+ $('connLog').textContent = state.connectionLog.join('\\n') || 'nothing yet';
199
+ }
200
+
201
+ function renderAgents() {
202
+ const body = $('agents');
203
+ body.replaceChildren();
204
+ for (const a of state.agents) {
205
+ const label = { 'quota-exhausted': '✗ limit reached', 'sign-in-failed': '✗ sign-in failed', 'directory-not-trusted': '✗ folder not trusted' }[a.status];
206
+ const status = el('td', {}, a.available ? el('span', { class: 'ok', text: '✓ ready' }) : el('span', { class: 'err', text: label || '✗ not ready' }),
207
+ el('div', { class: 'hint', text: a.calls ? a.calls + ' call' + (a.calls === 1 ? '' : 's') + (a.lastSuccessAt ? ', last ok ' + new Date(a.lastSuccessAt).toLocaleTimeString() : '') : (a.available ? 'not tried yet' : '') }));
208
+ const may = !a.readsFiles ? 'cannot read files' : (a.writesFiles || a.runsCommands)
209
+ ? 'may ' + [a.writesFiles ? 'edit files' : '', a.runsCommands ? 'run commands' : ''].filter(Boolean).join(' & ')
210
+ : 'read-only';
211
+ const name = el('td', {}, el('b', { text: a.name }), el('span', { class: 'tag', text: a.origin }), el('div', { class: 'hint', text: may + ' · ' + a.timeoutSeconds + ' s limit' }));
212
+ const detail = el('td', { class: 'detail' }, el('div', { text: a.detail }));
213
+ if (a.key) detail.append(el('div', { class: 'hint', text: 'key ' + a.key.env + ': ' + (a.key.saved ? 'saved ' + a.key.saved : a.key.inEnvironment ? 'from the environment' : 'not set') }));
214
+ const result = el('div');
215
+ const show = (cls, text) => { results[a.name] = [cls, text]; result.className = cls; result.textContent = text; };
216
+ if (results[a.name]) { result.className = results[a.name][0]; result.textContent = results[a.name][1]; }
217
+ detail.append(result);
218
+ const btns = el('td', { class: 'btns' });
219
+ const test = el('button', { class: 'ghost small', type: 'button', text: 'Test', onclick: async () => {
220
+ test.disabled = true; show('result', 'asking ' + a.name + '… (a CLI agent can take a minute)');
221
+ try {
222
+ const r = await api('/api/agents/test', { name: a.name });
223
+ show('result ' + (r.succeeded ? 'good' : 'bad'),
224
+ (r.succeeded ? '✓ ' + (r.output || '(empty answer)') : '✗ ' + r.diagnostics) + '\\n' + (r.durationMs / 1000).toFixed(1) + ' s' + (r.notes ? ' · ' + r.notes : ''));
225
+ } catch (e) { show('result bad', e.message); }
226
+ test.disabled = false;
227
+ } });
228
+ btns.append(test);
229
+ btns.append(el('button', { class: 'ghost small', type: 'button', text: a.origin === 'built-in' ? 'Customise' : 'Edit', onclick: () => editAgent(a) }));
230
+ if (a.origin !== 'built-in') {
231
+ btns.append(el('button', { class: 'danger small', type: 'button', text: 'Remove', onclick: async () => {
232
+ if (!confirm('Remove "' + a.name + '" from your config?' + (a.origin === 'override' ? ' The built-in version comes back.' : ''))) return;
233
+ try { await api('/api/agents/remove', { name: a.name }); await load(); } catch (e) { alert(e.message); }
234
+ } }));
235
+ }
236
+ body.append(el('tr', {}, name, status, detail, btns));
237
+ }
238
+ }
239
+
240
+ function renderPresets() {
241
+ const select = $('preset');
242
+ for (const p of presets) select.append(el('option', { value: p.id, text: p.label }));
243
+ applyPreset(presets[0]);
244
+ }
245
+
246
+ function showKind(kind) {
247
+ for (const node of document.querySelectorAll('[data-kind]')) node.hidden = !node.dataset.kind.split(' ').includes(kind);
248
+ // kept apart from data-kind, which marks the fields to show; the form must not hide itself
249
+ $('agentForm').dataset.activeKind = kind;
250
+ }
251
+
252
+ // mirrors the server's naming, to show each agent's name before it is saved
253
+ function agentNameFor(prefix, model) {
254
+ const slug = (model.split('/').pop() || model).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
255
+ const full = prefix + '-' + (slug || 'model');
256
+ if (full.length <= 32) return full;
257
+ const cut = full.slice(0, 32), dash = cut.lastIndexOf('-');
258
+ return (dash > prefix.length ? cut.slice(0, dash) : cut).replace(/[-_]+$/, '');
259
+ }
260
+ let autoName = null;
261
+ let listedModels = [];
262
+
263
+ function renderMulti() {
264
+ const f = $('agentForm'), q = $('multiFilter').value.trim().toLowerCase();
265
+ const ticked = new Set([...document.querySelectorAll('#multiList input:checked')].map((i) => i.value));
266
+ const prefix = (presets.find((p) => p.id === f.preset.value) || {}).name || f.name.value || 'agent';
267
+ const shown = listedModels.filter((m) => !q || m.toLowerCase().includes(q) || ticked.has(m));
268
+ $('multiList').replaceChildren(...shown.map((m) => {
269
+ const box = el('input', { type: 'checkbox', value: m });
270
+ box.checked = ticked.has(m);
271
+ box.addEventListener('change', countTicked);
272
+ return el('label', { class: 'check' }, box, el('span', { text: m }), el('span', { class: 'hint', text: agentNameFor(prefix, m) }));
273
+ }));
274
+ countTicked();
275
+ }
276
+ function countTicked() {
277
+ const n = document.querySelectorAll('#multiList input:checked').length;
278
+ $('multiCount').textContent = n ? n + ' ticked' : '';
279
+ $('saveMany').disabled = n === 0;
280
+ $('saveMany').textContent = n ? 'Add ' + n + ' model' + (n === 1 ? '' : 's') + ' as agents' : 'Add ticked models';
281
+ }
282
+
283
+ function applyPreset(p) {
284
+ const f = $('agentForm');
285
+ autoName = null; listedModels = []; $('modelMulti').hidden = true; $('multiFilter').value = ''; $('multiOk').textContent = ''; $('multiErr').textContent = '';
286
+ $('formTitle').textContent = 'Add an agent';
287
+ f.preset.value = p.id; f.name.value = p.name; f.description.value = p.description || '';
288
+ f.baseUrl.value = p.baseUrl || (p.kind === 'ollama' ? '' : ''); f.model.value = p.model || ''; f.apiKeyEnv.value = p.apiKeyEnv || '';
289
+ f.apiKey.value = ''; f.apiKey.placeholder = 'paste it here; stored on this computer only';
290
+ f.command.value = (p.command || []).join('\\n'); f.promptVia.value = p.promptVia || 'argv'; f.timeoutSeconds.value = '';
291
+ $('keyLink').hidden = !p.keyUrl; if (p.keyUrl) $('keyLink').href = p.keyUrl;
292
+ $('presetNote').hidden = !p.note; $('presetNote').textContent = p.note || '';
293
+ $('modelSelect').replaceChildren(); $('modelPick').hidden = true; $('modelsInfo').textContent = '';
294
+ f.allowEdits.checked = false; f.allowCommands.checked = false; f.allowTrust.checked = false;
295
+ showAllow(null, (p.command || [])[0]);
296
+ $('agentErr').textContent = ''; $('agentOk').textContent = '';
297
+ showKind(p.kind);
298
+ }
299
+
300
+ // which switches mean something depends on the CLI; for one the bridge does not know, none do
301
+ const KNOWN = { claude: [1, 1, 0, ''], codex: [1, 1, 0, 'For Codex, edits and commands are one sandbox setting: either turns on both.'],
302
+ grok: [1, 1, 0, 'Grok Build approves everything or nothing: either turns on both.'], gemini: [1, 1, 1, 'Allowing commands also allows edits for Gemini CLI.'] };
303
+ function showAllow(permissions, executable) {
304
+ let supports = permissions && permissions.supports, note = permissions ? permissions.note : '';
305
+ if (!supports) {
306
+ const first = String(executable || $('agentForm').command.value.split('\\n')[0] || '');
307
+ const exe = first.split(/[\\\\/]/).pop().toLowerCase().replace(/\\.(exe|cmd|bat|ps1)$/, '');
308
+ const k = KNOWN[exe];
309
+ supports = k ? { edits: !!k[0], commands: !!k[1], trustDirectory: !!k[2] } : null; note = k ? k[3] : '';
310
+ }
311
+ $('allowBox').hidden = !supports;
312
+ if (!supports) return;
313
+ $('trustRow').hidden = !supports.trustDirectory;
314
+ $('allowNote').textContent = note;
315
+ }
316
+
317
+ function editAgent(a) {
318
+ const f = $('agentForm'), c = a.config || {};
319
+ const kind = c.type === 'openai' ? 'openai' : c.type === 'ollama' ? 'ollama' : 'cli';
320
+ const preset = presets.find((p) => p.kind === kind && p.id.startsWith('custom')) || presets.find((p) => p.kind === kind);
321
+ applyPreset(preset);
322
+ $('formTitle').textContent = 'Edit ' + a.name;
323
+ $('modelMulti').hidden = true;
324
+ f.name.value = a.name; f.description.value = c.description || '';
325
+ f.baseUrl.value = c.baseUrl || ''; f.model.value = c.model || ''; f.apiKeyEnv.value = c.apiKeyEnv || '';
326
+ if (a.key && a.key.saved) f.apiKey.placeholder = 'saved ' + a.key.saved + ' — leave empty to keep it';
327
+ f.command.value = (c.command || []).join('\\n'); f.promptVia.value = c.promptVia || 'argv';
328
+ f.timeoutSeconds.value = c.timeoutMs ? Math.round(c.timeoutMs / 1000) : '';
329
+ const allow = c.allow || {};
330
+ f.allowEdits.checked = !!allow.edits; f.allowCommands.checked = !!allow.commands; f.allowTrust.checked = !!allow.trustDirectory;
331
+ showAllow(a.permissions);
332
+ f.scrollIntoView({ behavior: 'smooth' });
333
+ }
334
+
335
+ $('agentForm').command.addEventListener('input', () => showAllow(null));
336
+ $('modelSelect').addEventListener('change', (e) => {
337
+ const f = $('agentForm');
338
+ f.model.value = e.target.value;
339
+ // adding, not editing: name it after the model, unless the person typed a name of their own
340
+ const preset = presets.find((p) => p.id === f.preset.value);
341
+ if ($('formTitle').textContent.startsWith('Add') && preset && (f.name.value === preset.name || f.name.value === autoName)) {
342
+ autoName = agentNameFor(preset.name, e.target.value);
343
+ f.name.value = autoName;
344
+ }
345
+ });
346
+ $('multiFilter').addEventListener('input', renderMulti);
347
+ $('saveMany').addEventListener('click', async () => {
348
+ const f = $('agentForm'), btn = $('saveMany');
349
+ const models = [...document.querySelectorAll('#multiList input:checked')].map((i) => i.value);
350
+ const prefix = (presets.find((p) => p.id === f.preset.value) || {}).name || f.name.value;
351
+ $('multiErr').textContent = ''; $('multiOk').textContent = '';
352
+ btn.disabled = true;
353
+ try {
354
+ const r = await api('/api/agents/save-many', { name: prefix, kind: 'openai', models, baseUrl: f.baseUrl.value,
355
+ apiKeyEnv: f.apiKeyEnv.value, apiKey: f.apiKey.value, description: f.description.value });
356
+ $('multiOk').textContent = '✓ added ' + r.saved.join(', ') + ' — press Test on each below';
357
+ for (const i of document.querySelectorAll('#multiList input:checked')) i.checked = false;
358
+ await load();
359
+ } catch (e) { $('multiErr').textContent = e.message; }
360
+ countTicked();
361
+ });
362
+ $('preset').addEventListener('change', (e) => applyPreset(presets.find((p) => p.id === e.target.value)));
363
+ $('resetForm').addEventListener('click', () => applyPreset(presets.find((p) => p.id === $('preset').value) || presets[0]));
364
+ $('refresh').addEventListener('click', async () => { await api('/api/reload', {}).catch(() => {}); await load(); });
365
+ $('tokenHelp').addEventListener('click', (e) => { e.preventDefault(); alert('Open your relay (for example https://mcpbridge.app), sign in, and press "Generate agent token" on the dashboard. It starts with mba_ and is shown once.'); });
366
+
367
+ $('fetchModels').addEventListener('click', async () => {
368
+ const f = $('agentForm'), btn = $('fetchModels');
369
+ btn.disabled = true; $('modelsInfo').textContent = 'asking the provider…';
370
+ try {
371
+ const r = await api('/api/models', { baseUrl: f.baseUrl.value, apiKey: f.apiKey.value, apiKeyEnv: f.apiKeyEnv.value });
372
+ // a real select, not a datalist: a datalist only offers what matches the text already
373
+ // in the field, so a pre-filled model hid every other choice
374
+ const current = f.model.value;
375
+ const options = r.models.map((m) => el('option', { value: m, text: m }));
376
+ if (current && !r.models.includes(current)) options.unshift(el('option', { value: current, text: current + ' (not listed by the provider)' }));
377
+ $('modelSelect').replaceChildren(...options);
378
+ if (current) $('modelSelect').value = current;
379
+ $('modelPick').hidden = false;
380
+ $('modelsInfo').textContent = r.models.length + ' models — choose one below';
381
+ listedModels = r.models;
382
+ $('modelMulti').hidden = !$('formTitle').textContent.startsWith('Add');
383
+ renderMulti();
384
+ if (!f.model.value && r.models.length) f.model.value = r.models[0];
385
+ } catch (e) { $('modelsInfo').textContent = e.message; }
386
+ btn.disabled = false;
387
+ });
388
+
389
+ $('agentForm').addEventListener('submit', async (e) => {
390
+ e.preventDefault();
391
+ const f = e.target, kind = f.dataset.activeKind;
392
+ $('agentErr').textContent = ''; $('agentOk').textContent = '';
393
+ const body = { name: f.name.value, kind, description: f.description.value };
394
+ if (f.timeoutSeconds.value) body.timeoutMs = Number(f.timeoutSeconds.value) * 1000;
395
+ if (kind === 'openai') Object.assign(body, { baseUrl: f.baseUrl.value, model: f.model.value, apiKeyEnv: f.apiKeyEnv.value, apiKey: f.apiKey.value });
396
+ if (kind === 'ollama') Object.assign(body, { baseUrl: f.baseUrl.value, model: f.model.value });
397
+ if (kind === 'cli') Object.assign(body, { command: f.command.value.split('\\n').map((s) => s.trim()).filter(Boolean), promptVia: f.promptVia.value,
398
+ allow: { edits: f.allowEdits.checked, commands: f.allowCommands.checked, trustDirectory: f.allowTrust.checked } });
399
+ // saving under a name already in use replaces that agent; say so before it happens
400
+ const existing = state.agents.find((a) => a.name === f.name.value.trim().toLowerCase());
401
+ if (existing && $('formTitle').textContent.startsWith('Add')) {
402
+ const what = existing.origin === 'built-in' ? 'This replaces the built-in "' + existing.name + '" with your version.'
403
+ : 'An agent named "' + existing.name + '" already exists' + (existing.config && existing.config.model ? ' (' + existing.config.model + ')' : '') + ' and will be replaced.';
404
+ if (!confirm(what + ' Continue? To keep both, cancel and change the name.')) return;
405
+ }
406
+ try {
407
+ const r = await api('/api/agents/save', body);
408
+ $('agentOk').textContent = '✓ saved "' + r.saved + '" — press Test on it below' + (state.connection.state !== 'stopped' ? '; the relay already has it' : '');
409
+ f.apiKey.value = '';
410
+ await load();
411
+ } catch (err) { $('agentErr').textContent = err.message; }
412
+ });
413
+
414
+ $('relayForm').addEventListener('submit', async (e) => {
415
+ e.preventDefault();
416
+ const f = e.target; $('relayErr').textContent = '';
417
+ try { await api('/api/relay/save', { url: f.url.value, token: f.token.value, device: f.device.value }); f.token.value = ''; await load(); }
418
+ catch (err) { $('relayErr').textContent = err.message; }
419
+ });
420
+ $('connectBtn').addEventListener('click', async () => { $('relayErr').textContent = ''; try { await api('/api/relay/connect', {}); await load(); } catch (e) { $('relayErr').textContent = e.message; } });
421
+ $('disconnectBtn').addEventListener('click', async () => { try { await api('/api/relay/disconnect', {}); await load(); } catch (e) { $('relayErr').textContent = e.message; } });
422
+
423
+ load();
424
+ // skipped only while a field is being typed in, which a refresh would overwrite: focus left
425
+ // on a form's button after Save & connect used to hold the status at "Connecting…"
426
+ setInterval(() => { if (!document.hidden && !document.activeElement?.matches('form input, form select, form textarea')) load(); }, 4000);
427
+ `;
428
+ //# sourceMappingURL=page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/ui/page.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,MAAM;IACpB,OAAO;;;;;;SAMA,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UA6FJ,MAAM;;QAER,CAAC;AACT,CAAC;AAED,MAAM,KAAK,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+Bb,CAAC;AAEF,MAAM,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4Rd,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Starting points for the "add an agent" form. Each fills in what is fixed for that
3
+ * provider -- the endpoint, the variable the key lives in, where to get a key -- and leaves
4
+ * the model to the person, who can list what their key can reach with one click.
5
+ */
6
+ export interface Preset {
7
+ id: string;
8
+ label: string;
9
+ kind: 'openai' | 'cli' | 'ollama';
10
+ name: string;
11
+ description: string;
12
+ baseUrl?: string;
13
+ apiKeyEnv?: string;
14
+ /** where a key comes from, shown as a link next to the key field */
15
+ keyUrl?: string;
16
+ /** a model id to start from; the endpoint's own listing is the authority */
17
+ model?: string;
18
+ command?: string[];
19
+ promptVia?: 'argv' | 'stdin';
20
+ note?: string;
21
+ }
22
+ export declare const PRESETS: Preset[];
@@ -0,0 +1,57 @@
1
+ export const PRESETS = [
2
+ {
3
+ id: 'nvidia', label: 'NVIDIA (build.nvidia.com, free tier)', kind: 'openai', name: 'nvidia',
4
+ description: 'Open models hosted by NVIDIA',
5
+ baseUrl: 'https://integrate.api.nvidia.com/v1', apiKeyEnv: 'NVIDIA_API_KEY',
6
+ keyUrl: 'https://build.nvidia.com', model: 'meta/llama-3.3-70b-instruct',
7
+ note: 'Sign in at build.nvidia.com, open any model, and use "Get API Key"; the key starts with nvapi-. Free with an NVIDIA Developer account, rate-limited.',
8
+ },
9
+ {
10
+ id: 'deepseek', label: 'DeepSeek', kind: 'openai', name: 'deepseek',
11
+ description: 'DeepSeek via its API',
12
+ baseUrl: 'https://api.deepseek.com', apiKeyEnv: 'DEEPSEEK_API_KEY',
13
+ keyUrl: 'https://platform.deepseek.com/api_keys',
14
+ note: 'Prepaid: top up the balance on the platform, or calls fail with "Insufficient Balance".',
15
+ },
16
+ {
17
+ id: 'grok-cli', label: 'Grok (Grok Build CLI, your grok.com sign-in)', kind: 'cli', name: 'grok',
18
+ description: 'Grok via the official Grok Build CLI',
19
+ command: ['grok', '-p', '{prompt}', '--no-auto-update'],
20
+ note: 'Install with: irm https://x.ai/cli/install.ps1 | iex, then run grok login once. Grok Build needs usage balance on the account.',
21
+ },
22
+ {
23
+ id: 'xai', label: 'Grok (xAI API key)', kind: 'openai', name: 'grok-api',
24
+ description: 'Grok via the xAI API',
25
+ baseUrl: 'https://api.x.ai/v1', apiKeyEnv: 'XAI_API_KEY', keyUrl: 'https://console.x.ai',
26
+ },
27
+ {
28
+ id: 'openrouter', label: 'OpenRouter (one key, many providers)', kind: 'openai', name: 'openrouter',
29
+ description: 'Many providers through OpenRouter',
30
+ baseUrl: 'https://openrouter.ai/api/v1', apiKeyEnv: 'OPENROUTER_API_KEY', keyUrl: 'https://openrouter.ai/keys',
31
+ },
32
+ {
33
+ id: 'groq', label: 'Groq', kind: 'openai', name: 'groq',
34
+ description: 'Fast open models on Groq',
35
+ baseUrl: 'https://api.groq.com/openai/v1', apiKeyEnv: 'GROQ_API_KEY', keyUrl: 'https://console.groq.com/keys',
36
+ },
37
+ {
38
+ id: 'mistral', label: 'Mistral', kind: 'openai', name: 'mistral',
39
+ description: 'Mistral models via its API',
40
+ baseUrl: 'https://api.mistral.ai/v1', apiKeyEnv: 'MISTRAL_API_KEY', keyUrl: 'https://console.mistral.ai/api-keys',
41
+ },
42
+ {
43
+ id: 'ollama', label: 'Ollama (local, no key)', kind: 'ollama', name: 'local',
44
+ description: 'A model running locally in Ollama',
45
+ note: 'Leave the model empty to use the first one Ollama has installed.',
46
+ },
47
+ {
48
+ id: 'custom-openai', label: 'Any OpenAI-compatible API', kind: 'openai', name: '',
49
+ description: 'A model over an OpenAI-compatible API', baseUrl: '', apiKeyEnv: '',
50
+ },
51
+ {
52
+ id: 'custom-cli', label: 'Any command-line agent', kind: 'cli', name: '',
53
+ description: 'A command-line agent', command: [],
54
+ note: 'Put {prompt} where the prompt goes, or choose "stdin" for CLIs that read it from a pipe.',
55
+ },
56
+ ];
57
+ //# sourceMappingURL=presets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.js","sourceRoot":"","sources":["../../src/ui/presets.ts"],"names":[],"mappings":"AAsBA,MAAM,CAAC,MAAM,OAAO,GAAa;IAC/B;QACE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,sCAAsC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ;QAC3F,WAAW,EAAE,8BAA8B;QAC3C,OAAO,EAAE,qCAAqC,EAAE,SAAS,EAAE,gBAAgB;QAC3E,MAAM,EAAE,0BAA0B,EAAE,KAAK,EAAE,6BAA6B;QACxE,IAAI,EAAE,sJAAsJ;KAC7J;IACD;QACE,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU;QACnE,WAAW,EAAE,sBAAsB;QACnC,OAAO,EAAE,0BAA0B,EAAE,SAAS,EAAE,kBAAkB;QAClE,MAAM,EAAE,wCAAwC;QAChD,IAAI,EAAE,yFAAyF;KAChG;IACD;QACE,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,8CAA8C,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM;QAChG,WAAW,EAAE,sCAAsC;QACnD,OAAO,EAAE,CAAC,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,kBAAkB,CAAC;QACvD,IAAI,EAAE,gIAAgI;KACvI;IACD;QACE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,oBAAoB,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU;QACxE,WAAW,EAAE,sBAAsB;QACnC,OAAO,EAAE,qBAAqB,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,EAAE,sBAAsB;KACzF;IACD;QACE,EAAE,EAAE,YAAY,EAAE,KAAK,EAAE,sCAAsC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY;QACnG,WAAW,EAAE,mCAAmC;QAChD,OAAO,EAAE,8BAA8B,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,EAAE,4BAA4B;KAC/G;IACD;QACE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM;QACvD,WAAW,EAAE,0BAA0B;QACvC,OAAO,EAAE,gCAAgC,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,+BAA+B;KAC9G;IACD;QACE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS;QAChE,WAAW,EAAE,4BAA4B;QACzC,OAAO,EAAE,2BAA2B,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,EAAE,qCAAqC;KAClH;IACD;QACE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,wBAAwB,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO;QAC5E,WAAW,EAAE,mCAAmC;QAChD,IAAI,EAAE,kEAAkE;KACzE;IACD;QACE,EAAE,EAAE,eAAe,EAAE,KAAK,EAAE,2BAA2B,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE;QACjF,WAAW,EAAE,uCAAuC,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE;KACjF;IACD;QACE,EAAE,EAAE,YAAY,EAAE,KAAK,EAAE,wBAAwB,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QACxE,WAAW,EAAE,sBAAsB,EAAE,OAAO,EAAE,EAAE;QAChD,IAAI,EAAE,0FAA0F;KACjG;CACF,CAAC"}
@@ -0,0 +1,38 @@
1
+ import { type Server } from 'node:http';
2
+ import { type AgentConfig } from '../config.js';
3
+ export interface UiOptions {
4
+ port: number;
5
+ agentVersion: string;
6
+ configFile?: string;
7
+ secretsFile?: string;
8
+ log?: (line: string) => void;
9
+ /** replaces the call that lists a provider's models; for tests */
10
+ fetchImpl?: typeof fetch;
11
+ }
12
+ export interface UiServer {
13
+ server: Server;
14
+ port: number;
15
+ /** the address to open: carries the per-launch key, which the page swaps for a cookie */
16
+ url: string;
17
+ close(): Promise<void>;
18
+ }
19
+ /**
20
+ * The local control panel: everything `mcpbridgeapp` asks of a terminal -- adding an agent,
21
+ * saving its key, checking it answers, keeping this machine connected to the relay --
22
+ * done from a page on this machine.
23
+ *
24
+ * <p>Three things keep it this machine's alone. It listens on 127.0.0.1 only. Every
25
+ * request must carry a key generated at launch, which reaches the browser only through
26
+ * the link printed in the terminal and is then held in an HttpOnly cookie. And requests
27
+ * whose Host or Origin is not this server are refused, so a web page elsewhere cannot use
28
+ * the browser to drive it, by DNS rebinding or by cross-site POST.
29
+ */
30
+ export declare function startUi(options: UiOptions): Promise<UiServer>;
31
+ /** "deepseek-ai/deepseek-v4.1-flash" under "nvidia" -> "nvidia-deepseek-v4-1-flash", within 32 characters. */
32
+ export declare function agentNameFor(prefix: string, model: string): string;
33
+ /**
34
+ * A distinct name per model. A name already used by an agent on the same model and endpoint
35
+ * is reused, so ticking a model twice updates it; one used by anything else -- another
36
+ * model, a built-in -- gets a numbered suffix instead of being overwritten.
37
+ */
38
+ export declare function agentNamesFor(prefix: string, models: string[], baseUrl: string, existing: Record<string, AgentConfig>, builtIn: string[]): string[];