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".
243 lines
15 KiB
HTML
243 lines
15 KiB
HTML
<!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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|