diff --git a/README.md b/README.md index a5b1fc5c..86e71929 100644 --- a/README.md +++ b/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 " ``` 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. diff --git a/bundled-addons/claude-connector/README.md b/bundled-addons/claude-connector/README.md index 8311da3e..2995b522 100644 --- a/bundled-addons/claude-connector/README.md +++ b/bundled-addons/claude-connector/README.md @@ -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 | diff --git a/bundled-addons/claude-connector/addon.json b/bundled-addons/claude-connector/addon.json index a4d33ca4..7c7e775f 100644 --- a/bundled-addons/claude-connector/addon.json +++ b/bundled-addons/claude-connector/addon.json @@ -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=", diff --git a/bundled-addons/claude-connector/index.js b/bundled-addons/claude-connector/index.js index fa23cddf..3ef60615 100644 --- a/bundled-addons/claude-connector/index.js +++ b/bundled-addons/claude-connector/index.js @@ -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()) diff --git a/bundled-addons/claude-connector/lib/clients.cjs b/bundled-addons/claude-connector/lib/clients.cjs new file mode 100644 index 00000000..beede4b4 --- /dev/null +++ b/bundled-addons/claude-connector/lib/clients.cjs @@ -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 }; diff --git a/bundled-addons/claude-connector/panel.html b/bundled-addons/claude-connector/panel.html index 17b63eee..c2908106 100644 --- a/bundled-addons/claude-connector/panel.html +++ b/bundled-addons/claude-connector/panel.html @@ -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 @@

Connect Claude

+

One click adds Theseus to a Claude app on this computer. Theseus keeps the entry current when its token or address changes.

+
Claude Code
…
+
Claude Desktop
…
+
+
Set up by hand

Run once in a terminal. --scope user makes it available in every project.

@@ -82,6 +99,7 @@

The token in this command is the key to this browser. Keep it out of shared files; Revoke token makes a new one.

+
@@ -128,6 +146,7 @@ ? sess.map((s) => `${esc(s.client)} — connected since ${esc(new Date(s.since).toLocaleTimeString())}${s.streaming ? "" : ", idle"}`).join("
") + (st.controlledTabs.length ? `
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) => `
  • ${esc(o)}
  • `).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(); diff --git a/tests/claude-connector-unit.test.mjs b/tests/claude-connector-unit.test.mjs index 9ae56cdc..53e7768c 100644 --- a/tests/claude-connector-unit.test.mjs +++ b/tests/claude-connector-unit.test.mjs @@ -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);