-
-
Notifications
You must be signed in to change notification settings - Fork 59
feat: Support Claude Desktop #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * `lynkr desktop-token <token>` / `lynkr desktop-token --restore` | ||
| * | ||
| * Apply a freshly-minted Claude Pro/Max OAuth token to Claude Desktop's | ||
| * Lynkr gateway profile — or restore Desktop back to talking to Anthropic | ||
| * directly. | ||
| * | ||
| * This does NOT mint or refresh the token — that stays a manual, one-time | ||
| * `claude setup-token` (interactive browser approval) every time it expires. | ||
| * It does NOT restart Lynkr, either: the token lives entirely in Desktop's | ||
| * own config file (read at Desktop's launch, not Lynkr's), and Lynkr just | ||
| * forwards whatever bearer shows up on each request — it has no token state | ||
| * of its own to refresh. Restarting only matters when Lynkr's own code | ||
| * changed; use `lynkr restart` for that, separately. | ||
| * | ||
| * `lynkr desktop-token <token>`: | ||
| * 1. Installs the token into the Desktop "3p" gateway profile | ||
| * (delegates to scripts/claude-desktop.js --install) | ||
| * 2. Prints --status so you can see the profile actually took | ||
| * | ||
| * `lynkr desktop-token --restore`: | ||
| * Delegates to scripts/claude-desktop.js --restore — puts Desktop's | ||
| * deployment mode and applied-profile bookkeeping back to what they were | ||
| * before Lynkr's gateway profile was installed, and removes the profile | ||
| * + backup files. Safe to run even if nothing was ever installed (a | ||
| * no-op restore, not an error). | ||
| * | ||
| * Neither variant touches .env TIER_* values, and neither quits or | ||
| * relaunches Claude Desktop itself (that would close your open chat | ||
| * windows) — both just print the command to do so when you're ready. | ||
| * | ||
| * Usage: | ||
| * lynkr desktop-token sk-ant-oat01-... | ||
| * lynkr desktop-token --restore | ||
| */ | ||
|
|
||
| const path = require("path"); | ||
| const fs = require("fs"); | ||
| const { execFileSync } = require("child_process"); | ||
|
|
||
| const ROOT = path.join(__dirname, ".."); | ||
| const ENV_PATH = path.join(ROOT, ".env"); | ||
| const CLAUDE_DESKTOP_JS = path.join(ROOT, "scripts", "claude-desktop.js"); | ||
|
|
||
| function fail(msg) { | ||
| console.error(msg); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| function readPort() { | ||
| try { | ||
| const env = fs.readFileSync(ENV_PATH, "utf8"); | ||
| const m = env.match(/^PORT=(\d+)/m); | ||
| if (m) return Number(m[1]); | ||
| } catch (err) { | ||
| console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`); | ||
| } | ||
| return 8081; | ||
| } | ||
|
|
||
| function main() { | ||
| const token = process.argv[2]; | ||
| if (!token || token === "-h" || token === "--help") { | ||
| fail("Usage: lynkr desktop-token <sk-ant-oat-token> | --restore"); | ||
| } | ||
| if (token === "--restore") { | ||
| if (process.platform !== "darwin") { | ||
| fail("Claude Desktop profile management is only supported on macOS."); | ||
| } | ||
| console.log("Restoring Claude Desktop to the usual Claude profile..."); | ||
| execFileSync("node", [CLAUDE_DESKTOP_JS, "--restore"], { stdio: "inherit" }); | ||
| console.log( | ||
| "\nWhen you're ready (this would close your open chats if done automatically):" + | ||
| "\n killall Claude && open -a Claude" | ||
| ); | ||
| return; | ||
| } | ||
| if (!token.startsWith("sk-ant-oat")) { | ||
| fail( | ||
| `That doesn't look like a Claude Code OAuth access token (expected it to start ` + | ||
| `with "sk-ant-oat"). Got: ${token.slice(0, 12)}...\n` + | ||
| `Mint one with: claude setup-token` | ||
| ); | ||
| } | ||
| if (process.platform !== "darwin") { | ||
| fail("Claude Desktop profile management is only supported on macOS."); | ||
| } | ||
|
|
||
| const url = `http://127.0.0.1:${readPort()}`; | ||
|
|
||
| console.log("Step 1/2 — installing token into Claude Desktop's Lynkr gateway profile..."); | ||
| execFileSync("node", [CLAUDE_DESKTOP_JS, "--install", "--url", url, "--key", token], { | ||
| stdio: "inherit", | ||
| }); | ||
|
|
||
| console.log("\nStep 2/2 — status:"); | ||
| execFileSync("node", [CLAUDE_DESKTOP_JS, "--status"], { stdio: "inherit" }); | ||
|
|
||
| console.log( | ||
| "\nNext (manual, on purpose — this would close your open chats if done for you):" + | ||
| "\n killall Claude && open -a Claude" + | ||
| "\n\nWhen Anthropic calls start 401ing: claude setup-token, then: lynkr desktop-token <token>" + | ||
| "\nIf you've also changed Lynkr's own code, that's a separate step: lynkr restart" | ||
| ); | ||
| } | ||
|
|
||
| main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * `lynkr restart` | ||
| * | ||
| * Stop whatever's listening on Lynkr's configured port and start a fresh | ||
| * `npm start`, detached so it survives this command exiting. Use this after | ||
| * changing Lynkr's own code (routing, config, etc.) — it has nothing to do | ||
| * with Claude Desktop's token or profile, which Desktop reads from disk at | ||
| * its own launch time, independent of whether Lynkr's process restarts. | ||
| * See `lynkr desktop-token` for that. | ||
| * | ||
| * Usage: | ||
| * lynkr restart | ||
| */ | ||
|
|
||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const http = require("http"); | ||
| const { execFileSync, spawn } = require("child_process"); | ||
|
|
||
| const ROOT = path.join(__dirname, ".."); | ||
| const ENV_PATH = path.join(ROOT, ".env"); | ||
| const LOG_PATH = path.join(ROOT, "data", "logs", "lynkr-desktop.log"); | ||
|
|
||
| function fail(msg) { | ||
| console.error(msg); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| function readPort() { | ||
| try { | ||
| const env = fs.readFileSync(ENV_PATH, "utf8"); | ||
| const m = env.match(/^PORT=(\d+)/m); | ||
| if (m) return Number(m[1]); | ||
| } catch (err) { | ||
| console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`); | ||
| } | ||
| return 8081; | ||
| } | ||
|
|
||
| function findListeningPid(port) { | ||
| try { | ||
| const out = execFileSync("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-t"], { | ||
| encoding: "utf8", | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| }).trim(); | ||
| return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : []; | ||
| } catch { | ||
| return []; // lsof exits non-zero when nothing matches | ||
| } | ||
| } | ||
|
|
||
| function healthCheck(port) { | ||
| return new Promise((resolve) => { | ||
| const req = http.get({ host: "127.0.0.1", port, path: "/health", timeout: 1500 }, (res) => { | ||
| let body = ""; | ||
| res.on("data", (d) => (body += d)); | ||
| res.on("end", () => { | ||
| try { | ||
| resolve(res.statusCode === 200 && JSON.parse(body).status === "ok"); | ||
| } catch { | ||
| resolve(false); | ||
| } | ||
| }); | ||
| }); | ||
| req.on("error", () => resolve(false)); | ||
| req.on("timeout", () => { | ||
| req.destroy(); | ||
| resolve(false); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function waitForHealth(port, timeoutMs) { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| if (await healthCheck(port)) return true; | ||
| await new Promise((r) => setTimeout(r, 500)); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| async function main() { | ||
| if (process.argv[2] === "-h" || process.argv[2] === "--help") { | ||
| console.log("Usage: lynkr restart"); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| const port = readPort(); | ||
| const pids = findListeningPid(port); | ||
| if (pids.length) { | ||
| console.log(`Stopping existing Lynkr process (pid ${pids.join(", ")}) on port ${port}...`); | ||
| for (const pid of pids) { | ||
| try { | ||
| process.kill(Number(pid), "SIGTERM"); | ||
| } catch (err) { | ||
| console.warn(` couldn't signal pid ${pid}: ${err.message}`); | ||
| } | ||
| } | ||
| const freedByDeadline = Date.now() + 5000; | ||
| while (Date.now() < freedByDeadline && findListeningPid(port).length) { | ||
| await new Promise((r) => setTimeout(r, 250)); | ||
| } | ||
| } else { | ||
| console.log(`Nothing currently listening on port ${port}.`); | ||
| } | ||
|
|
||
| fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); | ||
| const logFd = fs.openSync(LOG_PATH, "a"); | ||
| console.log(`Starting Lynkr (npm start), logging to ${LOG_PATH}...`); | ||
| const child = spawn("npm", ["start"], { | ||
| cwd: ROOT, | ||
| stdio: ["ignore", logFd, logFd], | ||
| detached: true, | ||
| }); | ||
| child.unref(); | ||
|
|
||
| const up = await waitForHealth(port, 15000); | ||
| if (!up) { | ||
| fail(`Lynkr didn't answer /health on port ${port} within 15s.\nCheck ${LOG_PATH} for errors.`); | ||
| } | ||
| console.log(`Lynkr is up on http://127.0.0.1:${port}.`); | ||
| } | ||
|
|
||
| main().catch((err) => fail(err.stack || String(err))); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Stop when the old listener still owns the port.
If SIGTERM does not free the port within five seconds, Line 111 still starts
npm. The new process then fails to bind. If the old Lynkr process still answers/health, this command reports success although it did not deploy the new process.Check
findListeningPid(port)after the grace period. Fail before spawning when a listener remains.Proposed fix
🤖 Prompt for AI Agents