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".
This commit is contained in:
parent
17c434dcee
commit
a920737c3c
7 changed files with 375 additions and 8 deletions
21
README.md
21
README.md
|
|
@ -32,19 +32,28 @@ Desktop on the same computer work in your signed-in Theseus tabs: open and
|
|||
read pages, click, type into rich editors, paste formatted text, upload
|
||||
files. Design: [DESIGN-claude-connector.md](DESIGN-claude-connector.md).
|
||||
|
||||
**Turn it on.** Settings › Extensions › Claude connector. Its panel (the
|
||||
connector icon in the toolbar dock) shows the address, the token, the
|
||||
approved sites, the upload folders and a live log of every action.
|
||||
**Turn it on.** Settings › Extensions › Claude connector. Its panel opens by
|
||||
itself the first time; later it is the connector icon in the toolbar dock.
|
||||
It shows the address, the token, the approved sites, the upload folders and
|
||||
a live log of every action.
|
||||
|
||||
**Add it to Claude Code** (once; `--scope user` makes it available in every
|
||||
project). Copy the exact command from the panel; it looks like:
|
||||
**Connect Claude.** In the panel, click **Connect** next to Claude Code or
|
||||
Claude Desktop. Theseus registers itself in that app's own configuration
|
||||
(Claude Code through its `claude mcp` command, user scope; Claude Desktop in
|
||||
`claude_desktop_config.json`, backup kept beside it) and keeps the entry
|
||||
current: the token changes whenever the connector stops or is revoked, and
|
||||
the entry follows. **Remove** takes it out again. Claude Desktop needs a
|
||||
restart after a change; a running Claude Code session picks it up with `/mcp`.
|
||||
|
||||
**By hand**, if you prefer (panel › *Set up by hand*), the same entry for
|
||||
Claude Code is (once; `--scope user` makes it available in every project):
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user --transport http theseus http://127.0.0.1:47800/mcp --header "Authorization: Bearer <token from the panel>"
|
||||
```
|
||||
|
||||
Check it with `claude mcp list`. **Claude Desktop** speaks stdio only; the
|
||||
panel's *Claude Desktop* tab has a ready `claude_desktop_config.json` entry
|
||||
panel's *Claude Desktop* entry is a ready `claude_desktop_config.json` entry
|
||||
that runs `stdio-shim.cjs` through the Theseus executable itself
|
||||
(`ELECTRON_RUN_AS_NODE=1`), so nothing else needs installing.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ Theseus README ("Claude connector (MCP)") and DESIGN-claude-connector.md.
|
|||
| `lib/tab-driver.cjs` | one tab over its own `webContents.debugger` (CDP 1.3) |
|
||||
| `lib/policy.cjs` | site approvals, upload-folder checks, password-field detection |
|
||||
| `lib/clipboard.cjs` | put / save / restore the system clipboard around a paste |
|
||||
| `lib/clients.cjs` | "Connect": registers the connector with Claude Code (its CLI, user scope) and Claude Desktop (its config file), reports status, re-registers after token/port changes |
|
||||
| `lib/action-log.cjs` | timestamped action log (panel + `actions.log`) |
|
||||
| `panel.html` | the sidebar panel |
|
||||
| `stdio-shim.cjs` | stdio ⇄ HTTP relay for Claude Desktop, run by Theseus in Node mode |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "claude-connector",
|
||||
"name": "Claude connector",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Lets Claude Code or Claude Desktop on this computer work in your Theseus tabs through a local MCP connection: read pages, click, type, paste and upload. Off until you turn it on; asks before each new site; never types into password fields; logs every action.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iMTQiIGZpbGw9IiNkOTc3NTciLz48cGF0aCBkPSJNMzIgMTN2MzhNMTMgMzJoMzhNMTguNiAxOC42bDI2LjggMjYuOE00NS40IDE4LjYgMTguNiA0NS40IiBzdHJva2U9IiNmZmY0ZWUiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+DQo=",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const { Controller } = require("./lib/tools.cjs");
|
|||
const { SitePolicy } = require("./lib/policy.cjs");
|
||||
const { ActionLog } = require("./lib/action-log.cjs");
|
||||
const { makeClipboard } = require("./lib/clipboard.cjs");
|
||||
const apps = require("./lib/clients.cjs");
|
||||
|
||||
let server = null;
|
||||
let controller = null;
|
||||
|
|
@ -121,9 +122,61 @@ module.exports = {
|
|||
uploadDirs: S.uploadDirs,
|
||||
token: S.token,
|
||||
commands: url ? commandsFor(url, S.token) : null,
|
||||
apps: appStatus,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Claude apps on this computer -------------------------------------
|
||||
// "Connect" registers the connector with Claude Code / Claude Desktop in
|
||||
// their own configs (lib/clients.cjs). The apps the user connected this
|
||||
// way are remembered and kept current: the token changes on every stop
|
||||
// and on Revoke, and the port can change, so after each start the
|
||||
// entries are rewritten when they no longer match.
|
||||
const APPS = { claudeCode: apps.claudeCode, claudeDesktop: apps.claudeDesktop };
|
||||
const linked = () => ({ claudeCode: false, claudeDesktop: false, ...(api.storage.get("linkedApps", {}) || {}) });
|
||||
let appStatus = null;
|
||||
function appParams() {
|
||||
return { url: server && server.url, token: S.token, exe: process.execPath, shim: path.join(api.folder, "stdio-shim.cjs") };
|
||||
}
|
||||
function refreshApps() {
|
||||
const params = appParams();
|
||||
const l = linked();
|
||||
const out = {};
|
||||
for (const [id, impl] of Object.entries(APPS)) {
|
||||
try { out[id] = { ...impl.status(params), linked: !!l[id] }; }
|
||||
catch (e) { out[id] = { installed: false, configured: false, current: false, linked: !!l[id], error: e.message }; }
|
||||
}
|
||||
appStatus = out;
|
||||
return out;
|
||||
}
|
||||
async function connectApp(id) {
|
||||
const impl = APPS[id];
|
||||
if (!impl) throw new Error(`unknown app ${id}`);
|
||||
if (!server) throw new Error("turn on Accept connections first");
|
||||
const r = await impl.register(appParams());
|
||||
api.storage.set("linkedApps", { ...linked(), [id]: true });
|
||||
log.add({ tool: "connector", args: `registered with ${id === "claudeCode" ? "Claude Code" : "Claude Desktop"}`, ok: true });
|
||||
return r;
|
||||
}
|
||||
async function syncApps() {
|
||||
if (!server) return;
|
||||
const params = appParams();
|
||||
for (const [id, on] of Object.entries(linked())) {
|
||||
if (!on || !APPS[id]) continue;
|
||||
let st;
|
||||
try { st = APPS[id].status(params); } catch { continue; }
|
||||
if (!st.installed || st.current) continue;
|
||||
try {
|
||||
await APPS[id].register(params);
|
||||
log.add({ tool: "connector", args: `${id === "claudeCode" ? "Claude Code" : "Claude Desktop"} updated to the current address and token`, ok: true });
|
||||
} catch (e) {
|
||||
log.add({ tool: "connector", args: `could not update ${id}`, ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
refreshApps();
|
||||
pushState();
|
||||
}
|
||||
|
||||
function commandsFor(url, token) {
|
||||
const exe = process.execPath;
|
||||
const shim = path.join(api.folder, "stdio-shim.cjs");
|
||||
|
|
@ -156,6 +209,7 @@ module.exports = {
|
|||
api.log(`listening on ${s.url}`);
|
||||
log.add({ tool: "connector", args: `listening on ${s.url}`, ok: true });
|
||||
pushStatus();
|
||||
syncApps().catch((e) => api.log("app sync failed:", e.message));
|
||||
}
|
||||
|
||||
async function stop(reason) {
|
||||
|
|
@ -211,8 +265,24 @@ module.exports = {
|
|||
await controller.releaseAll();
|
||||
log.add({ tool: "connector", args: "token revoked; a new one was issued", ok: true });
|
||||
pushStatus();
|
||||
await syncApps();
|
||||
return publicState();
|
||||
});
|
||||
api.onMessage("apps", () => { refreshApps(); return publicState(); });
|
||||
api.onMessage("app-connect", async ({ app } = {}) => {
|
||||
const r = await connectApp(String(app || ""));
|
||||
refreshApps();
|
||||
return { ...publicState(), restart: !!(r && r.restart) };
|
||||
});
|
||||
api.onMessage("app-remove", async ({ app } = {}) => {
|
||||
const id = String(app || "");
|
||||
if (!APPS[id]) throw new Error(`unknown app ${id}`);
|
||||
const r = await APPS[id].unregister();
|
||||
api.storage.set("linkedApps", { ...linked(), [id]: false });
|
||||
log.add({ tool: "connector", args: `removed from ${id === "claudeCode" ? "Claude Code" : "Claude Desktop"}`, ok: true });
|
||||
refreshApps();
|
||||
return { ...publicState(), restart: !!(r && r.restart) };
|
||||
});
|
||||
api.onMessage("set-evaluate", ({ on } = {}) => {
|
||||
api.storage.set("allowEvaluate", !!on);
|
||||
log.add({ tool: "connector", args: on ? "evaluate allowed" : "evaluate turned off", ok: true });
|
||||
|
|
@ -243,6 +313,15 @@ module.exports = {
|
|||
|
||||
api.registerSidebarPanel({ id: "main", title: "Claude connector", page: "panel.html" });
|
||||
|
||||
// The first time the extension is turned on, open its panel: the next
|
||||
// step (Connect) is there, and nothing else points to it.
|
||||
if (!api.storage.get("introShown", false)) {
|
||||
api.storage.set("introShown", true);
|
||||
Promise.resolve(api.whenUiReady ? api.whenUiReady() : null)
|
||||
.then(() => api.revealSidebar && api.revealSidebar("main"))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (S.accept) {
|
||||
Promise.resolve(api.whenUiReady ? api.whenUiReady() : null)
|
||||
.then(() => start())
|
||||
|
|
|
|||
168
bundled-addons/claude-connector/lib/clients.cjs
Normal file
168
bundled-addons/claude-connector/lib/clients.cjs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Registers the connector with the Claude apps on this computer, so the user
|
||||
// clicks "Connect" instead of copying a command into a terminal.
|
||||
//
|
||||
// Claude Code: through its own CLI (`claude mcp add-json … --scope user`), so
|
||||
// Claude Code writes its own config and nothing here edits ~/.claude.json,
|
||||
// which a running Claude Code rewrites at will. That file is only READ, to
|
||||
// show whether the entry is there and current.
|
||||
//
|
||||
// Claude Desktop: its claude_desktop_config.json is the file users are told
|
||||
// to edit by hand. Only the `theseus` entry is added, changed or removed; the
|
||||
// rest is kept as it was, a copy of the previous file is kept beside it, and
|
||||
// a file that does not parse is left alone.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const NAME = "theseus";
|
||||
|
||||
function env(opts) {
|
||||
const e = opts.env || process.env;
|
||||
const platform = opts.platform || process.platform;
|
||||
const home = opts.home || os.homedir();
|
||||
return { e, platform, home };
|
||||
}
|
||||
|
||||
// ---- Claude Code ---------------------------------------------------------
|
||||
|
||||
// The CLI as a native binary only: an npm `claude.cmd` would need a shell,
|
||||
// and a shell would have to re-quote the JSON argument.
|
||||
function findClaudeCli(opts = {}) {
|
||||
const { e, platform, home } = env(opts);
|
||||
if (opts.cli) return fs.existsSync(opts.cli) ? opts.cli : null;
|
||||
const names = platform === "win32" ? ["claude.exe"] : ["claude"];
|
||||
const dirs = [
|
||||
path.join(home, ".local", "bin"),
|
||||
path.join(home, ".claude", "local"),
|
||||
...(platform === "win32" ? [] : ["/usr/local/bin", "/opt/homebrew/bin"]),
|
||||
...String(e.PATH || e.Path || "").split(path.delimiter).filter(Boolean),
|
||||
];
|
||||
for (const d of dirs) {
|
||||
for (const n of names) {
|
||||
const p = path.join(d, n);
|
||||
try { if (fs.statSync(p).isFile()) return p; } catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function claudeCodeConfigPath(opts = {}) {
|
||||
const { e, home } = env(opts);
|
||||
return e.CLAUDE_CONFIG_DIR ? path.join(e.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(home, ".claude.json");
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
||||
}
|
||||
|
||||
function httpEntry(url, token) {
|
||||
return { type: "http", url, headers: { Authorization: `Bearer ${token}` } };
|
||||
}
|
||||
|
||||
function sameHttp(entry, url, token) {
|
||||
return !!entry && entry.url === url && entry.headers && entry.headers.Authorization === `Bearer ${token}`;
|
||||
}
|
||||
|
||||
function run(cli, args, opts) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cli, args, { timeout: 30000, windowsHide: true, env: opts.env || process.env }, (err, stdout, stderr) => {
|
||||
resolve({ ok: !err, code: err ? (err.code ?? 1) : 0, out: `${stdout || ""}${stderr || ""}`.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const claudeCode = {
|
||||
status({ url, token }, opts = {}) {
|
||||
const cli = findClaudeCli(opts);
|
||||
const cfg = readJson(claudeCodeConfigPath(opts));
|
||||
const entry = cfg && cfg.mcpServers ? cfg.mcpServers[NAME] : null;
|
||||
return {
|
||||
installed: !!cli,
|
||||
configured: !!entry,
|
||||
current: !!url && sameHttp(entry, url, token),
|
||||
};
|
||||
},
|
||||
async register({ url, token }, opts = {}) {
|
||||
const cli = findClaudeCli(opts);
|
||||
if (!cli) throw new Error("Claude Code was not found on this computer (looked for the claude command)");
|
||||
// add-json refuses an existing name, so an update is remove + add.
|
||||
await run(cli, ["mcp", "remove", NAME, "--scope", "user"], opts);
|
||||
const r = await run(cli, ["mcp", "add-json", NAME, JSON.stringify(httpEntry(url, token)), "--scope", "user"], opts);
|
||||
if (!r.ok) throw new Error(`Claude Code refused the entry: ${r.out || `exit ${r.code}`}`);
|
||||
return { ok: true };
|
||||
},
|
||||
async unregister(opts = {}) {
|
||||
const cli = findClaudeCli(opts);
|
||||
if (!cli) throw new Error("Claude Code was not found on this computer");
|
||||
const r = await run(cli, ["mcp", "remove", NAME, "--scope", "user"], opts);
|
||||
if (!r.ok && !/not found|no .*server/i.test(r.out)) throw new Error(`Claude Code: ${r.out || `exit ${r.code}`}`);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Claude Desktop ------------------------------------------------------
|
||||
|
||||
function desktopConfigPath(opts = {}) {
|
||||
const { e, platform, home } = env(opts);
|
||||
if (platform === "win32") return path.join(e.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
||||
if (platform === "darwin") return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
||||
return path.join(e.XDG_CONFIG_HOME || path.join(home, ".config"), "Claude", "claude_desktop_config.json");
|
||||
}
|
||||
|
||||
// Claude Desktop starts stdio servers only: it runs Theseus itself in Node
|
||||
// mode with the relay, which reads the address from Theseus's endpoint file.
|
||||
function desktopEntry({ exe, shim, url, token }) {
|
||||
return { command: exe, args: [shim], env: { ELECTRON_RUN_AS_NODE: "1", THESEUS_MCP_URL: url, THESEUS_MCP_TOKEN: token } };
|
||||
}
|
||||
|
||||
function sameDesktop(entry, want) {
|
||||
return !!entry && entry.command === want.command && JSON.stringify(entry.args) === JSON.stringify(want.args)
|
||||
&& entry.env && entry.env.THESEUS_MCP_URL === want.env.THESEUS_MCP_URL && entry.env.THESEUS_MCP_TOKEN === want.env.THESEUS_MCP_TOKEN;
|
||||
}
|
||||
|
||||
function editDesktopConfig(file, edit) {
|
||||
let cfg = {};
|
||||
if (fs.existsSync(file)) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
try { cfg = text.trim() ? JSON.parse(text) : {}; }
|
||||
catch { throw new Error(`${file} is not valid JSON; fix or remove it in Claude Desktop first`); }
|
||||
fs.copyFileSync(file, file + ".theseus-backup");
|
||||
}
|
||||
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) throw new Error(`${file} does not hold a settings object`);
|
||||
edit(cfg);
|
||||
const tmp = file + ".tmp-" + process.pid;
|
||||
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
const claudeDesktop = {
|
||||
status(params, opts = {}) {
|
||||
const file = desktopConfigPath(opts);
|
||||
const cfg = readJson(file);
|
||||
const entry = cfg && cfg.mcpServers ? cfg.mcpServers[NAME] : null;
|
||||
return {
|
||||
installed: fs.existsSync(path.dirname(file)),
|
||||
configured: !!entry,
|
||||
current: !!params.url && sameDesktop(entry, desktopEntry(params)),
|
||||
};
|
||||
},
|
||||
async register(params, opts = {}) {
|
||||
const file = desktopConfigPath(opts);
|
||||
if (!fs.existsSync(path.dirname(file))) throw new Error("Claude Desktop was not found on this computer (no Claude settings folder)");
|
||||
editDesktopConfig(file, (cfg) => {
|
||||
if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") cfg.mcpServers = {};
|
||||
cfg.mcpServers[NAME] = desktopEntry(params);
|
||||
});
|
||||
return { ok: true, restart: true };
|
||||
},
|
||||
async unregister(opts = {}) {
|
||||
const file = desktopConfigPath(opts);
|
||||
if (!fs.existsSync(file)) return { ok: true };
|
||||
editDesktopConfig(file, (cfg) => { if (cfg.mcpServers) delete cfg.mcpServers[NAME]; });
|
||||
return { ok: true, restart: true };
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { claudeCode, claudeDesktop, findClaudeCli, claudeCodeConfigPath, desktopConfigPath, httpEntry, desktopEntry };
|
||||
|
|
@ -54,6 +54,18 @@
|
|||
.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); }
|
||||
|
|
@ -70,6 +82,11 @@
|
|||
|
||||
<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>
|
||||
|
|
@ -82,6 +99,7 @@
|
|||
<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>
|
||||
|
|
@ -128,6 +146,7 @@
|
|||
? 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("")
|
||||
|
|
@ -182,7 +201,42 @@
|
|||
box.insertAdjacentHTML("afterbegin", entryHtml(e));
|
||||
while (box.children.length > 300) box.lastChild.remove();
|
||||
});
|
||||
S.invoke("state").then(render);
|
||||
// ---- 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>
|
||||
|
|
|
|||
|
|
@ -329,6 +329,62 @@ await test("keyInfo", () => {
|
|||
ok(threw);
|
||||
});
|
||||
|
||||
console.log("claude apps");
|
||||
const apps = lib("clients.cjs");
|
||||
await test("Claude Desktop: adds, updates and removes only the theseus entry, with a backup", async () => {
|
||||
const appData = fs.mkdtempSync(path.join(os.tmpdir(), "cc-desk-"));
|
||||
const o = { platform: "win32", home: appData, env: { APPDATA: appData } };
|
||||
const params = { url: "http://127.0.0.1:47800/mcp", token: "t1", exe: "C:/Theseus.exe", shim: "C:/shim.cjs" };
|
||||
eq(apps.claudeDesktop.status(params, o).installed, false);
|
||||
await rejects(apps.claudeDesktop.register(params, o), /not found/);
|
||||
const dir = path.join(appData, "Claude"); fs.mkdirSync(dir);
|
||||
const file = path.join(dir, "claude_desktop_config.json");
|
||||
fs.writeFileSync(file, JSON.stringify({ globalShortcut: "Ctrl+Space", mcpServers: { other: { command: "x" } } }));
|
||||
const r = await apps.claudeDesktop.register(params, o);
|
||||
ok(r.restart, "asks for a restart");
|
||||
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
eq(cfg.globalShortcut, "Ctrl+Space"); eq(cfg.mcpServers.other, { command: "x" });
|
||||
eq(cfg.mcpServers.theseus.env.THESEUS_MCP_TOKEN, "t1");
|
||||
eq(cfg.mcpServers.theseus.env.ELECTRON_RUN_AS_NODE, "1");
|
||||
ok(fs.existsSync(file + ".theseus-backup"), "backup kept");
|
||||
eq(apps.claudeDesktop.status(params, o), { installed: true, configured: true, current: true });
|
||||
eq(apps.claudeDesktop.status({ ...params, token: "t2" }, o).current, false, "a new token makes it stale");
|
||||
await apps.claudeDesktop.unregister(o);
|
||||
const after = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
ok(!after.mcpServers.theseus && after.mcpServers.other, "only theseus removed");
|
||||
fs.writeFileSync(file, "{ not json");
|
||||
await rejects(apps.claudeDesktop.register(params, o), /not valid JSON/);
|
||||
eq(fs.readFileSync(file, "utf8"), "{ not json", "an unreadable file is left alone");
|
||||
fs.rmSync(appData, { recursive: true, force: true });
|
||||
});
|
||||
await test("Claude Code: the CLI is found in ~/.local/bin", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "cc-home-"));
|
||||
const bin = path.join(home, ".local", "bin"); fs.mkdirSync(bin, { recursive: true });
|
||||
const exe = path.join(bin, process.platform === "win32" ? "claude.exe" : "claude"); fs.writeFileSync(exe, "");
|
||||
eq(apps.findClaudeCli({ home, env: { PATH: "" } }), exe);
|
||||
eq(apps.findClaudeCli({ home: path.join(home, "nobody"), env: { PATH: "" } }), null);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
await test("Claude Code: register, update and remove through the real claude CLI (throwaway config)", async () => {
|
||||
const cli = apps.findClaudeCli();
|
||||
if (!cli) { console.log(" (skipped: no claude CLI on this computer)"); return; }
|
||||
const cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-claude-"));
|
||||
const o = { env: { ...process.env, CLAUDE_CONFIG_DIR: cfgDir, USERPROFILE: cfgDir, HOME: cfgDir } };
|
||||
const p1 = { url: "http://127.0.0.1:47800/mcp", token: "tok-one" };
|
||||
eq(apps.claudeCode.status(p1, o).configured, false);
|
||||
await apps.claudeCode.register(p1, o);
|
||||
eq(apps.claudeCode.status(p1, o), { installed: true, configured: true, current: true });
|
||||
const p2 = { url: "http://127.0.0.1:47801/mcp", token: "tok-two" };
|
||||
eq(apps.claudeCode.status(p2, o).current, false, "stale after a token/port change");
|
||||
await apps.claudeCode.register(p2, o); // an existing entry is replaced, not refused
|
||||
eq(apps.claudeCode.status(p2, o).current, true);
|
||||
const saved = JSON.parse(fs.readFileSync(path.join(cfgDir, ".claude.json"), "utf8")).mcpServers.theseus;
|
||||
eq(saved, { type: "http", url: p2.url, headers: { Authorization: "Bearer tok-two" } });
|
||||
await apps.claudeCode.unregister(o);
|
||||
eq(apps.claudeCode.status(p2, o).configured, false);
|
||||
fs.rmSync(cfgDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue