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:
Dev 2026-10-24 00:32:06 +03:00
parent 17c434dcee
commit a920737c3c
7 changed files with 375 additions and 8 deletions

View file

@ -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.

View file

@ -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 |

View file

@ -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=",

View file

@ -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())

View 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 };

View file

@ -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>

View file

@ -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);