Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ const pkg = require('../package.json');
// Subcommands. Dispatched before server boot so `lynkr usage` / `lynkr trajectory`
// don't start the proxy. Add new subcommands here, not in scattered binaries.
const SUBCOMMANDS = {
usage: path.join(__dirname, "lynkr-usage.js"),
stats: path.join(__dirname, "lynkr-usage.js"),
trajectory: path.join(__dirname, "lynkr-trajectory.js"),
wrap: path.join(__dirname, "wrap.js"),
init: path.join(__dirname, "lynkr-init.js"),
reset: path.join(__dirname, "lynkr-reset.js"),
usage: path.join(__dirname, "lynkr-usage.js"),
stats: path.join(__dirname, "lynkr-usage.js"),
trajectory: path.join(__dirname, "lynkr-trajectory.js"),
wrap: path.join(__dirname, "wrap.js"),
init: path.join(__dirname, "lynkr-init.js"),
reset: path.join(__dirname, "lynkr-reset.js"),
"desktop-token": path.join(__dirname, "lynkr-desktop-token.js"),
restart: path.join(__dirname, "lynkr-restart.js"),
};

const sub = process.argv[2];
Expand Down Expand Up @@ -51,6 +53,11 @@ Usage:
lynkr start [options] Alias for the above
lynkr init [options] Interactive setup wizard (writes .env, pulls classifier model)
lynkr wrap <target> [options] Wrap CLI tools through Lynkr proxy
lynkr desktop-token <token> Apply a Claude Pro/Max OAuth token to Claude Desktop's
gateway profile (macOS only)
lynkr desktop-token --restore Restore Claude Desktop to talking to Anthropic directly
(undoes the above; macOS only)
lynkr restart Restart the local Lynkr server (pick up code/config changes)
lynkr usage [options] Show AI spend report and tier-routing savings
lynkr stats [options] Shareable savings-receipt card (also: lynkr usage --card)
lynkr trajectory [options] Export agent trajectories as JSONL training data
Expand Down
108 changes: 108 additions & 0 deletions bin/lynkr-desktop-token.js
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();
125 changes: 125 additions & 0 deletions bin/lynkr-restart.js
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)));
3 changes: 2 additions & 1 deletion documentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ New to Lynkr? Start here:
Connect Lynkr to your development tools:

- **[Claude Code CLI Setup](claude-code-cli.md)** - Configure Claude Code CLI to use Lynkr
- **[Claude Desktop Setup](claude-desktop.md)** - Route the Claude Desktop app through Lynkr (macOS gateway profile, model picker as tier selector)
- **[Codex CLI Setup](codex-cli.md)** - Configure OpenAI Codex CLI with Lynkr (config.toml, wire_api, troubleshooting)
- **[OpenClaw Integration](openclaw-integration.md)** - Use OpenClaw with Lynkr as its AI backend
- **[Cursor IDE Integration](cursor-integration.md)** - Full Cursor IDE setup with troubleshooting
Expand Down Expand Up @@ -72,7 +73,7 @@ Get help and contribute:
## Quick Navigation by Topic

### Setup & Configuration
- [Installation](installation.md) | [Providers](providers.md) | [Claude Code](claude-code-cli.md) | [Codex CLI](codex-cli.md) | [OpenClaw](openclaw-integration.md) | [Cursor](cursor-integration.md) | [Embeddings](embeddings.md)
- [Installation](installation.md) | [Providers](providers.md) | [Claude Code](claude-code-cli.md) | [Claude Desktop](claude-desktop.md) | [Codex CLI](codex-cli.md) | [OpenClaw](openclaw-integration.md) | [Cursor](cursor-integration.md) | [Embeddings](embeddings.md)

### Features & Optimization
- [Features](features.md) | [Routing](routing.md) | [Memory System](memory-system.md) | [Token Optimization](token-optimization.md) | [Headroom](headroom.md) | [Tools](tools.md)
Expand Down
Loading
Loading