theseus/bundled-addons/claude-connector/panel.html
Dev a920737c3c Claude connector 0.2.0: connect Claude Code and Claude Desktop from the panel
Setting the connector up meant copying a command with a token into a
terminal, and since 0.3.107 the token rotates whenever the connector
stops, so a configured client silently lost access after any extension
toggle and had to be set up again.

The panel now has a Connect button per app. Claude Code is registered
through its own CLI (claude mcp add-json, user scope), so its config is
written by Claude Code itself; Claude Desktop gets only the theseus entry
in claude_desktop_config.json, with a backup and no write to a file that
does not parse. Apps connected this way are re-registered after every
start and every Revoke when the address or token no longer matches. The
panel opens by itself the first time the extension is turned on. The
copy-a-command route stays under "Set up by hand".
2026-10-24 00:32:06 +03:00

243 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Claude connector</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --clay:#d97757; --bad:#ff9081;
--btn:#1c2432; --btn-h:#242e40; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --btn:#f0f3fa; --btn-h:#e3e8f2; --bad:#c43c2c; }
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 2; }
header .t { font-weight: 600; flex: 1; }
.pill { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--mut); white-space: nowrap; }
.pill.on { color: var(--clay); border-color: rgb(217 119 87 / .6); background: rgb(217 119 87 / .12); }
.pill.idle { color: var(--acid); border-color: rgb(from var(--acid) r g b / .35); }
section { padding: 12px 14px; border-bottom: 1px solid var(--line); }
h2 { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); margin: 0 0 8px; font-weight: 600; }
p { margin: 0 0 8px; color: var(--mut); }
.row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.row .grow { flex: 1; min-width: 0; }
.sw { position: relative; width: 34px; height: 20px; flex: none; }
.sw input { opacity: 0; width: 0; height: 0; position: absolute; }
.sw span { position: absolute; inset: 0; background: var(--btn); border: 1px solid var(--line); border-radius: 999px; cursor: pointer; transition: .15s; }
.sw span::after { content: ""; position: absolute; width: 14px; height: 14px; left: 2px; top: 2px; background: var(--mut); border-radius: 50%; transition: .15s; }
.sw input:checked + span { background: rgb(217 119 87 / .35); border-color: var(--clay); }
.sw input:checked + span::after { transform: translateX(14px); background: var(--clay); }
button.b { font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--btn); color: var(--ink); cursor: pointer; white-space: nowrap; }
button.b:hover { background: var(--btn-h); }
button.b.warn { color: var(--bad); }
button.x { border: none; background: none; color: var(--dim); cursor: pointer; font-size: 13px; padding: 2px 6px; }
button.x:hover { color: var(--bad); }
pre { margin: 6px 0; padding: 8px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; font: 11.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow: auto; }
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.list { margin: 4px 0; padding: 0; list-style: none; }
.list li { display: flex; align-items: center; gap: 6px; padding: 3px 0; border-bottom: 1px dashed var(--line); }
.list li:last-child { border-bottom: none; }
.list li .grow { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.empty { color: var(--dim); font-style: italic; }
.note { font-size: 12px; color: var(--mut); }
.warnbox { font-size: 12px; color: var(--bad); margin-top: 4px; }
.log { font: 11.5px/1.4 ui-monospace, Consolas, monospace; }
.log .e { padding: 4px 0; border-bottom: 1px solid var(--line); }
.log .e .h { display: flex; gap: 6px; flex-wrap: wrap; }
.log .ts { color: var(--dim); }
.log .tool { color: var(--ink); font-weight: 600; }
.log .org { color: var(--mut); }
.log .ok { color: var(--acid); }
.log .bad { color: var(--bad); }
.log .a { color: var(--mut); overflow-wrap: anywhere; }
.app { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px dashed var(--line); }
.app:last-of-type { border-bottom: none; }
.app .grow { flex: 1; min-width: 0; }
.app .st { font-size: 12px; color: var(--mut); }
.app .st.ok { color: var(--acid); }
.app .st.warn { color: var(--clay); }
button.b.pri { background: rgb(217 119 87 / .2); border-color: var(--clay); color: var(--ink); }
button.b:disabled { opacity: .5; cursor: default; }
details.manual { margin-top: 8px; }
details.manual summary { cursor: pointer; color: var(--mut); font-size: 12px; }
.msg { font-size: 12px; margin-top: 6px; color: var(--mut); }
.msg.bad { color: var(--bad); }
.tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.tabs button { font: inherit; font-size: 12px; padding: 3px 9px; border: 1px solid var(--line); background: var(--btn); color: var(--mut); border-radius: 6px; cursor: pointer; }
.tabs button.sel { color: var(--ink); border-color: var(--clay); }
</style>
</head>
<body>
<header><span class="t">Claude connector</span><span class="pill" id="pill">…</span></header>
<section>
<div class="row"><div class="grow"><b>Accept connections</b><div class="note" id="where">Off</div></div><label class="sw"><input type="checkbox" id="accept"><span></span></label></div>
<div id="clients" class="note"></div>
<div class="row" style="margin-top:8px"><button class="b" id="disconnect">Disconnect clients</button><button class="b warn" id="revoke" title="Issue a new token. Every client must be set up again with the new command.">Revoke token</button></div>
</section>
<section id="setup">
<h2>Connect Claude</h2>
<p>One click adds Theseus to a Claude app on this computer. Theseus keeps the entry current when its token or address changes.</p>
<div class="app"><div class="grow"><b>Claude Code</b><div class="st" id="stCode">…</div></div><button class="b" id="rmCode" hidden>Remove</button><button class="b pri" id="goCode">Connect</button></div>
<div class="app"><div class="grow"><b>Claude Desktop</b><div class="st" id="stDesk">…</div></div><button class="b" id="rmDesk" hidden>Remove</button><button class="b pri" id="goDesk">Connect</button></div>
<div class="msg" id="appMsg"></div>
<details class="manual"><summary>Set up by hand</summary>
<div class="tabs"><button id="tCode" class="sel">Claude Code</button><button id="tDesk">Claude Desktop</button></div>
<div id="pCode">
<p>Run once in a terminal. <span class="mono">--scope user</span> makes it available in every project.</p>
<pre id="cmdCode"></pre>
<div class="row"><button class="b" id="copyCode">Copy command</button></div>
</div>
<div id="pDesk" hidden>
<p>Add to <span class="mono">claude_desktop_config.json</span> (Settings › Developer › Edit config), then restart Claude Desktop. It runs through Theseus itself, nothing else to install.</p>
<pre id="cmdDesk"></pre>
<div class="row"><button class="b" id="copyDesk">Copy config</button></div>
</div>
<p class="note">The token in this command is the key to this browser. Keep it out of shared files; Revoke token makes a new one.</p>
</details>
</section>
<section>
<h2>Sites Claude may control</h2>
<p>Theseus asks the first time Claude acts on a site.</p>
<ul class="list" id="origins"></ul>
</section>
<section>
<h2>Upload folders</h2>
<p>Claude can attach files only from these folders.</p>
<ul class="list" id="dirs"></ul>
<div class="row"><button class="b" id="addDir">Add folder…</button></div>
</section>
<section>
<h2>Advanced</h2>
<div class="row"><div class="grow"><b>Allow evaluate</b><div class="note">Lets Claude run its own JavaScript in approved sites' pages.</div></div><label class="sw"><input type="checkbox" id="evaluate"><span></span></label></div>
<div class="warnbox" id="evalWarn" hidden>Evaluate runs any script as the page itself, including reading what you see there. Turn it off when you do not need it.</div>
</section>
<section>
<h2>Activity</h2>
<div class="row"><span class="note grow">Every action, newest first.</span><button class="b" id="openLog">Log file</button><button class="b" id="clearLog">Clear</button></div>
<div class="log" id="log"></div>
</section>
<script>
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
let state = null;
function render(st) {
state = st;
$("accept").checked = !!st.accept;
const pill = $("pill");
if (st.connected) { pill.textContent = "Connected"; pill.className = "pill on"; }
else if (st.listening) { pill.textContent = "Waiting for Claude"; pill.className = "pill idle"; }
else { pill.textContent = "Off"; pill.className = "pill"; }
$("where").textContent = st.listening ? `Listening on ${st.url} (this computer only)` : st.accept ? "Starting…" : "Not listening";
const sess = st.sessions || [];
$("clients").innerHTML = sess.length
? sess.map((s) => `${esc(s.client)} — connected since ${esc(new Date(s.since).toLocaleTimeString())}${s.streaming ? "" : ", idle"}`).join("<br>") + (st.controlledTabs.length ? `<br>Controlling tab${st.controlledTabs.length === 1 ? "" : "s"} ${st.controlledTabs.join(", ")}` : "")
: (st.listening ? "No client connected." : "");
$("setup").hidden = !st.commands;
renderApps(st.apps);
if (st.commands) { $("cmdCode").textContent = st.commands.claudeCode; $("cmdDesk").textContent = st.commands.claudeDesktop; }
$("origins").innerHTML = st.allowedOrigins.length
? st.allowedOrigins.map((o) => `<li><span class="grow mono">${esc(o)}</span><button class="x" data-origin="${esc(o)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None yet.</li>';
$("dirs").innerHTML = st.uploadDirs.length
? st.uploadDirs.map((d) => `<li><span class="grow mono">${esc(d)}</span><button class="x" data-dir="${esc(d)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None. Uploads are refused.</li>';
$("evaluate").checked = !!st.allowEvaluate;
$("evalWarn").hidden = !st.allowEvaluate;
}
function entryHtml(e) {
const t = new Date(e.ts);
const ts = `${t.toLocaleDateString()} ${t.toLocaleTimeString()}`;
return `<div class="e"><div class="h"><span class="ts">${esc(ts)}</span><span class="tool">${esc(e.tool)}</span>${e.tab != null ? `<span class="org">tab ${esc(e.tab)}</span>` : ""}${e.origin ? `<span class="org">${esc(e.origin)}</span>` : ""}<span class="${e.ok ? "ok" : "bad"}">${e.ok ? "ok" : "failed"}</span>${e.ms != null ? `<span class="ts">${esc(e.ms)} ms</span>` : ""}</div>${e.args ? `<div class="a">${esc(e.args)}</div>` : ""}${e.error ? `<div class="bad">${esc(e.error)}</div>` : ""}</div>`;
}
async function loadLog() {
const list = await S.invoke("log", { n: 200 });
$("log").innerHTML = list.slice().reverse().map(entryHtml).join("") || '<div class="empty">Nothing yet.</div>';
}
$("accept").onchange = async (e) => render(await S.invoke("set-accept", { on: e.target.checked }));
$("evaluate").onchange = async (e) => render(await S.invoke("set-evaluate", { on: e.target.checked }));
$("disconnect").onclick = async () => render(await S.invoke("disconnect"));
// Two clicks: every client has to be set up again afterwards.
let revokeArmed = null;
$("revoke").onclick = async () => {
const b = $("revoke");
if (!revokeArmed) { b.textContent = "Click again to revoke"; revokeArmed = setTimeout(() => { revokeArmed = null; b.textContent = "Revoke token"; }, 4000); return; }
clearTimeout(revokeArmed); revokeArmed = null; b.textContent = "Revoke token";
render(await S.invoke("revoke-token"));
};
$("addDir").onclick = async () => render(await S.invoke("add-upload-dir"));
$("copyCode").onclick = () => state && S.invoke("copy", { text: state.commands.claudeCode }).then(() => flash("copyCode"));
$("copyDesk").onclick = () => state && S.invoke("copy", { text: state.commands.claudeDesktop }).then(() => flash("copyDesk"));
$("clearLog").onclick = async () => { await S.invoke("clear-log"); loadLog(); };
$("openLog").onclick = () => S.invoke("open-log-folder");
$("tCode").onclick = () => { $("pCode").hidden = false; $("pDesk").hidden = true; $("tCode").classList.add("sel"); $("tDesk").classList.remove("sel"); };
$("tDesk").onclick = () => { $("pCode").hidden = true; $("pDesk").hidden = false; $("tDesk").classList.add("sel"); $("tCode").classList.remove("sel"); };
document.body.addEventListener("click", async (e) => {
const o = e.target.closest("[data-origin]");
if (o) render(await S.invoke("remove-origin", { origin: o.dataset.origin }));
const d = e.target.closest("[data-dir]");
if (d) render(await S.invoke("remove-upload-dir", { dir: d.dataset.dir }));
});
function flash(id) { const b = $(id), t = b.textContent; b.textContent = "Copied"; setTimeout(() => { b.textContent = t; }, 1200); }
S.on("status", render);
S.on("log-entry", (e) => {
const box = $("log");
if (box.querySelector(".empty")) box.innerHTML = "";
box.insertAdjacentHTML("afterbegin", entryHtml(e));
while (box.children.length > 300) box.lastChild.remove();
});
// ---- Claude apps ----
const APP_UI = { claudeCode: ["stCode", "goCode", "rmCode"], claudeDesktop: ["stDesk", "goDesk", "rmDesk"] };
function renderApps(apps) {
if (!apps) return;
for (const [id, [stId, goId, rmId]] of Object.entries(APP_UI)) {
const a = apps[id] || {};
const st = $(stId), go = $(goId), rm = $(rmId);
if (!a.installed && !a.configured) { st.textContent = id === "claudeCode" ? "Not found on this computer (the claude command)." : "Not installed on this computer."; st.className = "st"; go.hidden = true; rm.hidden = true; continue; }
go.hidden = false;
if (a.current) { st.textContent = "Connected. Theseus keeps it up to date."; st.className = "st ok"; go.hidden = true; }
else if (a.configured) { st.textContent = "Set up with an old address or token."; st.className = "st warn"; go.textContent = "Update"; }
else { st.textContent = "Not connected."; st.className = "st"; go.textContent = "Connect"; }
go.disabled = !(state && state.listening) || !a.installed;
if (!(state && state.listening)) st.textContent += " Turn on Accept connections first.";
rm.hidden = !a.configured;
}
}
async function appAction(msg, app, btn) {
const m = $("appMsg");
btn.disabled = true; m.className = "msg"; m.textContent = "Working…";
try {
const st = await S.invoke(msg, { app });
render(st);
const name = app === "claudeCode" ? "Claude Code" : "Claude Desktop";
m.textContent = msg === "app-connect"
? (st.restart ? `Added. Restart ${name} to load it.` : `Added to ${name}. New Claude Code sessions see the Theseus tools; in a running one, use /mcp.`)
: (st.restart ? `Removed. Restart ${name} to unload it.` : `Removed from ${name}.`);
} catch (e) { m.className = "msg bad"; m.textContent = String(e && e.message || e).replace(/^Error invoking remote method '[^']*': (Error: )?/, ""); }
finally { btn.disabled = false; }
}
$("goCode").onclick = (e) => appAction("app-connect", "claudeCode", e.currentTarget);
$("goDesk").onclick = (e) => appAction("app-connect", "claudeDesktop", e.currentTarget);
$("rmCode").onclick = (e) => appAction("app-remove", "claudeCode", e.currentTarget);
$("rmDesk").onclick = (e) => appAction("app-remove", "claudeDesktop", e.currentTarget);
S.invoke("apps").then(render);
loadLog();
</script>
</body>
</html>