From 1b0c4813768028f5ac4d6eb0eb1a1fff7326edff Mon Sep 17 00:00:00 2001
From: musistudio
Date: Sun, 28 Jun 2026 21:45:45 +0800
Subject: [PATCH 001/101] Remove CDN workflow and add MIT license info
---
.github/workflows/cdn.yml | 33 ---------------------------------
LICENSE | 21 +++++++++++++++++++++
README.md | 5 +++++
README_zh.md | 5 +++++
cdn/package.json | 1 +
docs/package-lock.json | 1 +
docs/package.json | 1 +
package-lock.json | 1 +
package.json | 1 +
9 files changed, 36 insertions(+), 33 deletions(-)
delete mode 100644 .github/workflows/cdn.yml
create mode 100644 LICENSE
diff --git a/.github/workflows/cdn.yml b/.github/workflows/cdn.yml
deleted file mode 100644
index 6223aa6..0000000
--- a/.github/workflows/cdn.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: CDN
-
-on:
- push:
- branches:
- - main
- paths:
- - "cdn/**"
- - ".github/workflows/cdn.yml"
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: cdn
- cancel-in-progress: false
-
-jobs:
- deploy:
- name: Deploy Cloudflare Pages
- runs-on: ubuntu-latest
- steps:
- - name: Check out repository
- uses: actions/checkout@v6
-
- - name: Deploy CDN assets
- uses: cloudflare/wrangler-action@v3
- with:
- apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- workingDirectory: cdn
- command: pages deploy public --project-name=claude-code-router-cdn --branch=${{ github.ref_name }}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ea7e8ac
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 musistudio
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 1377e08..e53da3b 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
+
@@ -281,3 +282,7 @@ A huge thank you to all our sponsors for their generous support.
- @x\*g
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).
diff --git a/README_zh.md b/README_zh.md
index dc81f80..dfe6b3a 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -4,6 +4,7 @@
+
@@ -281,3 +282,7 @@ macOS 打包 hook 会在产物生成前验证代码签名、公证票据 staplin
- @x\*g
(如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。)
+
+## 许可证
+
+本项目基于 [MIT License](LICENSE) 发布。
diff --git a/cdn/package.json b/cdn/package.json
index 3968876..f49b77a 100644
--- a/cdn/package.json
+++ b/cdn/package.json
@@ -2,6 +2,7 @@
"name": "claude-code-router-cdn",
"private": true,
"version": "0.1.0",
+ "license": "MIT",
"type": "module",
"scripts": {
"deploy": "wrangler pages deploy public --project-name=claude-code-router-cdn",
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 02de48c..92adc81 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "claude-code-router-docs",
"version": "0.1.0",
+ "license": "MIT",
"devDependencies": {
"astro": "7.0.0",
"lucide-astro": "^0.556.0"
diff --git a/docs/package.json b/docs/package.json
index b2046bf..0f0aa87 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -2,6 +2,7 @@
"name": "claude-code-router-docs",
"private": true,
"version": "0.1.0",
+ "license": "MIT",
"type": "module",
"scripts": {
"dev": "astro dev",
diff --git a/package-lock.json b/package-lock.json
index 036ccac..a741305 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "claude-code-router",
"version": "3.0.2",
+ "license": "MIT",
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.1",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
diff --git a/package.json b/package.json
index 6666ca5..1a070ff 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
"name": "claude-code-router",
"private": true,
"version": "3.0.2",
+ "license": "MIT",
"description": "Desktop scaffold for Claude Code Router.",
"main": "dist/main/main.js",
"bin": {
From c899b51b04df560dce7c9852eeece71374f6584d Mon Sep 17 00:00:00 2001
From: musi
Date: Mon, 29 Jun 2026 08:44:27 +0800
Subject: [PATCH 002/101] Add remote sync to Claude wrapper and provider IDs
---
src/main/codex-cli-middleware-runtime.ts | 218 ++++++-
src/main/config.ts | 45 +-
src/main/profile-service.ts | 25 +-
src/main/request-log-store.ts | 2 +
src/main/usage-store.ts | 2 +
src/renderer/pages/home/shared/providers.ts | 2 +-
src/server/gateway/remote-control-service.ts | 627 +++++++++++++++++++
src/server/gateway/service.ts | 134 +++-
src/shared/app.ts | 1 +
9 files changed, 1023 insertions(+), 33 deletions(-)
create mode 100644 src/server/gateway/remote-control-service.ts
diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts
index 6791ffb..fcd0a11 100644
--- a/src/main/codex-cli-middleware-runtime.ts
+++ b/src/main/codex-cli-middleware-runtime.ts
@@ -58,12 +58,34 @@ async function main() {
async function runClaudeCodeCliWrapper(args) {
const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude");
log("claude_code_wrapper_start", { realCli, args });
+ const remoteSync = createRemoteSyncClient({
+ args,
+ cwd: process.cwd(),
+ mode: "claude-cli",
+ title: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") || "Claude Code"
+ });
+ const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN");
const child = childProcess.spawn(realCli, args, {
env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
- stdio: ["inherit", "pipe", "inherit"]
+ stdio: [injectRemoteStdin ? "pipe" : "inherit", "pipe", "inherit"]
+ });
+ if (injectRemoteStdin && child.stdin) {
+ process.stdin.pipe(child.stdin);
+ }
+ remoteSync.start((event) => {
+ const text = remoteEventText(event);
+ if (!text) return;
+ if (injectRemoteStdin && child.stdin && !child.killed) {
+ child.stdin.write(text + "\n");
+ return;
+ }
+ if (boolEnv("CCR_REMOTE_SYNC_NOTIFY_INBOUND") || !process.env.CCR_REMOTE_SYNC_NOTIFY_INBOUND) {
+ process.stdout.write("\n[CCR remote] " + text + "\n");
+ }
});
child.on("error", (error) => {
log("claude_code_wrapper_spawn_error", { error: formatError(error) });
+ remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" });
});
let pending = "";
child.stdout.on("data", (chunk) => {
@@ -73,12 +95,16 @@ async function runClaudeCodeCliWrapper(args) {
pending = lines.pop() || "";
for (const line of lines) {
botBridge().handleClaudeCliLine(line);
+ remoteSync.postEvent("claude.stdout", { line }, { text: line });
}
});
const code = await waitForChild(child);
if (pending.trim()) {
botBridge().handleClaudeCliLine(pending);
+ remoteSync.postEvent("claude.stdout", { line: pending }, { text: pending });
}
+ await remoteSync.postEvent("claude.exit", { code }, { direction: "system" });
+ remoteSync.stop();
log("claude_code_wrapper_exit", { code });
process.exitCode = code;
}
@@ -2365,6 +2391,196 @@ function writeLine(stream, value) {
stream.write(JSON.stringify(value) + "\n");
}
+function createRemoteSyncClient(options) {
+ const endpoint = normalizeRemoteSyncEndpoint(nonEmptyEnv("CCR_REMOTE_SYNC_ENDPOINT"));
+ const enabled = endpoint && !["0", "false", "no", "off"].includes(String(process.env.CCR_REMOTE_SYNC_ENABLED || "1").trim().toLowerCase());
+ if (!enabled || typeof fetch !== "function") {
+ return {
+ postEvent: async () => {},
+ start() {},
+ stop() {}
+ };
+ }
+ return new RemoteSyncClient({
+ args: options.args || [],
+ cwd: options.cwd || process.cwd(),
+ endpoint,
+ mode: options.mode || "agent",
+ title: options.title || "CCR Remote",
+ profileId: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_ID"),
+ profileName: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME")
+ });
+}
+
+class RemoteSyncClient {
+ constructor(options) {
+ this.options = options;
+ this.active = false;
+ this.apiKey = "";
+ this.lastInboundSeq = 0;
+ this.pollTimer = null;
+ this.ready = null;
+ this.seenInbound = new Set();
+ this.sessionId = nonEmptyEnv("CCR_REMOTE_SYNC_SESSION_ID") || "ccr-" + safePathSegment(options.profileId || options.profileName || options.mode) + "-" + uuid();
+ }
+
+ start(onInbound) {
+ if (this.active) return;
+ this.active = true;
+ this.ready = this.open().catch((error) => {
+ this.active = false;
+ log("remote_sync_start_failed", { error: formatError(error) });
+ });
+ this.ready.then(() => {
+ if (this.active) this.pollInbound(onInbound);
+ }).catch(() => {});
+ }
+
+ stop() {
+ this.active = false;
+ if (this.pollTimer) {
+ clearTimeout(this.pollTimer);
+ this.pollTimer = null;
+ }
+ }
+
+ async open() {
+ this.apiKey = await readRemoteSyncApiKey();
+ const response = await this.request("POST", "/sessions", {
+ id: this.sessionId,
+ title: this.options.title,
+ metadata: {
+ args: this.options.args,
+ cwd: this.options.cwd,
+ mode: this.options.mode,
+ pid: process.pid,
+ profileId: this.options.profileId,
+ profileName: this.options.profileName,
+ startedAt: new Date().toISOString()
+ }
+ });
+ const session = response && response.session;
+ if (session && session.id) this.sessionId = session.id;
+ log("remote_sync_started", { sessionId: this.sessionId, endpoint: this.options.endpoint });
+ }
+
+ postEvent(type, payload, options) {
+ if (!this.active) return Promise.resolve();
+ const eventOptions = options || {};
+ return Promise.resolve(this.ready)
+ .then(() => {
+ if (!this.active || !this.sessionId) return undefined;
+ return this.request("POST", "/sessions/" + encodeURIComponent(this.sessionId) + "/events", {
+ direction: eventOptions.direction || "local",
+ payload: payload || {},
+ source: "ccr-claude-wrapper",
+ text: eventOptions.text,
+ type
+ });
+ })
+ .catch((error) => {
+ log("remote_sync_event_failed", { type, error: formatError(error) });
+ });
+ }
+
+ pollInbound(onInbound) {
+ if (!this.active) return;
+ this.request("GET", "/sessions/" + encodeURIComponent(this.sessionId) + "/inbound?after=" + this.lastInboundSeq)
+ .then((response) => {
+ const events = Array.isArray(response && response.events) ? response.events : [];
+ for (const event of events) {
+ if (!event || !Number.isFinite(event.seq)) continue;
+ this.lastInboundSeq = Math.max(this.lastInboundSeq, event.seq);
+ const key = event.id || event.dedupeKey || String(event.seq);
+ if (this.seenInbound.has(key)) continue;
+ this.seenInbound.add(key);
+ while (this.seenInbound.size > 500) {
+ const oldest = this.seenInbound.values().next().value;
+ if (!oldest) break;
+ this.seenInbound.delete(oldest);
+ }
+ try {
+ onInbound(event);
+ } catch (error) {
+ log("remote_sync_inbound_handler_failed", { error: formatError(error) });
+ }
+ }
+ })
+ .catch((error) => {
+ log("remote_sync_poll_failed", { error: formatError(error) });
+ })
+ .finally(() => {
+ if (!this.active) return;
+ this.pollTimer = setTimeout(() => this.pollInbound(onInbound), numberEnv("CCR_REMOTE_SYNC_POLL_INTERVAL_MS", 2000));
+ });
+ }
+
+ async request(method, suffix, body) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), numberEnv("CCR_REMOTE_SYNC_REQUEST_TIMEOUT_MS", 5000));
+ const headers = { "accept": "application/json" };
+ if (body !== undefined) headers["content-type"] = "application/json";
+ if (this.apiKey) headers.authorization = "Bearer " + this.apiKey;
+ try {
+ const response = await fetch(remoteSyncUrl(this.options.endpoint, suffix), {
+ method,
+ headers,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: controller.signal
+ });
+ if (!response.ok) {
+ throw new Error("HTTP " + response.status + " from CCR remote sync");
+ }
+ return await response.json();
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+}
+
+async function readRemoteSyncApiKey() {
+ const direct = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY");
+ if (direct) return direct;
+ const helper = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY_HELPER");
+ if (!helper) return "";
+ return new Promise((resolve) => {
+ childProcess.execFile(expandHome(helper), {
+ shell: process.platform === "win32",
+ timeout: 3000,
+ windowsHide: true
+ }, (error, stdout) => {
+ if (error) {
+ log("remote_sync_api_key_helper_failed", { error: formatError(error) });
+ resolve("");
+ return;
+ }
+ resolve(String(stdout || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "");
+ });
+ });
+}
+
+function normalizeRemoteSyncEndpoint(value) {
+ const trimmed = String(value || "").trim().replace(/\/+$/, "");
+ return trimmed || "";
+}
+
+function remoteSyncUrl(endpoint, suffix) {
+ return endpoint + (String(suffix || "").startsWith("/") ? suffix : "/" + suffix);
+}
+
+function remoteEventText(event) {
+ if (!event || typeof event !== "object") return "";
+ if (typeof event.text === "string" && event.text.trim()) return event.text.trim();
+ const payload = event.payload;
+ if (typeof payload === "string" && payload.trim()) return payload.trim();
+ if (payload && typeof payload === "object") {
+ if (typeof payload.text === "string" && payload.text.trim()) return payload.text.trim();
+ if (typeof payload.content === "string" && payload.content.trim()) return payload.content.trim();
+ if (typeof payload.message === "string" && payload.message.trim()) return payload.message.trim();
+ }
+ return "";
+}
+
function createBotGatewayBridge() {
const config = readBotGatewayBridgeConfig();
if (!config.enabled) {
diff --git a/src/main/config.ts b/src/main/config.ts
index 8fd494e..8f7f424 100644
--- a/src/main/config.ts
+++ b/src/main/config.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config-store";
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
@@ -623,6 +624,7 @@ function sanitizeConfigForDisk(config: AppConfig): AppConfig {
...config.gateway,
coreHost: INTERNAL_GATEWAY_CORE_HOST
},
+ Providers: withProviderIds(config.Providers),
profile: sanitizeProfileConfigForDisk(config.profile)
};
}
@@ -1100,6 +1102,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
extraBody: item.extraBody,
extraHeaders: item.extraHeaders,
icon: readString(item.icon),
+ id: readString(item.id),
models,
name,
provider: readString(item.provider),
@@ -1110,7 +1113,47 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
})
.filter((item): item is GatewayProviderConfig => Boolean(item));
- return providers;
+ return withProviderIds(providers);
+}
+
+function withProviderIds(providers: GatewayProviderConfig[]): GatewayProviderConfig[] {
+ const counts = new Map();
+ return providers.map((provider) => {
+ const baseId = providerRuntimeIdCandidate(provider);
+ const nextCount = (counts.get(baseId) ?? 0) + 1;
+ counts.set(baseId, nextCount);
+ return {
+ ...provider,
+ id: nextCount === 1 ? baseId : `${baseId}-${nextCount}`
+ };
+ });
+}
+
+function providerRuntimeIdCandidate(provider: GatewayProviderConfig): string {
+ const explicit = sanitizeProviderId(provider.id);
+ if (explicit) {
+ return explicit;
+ }
+ const slug = sanitizeProviderId(provider.name) || "provider";
+ const source = [
+ provider.name,
+ provider.provider ?? "",
+ providerBaseUrl(provider),
+ provider.type ?? ""
+ ].join("\n");
+ const hash = createHash("sha256").update(source).digest("hex").slice(0, 10);
+ return `provider-${slug.slice(0, 48)}-${hash}`;
+}
+
+function sanitizeProviderId(value: string | undefined): string | undefined {
+ const normalized = value
+ ?.normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9_.-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 72);
+ return normalized || undefined;
}
function parseProviderCredentials(value: unknown): ProviderCredentialConfig[] | undefined {
diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts
index 68b5888..f0df909 100644
--- a/src/main/profile-service.ts
+++ b/src/main/profile-service.ts
@@ -74,7 +74,7 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
}
const helperResult = writeClaudeCodeApiKeyHelper(profile, token);
- const wrapperResult = writeClaudeCodeWrapper(config, profile);
+ const wrapperResult = writeClaudeCodeWrapper(config, profile, helperResult.file);
const nextSettings = {
...settings,
apiKeyHelper: process.platform === "win32" ? `"${helperResult.file}"` : helperResult.file,
@@ -459,7 +459,7 @@ function claudeCodeApiKeyHelperCmdScript(token: string): string {
].join("\r\n");
}
-function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig): { backupFile?: string; changed: boolean; file: string } {
+function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig, apiKeyHelperFile: string): { backupFile?: string; changed: boolean; file: string } {
const binDir = path.join(CONFIGDIR, "bin");
mkdirSync(binDir, { mode: privateDirMode, recursive: true });
const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename());
@@ -469,8 +469,8 @@ function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig): { ba
}
const file = path.join(binDir, claudeCodeWrapperFilename(profile));
const content = process.platform === "win32"
- ? claudeCodeWrapperCmdScript(config, profile, runtimeFile)
- : claudeCodeWrapperShellScript(config, profile, runtimeFile);
+ ? claudeCodeWrapperCmdScript(config, profile, runtimeFile, apiKeyHelperFile)
+ : claudeCodeWrapperShellScript(config, profile, runtimeFile, apiKeyHelperFile);
const writeResult = writeFileWithBackup(file, content, { mode: privateExecutableMode });
if (process.platform !== "win32") {
chmodSync(file, privateExecutableMode);
@@ -489,9 +489,10 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string {
: `ccr-claude-code-wrapper-${slug}`;
}
-function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string {
+function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string): string {
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const surface = normalizeProfileSurface(profile.surface);
+ const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
@@ -505,14 +506,21 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
`export CCR_CLAUDE_CODE_WRAPPER=1`,
`export CCR_REAL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`,
`export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`,
+ `if [ -z "\${CCR_REMOTE_SYNC_ENABLED:-}" ]; then CCR_REMOTE_SYNC_ENABLED=1; fi`,
+ `if [ -z "\${CCR_REMOTE_SYNC_ENDPOINT:-}" ]; then CCR_REMOTE_SYNC_ENDPOINT=${shellQuote(remoteEndpoint)}; fi`,
+ `if [ -z "\${CCR_REMOTE_SYNC_API_KEY_HELPER:-}" ]; then CCR_REMOTE_SYNC_API_KEY_HELPER=${shellQuote(apiKeyHelperFile)}; fi`,
+ `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_ID:-}" ]; then CCR_REMOTE_SYNC_PROFILE_ID=${shellQuote(profile.id || profile.name || "claude-code")}; fi`,
+ `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_NAME:-}" ]; then CCR_REMOTE_SYNC_PROFILE_NAME=${shellQuote(profile.name || profile.id || "Claude Code")}; fi`,
+ "export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_HELPER CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME",
...nodeRuntimeShellExecLines(runtimeFile),
""
].join("\n");
}
-function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string {
+function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string): string {
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const surface = normalizeProfileSurface(profile.surface);
+ const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`;
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => cmdSetLine(key, value));
@@ -525,6 +533,11 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r
cmdSetLine("CCR_CLAUDE_CODE_WRAPPER", "1"),
cmdSetLine("CCR_REAL_CLAUDE_CODE_BIN", realClaude),
cmdSetLine("CODEXL_CLAUDE_CODE_BIN", realClaude),
+ `if not defined CCR_REMOTE_SYNC_ENABLED ${cmdSetLine("CCR_REMOTE_SYNC_ENABLED", "1")}`,
+ `if not defined CCR_REMOTE_SYNC_ENDPOINT ${cmdSetLine("CCR_REMOTE_SYNC_ENDPOINT", remoteEndpoint)}`,
+ `if not defined CCR_REMOTE_SYNC_API_KEY_HELPER ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_HELPER", apiKeyHelperFile)}`,
+ `if not defined CCR_REMOTE_SYNC_PROFILE_ID ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_ID", profile.id || profile.name || "claude-code")}`,
+ `if not defined CCR_REMOTE_SYNC_PROFILE_NAME ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_NAME", profile.name || profile.id || "Claude Code")}`,
...nodeRuntimeCmdExecLines(runtimeFile),
""
].join("\r\n");
diff --git a/src/main/request-log-store.ts b/src/main/request-log-store.ts
index b791dd8..1ebfea5 100644
--- a/src/main/request-log-store.ts
+++ b/src/main/request-log-store.ts
@@ -71,6 +71,7 @@ type RequestLogRecordInput = {
fallbackModel?: string;
method: string;
path: string;
+ providerName?: string;
providerProtocol?: GatewayProviderProtocol;
requestBody: Buffer;
requestHeaders: HeaderRecord;
@@ -250,6 +251,7 @@ class RequestLogStore {
const route = splitRouteSelector(input.fallbackModel);
const requestModel = extractModelFromBody(input.requestBody.toString("utf8"));
const provider =
+ normalizeFilterValue(input.providerName) ??
readResponseHeader(input.responseHeaders, "x-gateway-target-provider-name") ??
readResponseHeader(input.responseHeaders, "x-gateway-target-provider") ??
route.provider;
diff --git a/src/main/usage-store.ts b/src/main/usage-store.ts
index da17971..351d8c5 100644
--- a/src/main/usage-store.ts
+++ b/src/main/usage-store.ts
@@ -48,6 +48,7 @@ type UsageCaptureInput = {
fallbackModel?: string;
method: string;
path: string;
+ providerName?: string;
providerProtocol?: GatewayProviderProtocol;
requestId?: string;
responseHeaders: Headers;
@@ -316,6 +317,7 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi
});
const route = splitRouteSelector(input.fallbackModel);
const provider =
+ input.providerName ??
readHeader(input.responseHeaders, "x-gateway-target-provider-name") ??
readHeader(input.responseHeaders, "x-gateway-target-provider") ??
route.provider;
diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts
index 5fe0451..4694198 100644
--- a/src/renderer/pages/home/shared/providers.ts
+++ b/src/renderer/pages/home/shared/providers.ts
@@ -1866,7 +1866,7 @@ export function providerCapabilitiesSummary(provider: GatewayProviderConfig, tra
}
export function providerListItemKey(provider: GatewayProviderConfig, index: number): string {
- return `${index}:${provider.name || "provider"}`;
+ return provider.id || `${index}:${provider.name || "provider"}`;
}
export function providerMatchesQuery(provider: GatewayProviderConfig, query: string): boolean {
diff --git a/src/server/gateway/remote-control-service.ts b/src/server/gateway/remote-control-service.ts
new file mode 100644
index 0000000..b3a7e70
--- /dev/null
+++ b/src/server/gateway/remote-control-service.ts
@@ -0,0 +1,627 @@
+import { randomUUID } from "node:crypto";
+import type { IncomingMessage, ServerResponse } from "node:http";
+
+export const ccrRemoteControlPathPrefix = "/__ccr/remote";
+
+type RemoteDirection = "inbound" | "local" | "remote" | "system";
+
+export type CcrRemoteControlRequestContext = {
+ endpoint: string;
+ path: string;
+ readBody: (request: IncomingMessage) => Promise;
+ request: IncomingMessage;
+ response: ServerResponse;
+ sendJson: (response: ServerResponse, statusCode: number, payload: unknown) => void;
+};
+
+type RemoteSession = {
+ archivedAt?: string;
+ createdAt: string;
+ events: RemoteEvent[];
+ id: string;
+ inboundEvents: RemoteEvent[];
+ lastSeq: number;
+ metadata: Record;
+ presence: Record;
+ seenDedupeKeys: Map;
+ subscribers: Set;
+ title: string;
+ updatedAt: string;
+};
+
+type RemoteEvent = {
+ createdAt: string;
+ dedupeKey?: string;
+ direction: RemoteDirection;
+ id: string;
+ payload: unknown;
+ role?: string;
+ seq: number;
+ sessionId: string;
+ source?: string;
+ text?: string;
+ type: string;
+};
+
+type RemotePresence = {
+ lastSeenAt: string;
+ metadata: Record;
+ name: string;
+ role: string;
+};
+
+type RemoteSubscriber = {
+ close: () => void;
+ id: string;
+ kind: "events" | "inbound";
+ response: ServerResponse;
+};
+
+const maxSessions = 100;
+const maxEventsPerSession = 2_000;
+const maxInboundEventsPerSession = 500;
+const sseHeartbeatMs = 15_000;
+
+class CcrRemoteControlService {
+ private readonly sessions = new Map();
+
+ async handleRequest(context: CcrRemoteControlRequestContext): Promise {
+ const segments = remotePathSegments(context.path);
+ const [root, sessionId, resource] = segments;
+
+ if (segments.length === 0 || context.path === ccrRemoteControlPathPrefix) {
+ this.sendCapabilities(context);
+ return;
+ }
+
+ if (root === "capabilities") {
+ this.sendCapabilities(context);
+ return;
+ }
+
+ if (root !== "sessions") {
+ context.sendJson(context.response, 404, { error: { message: "Remote control endpoint not found." } });
+ return;
+ }
+
+ if (!sessionId) {
+ await this.handleSessionsRequest(context);
+ return;
+ }
+
+ if (!resource) {
+ await this.handleSessionRequest(context, sessionId);
+ return;
+ }
+
+ if (resource === "events") {
+ await this.handleEventsRequest(context, sessionId);
+ return;
+ }
+
+ if (resource === "inbound") {
+ await this.handleInboundRequest(context, sessionId);
+ return;
+ }
+
+ if (resource === "presence") {
+ await this.handlePresenceRequest(context, sessionId);
+ return;
+ }
+
+ context.sendJson(context.response, 404, { error: { message: "Remote control session endpoint not found." } });
+ }
+
+ private sendCapabilities(context: CcrRemoteControlRequestContext): void {
+ context.sendJson(context.response, 200, {
+ endpoints: {
+ createSession: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions`,
+ inbound: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions/{sessionId}/inbound`,
+ sessionEvents: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions/{sessionId}/events`
+ },
+ name: "ccr-remote-control",
+ protocol: "ccr.remote.v1",
+ transport: ["json", "sse"],
+ capabilities: {
+ catchupReplay: true,
+ fanout: true,
+ inboundQueue: true,
+ presence: true
+ }
+ });
+ }
+
+ private async handleSessionsRequest(context: CcrRemoteControlRequestContext): Promise {
+ if (context.request.method === "GET") {
+ context.sendJson(context.response, 200, {
+ sessions: [...this.sessions.values()].map((session) => this.sessionSummary(session, context.endpoint))
+ });
+ return;
+ }
+
+ if (context.request.method !== "POST") {
+ context.sendJson(context.response, 405, { error: { message: "Method not allowed." } });
+ return;
+ }
+
+ const body = await this.readJsonBody(context);
+ if (!body) {
+ return;
+ }
+ const id = sanitizeSessionId(readString(body.id) || readString(body.sessionId)) || randomUUID();
+ const title = readString(body.title) || readString(body.name) || `CCR Remote ${id.slice(0, 8)}`;
+ const metadata = readRecord(body.metadata) ?? {};
+ const session = this.ensureSession(id, title, metadata);
+ this.appendEvent(session, {
+ direction: "system",
+ payload: { title },
+ source: "ccr-gateway",
+ type: "session.created"
+ });
+
+ context.sendJson(context.response, 201, {
+ session: this.sessionSnapshot(session, context.endpoint)
+ });
+ }
+
+ private async handleSessionRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise {
+ const session = this.sessions.get(sessionId);
+ if (!session) {
+ context.sendJson(context.response, 404, { error: { message: "Remote session not found." } });
+ return;
+ }
+
+ if (context.request.method === "GET") {
+ context.sendJson(context.response, 200, { session: this.sessionSnapshot(session, context.endpoint) });
+ return;
+ }
+
+ if (context.request.method === "PATCH") {
+ const body = await this.readJsonBody(context);
+ if (!body) {
+ return;
+ }
+ const title = readString(body.title) || readString(body.name);
+ if (title) {
+ session.title = title;
+ }
+ const metadata = readRecord(body.metadata);
+ if (metadata) {
+ session.metadata = { ...session.metadata, ...metadata };
+ }
+ session.updatedAt = new Date().toISOString();
+ this.appendEvent(session, {
+ direction: "system",
+ payload: { metadata: metadata ?? {}, title: title ?? session.title },
+ source: "ccr-gateway",
+ type: "session.updated"
+ });
+ context.sendJson(context.response, 200, { session: this.sessionSnapshot(session, context.endpoint) });
+ return;
+ }
+
+ if (context.request.method === "DELETE") {
+ session.archivedAt = new Date().toISOString();
+ session.updatedAt = session.archivedAt;
+ this.appendEvent(session, {
+ direction: "system",
+ payload: { archivedAt: session.archivedAt },
+ source: "ccr-gateway",
+ type: "session.archived"
+ });
+ context.sendJson(context.response, 200, { archived: true, session: this.sessionSummary(session, context.endpoint) });
+ return;
+ }
+
+ context.sendJson(context.response, 405, { error: { message: "Method not allowed." } });
+ }
+
+ private async handleEventsRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise {
+ const session = this.sessions.get(sessionId);
+ if (!session) {
+ context.sendJson(context.response, 404, { error: { message: "Remote session not found." } });
+ return;
+ }
+
+ if (context.request.method === "GET") {
+ const after = remoteAfterSeq(context.request);
+ if (wantsSse(context.request)) {
+ this.openSse(context, session, "events", after);
+ return;
+ }
+ context.sendJson(context.response, 200, {
+ events: session.events.filter((event) => event.seq > after),
+ session: this.sessionSummary(session, context.endpoint)
+ });
+ return;
+ }
+
+ if (context.request.method !== "POST") {
+ context.sendJson(context.response, 405, { error: { message: "Method not allowed." } });
+ return;
+ }
+
+ const body = await this.readJsonBody(context);
+ if (!body) {
+ return;
+ }
+ const events = normalizeEventInputs(body).map((event) =>
+ this.appendEvent(session, {
+ ...event,
+ direction: event.direction ?? "local",
+ type: event.type || "message"
+ })
+ );
+ context.sendJson(context.response, 202, {
+ accepted: events.length,
+ events,
+ session: this.sessionSummary(session, context.endpoint)
+ });
+ }
+
+ private async handleInboundRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise {
+ const session = this.sessions.get(sessionId);
+ if (!session) {
+ context.sendJson(context.response, 404, { error: { message: "Remote session not found." } });
+ return;
+ }
+
+ if (context.request.method === "GET") {
+ const after = remoteAfterSeq(context.request);
+ if (wantsSse(context.request)) {
+ this.openSse(context, session, "inbound", after);
+ return;
+ }
+ context.sendJson(context.response, 200, {
+ events: session.inboundEvents.filter((event) => event.seq > after),
+ session: this.sessionSummary(session, context.endpoint)
+ });
+ return;
+ }
+
+ if (context.request.method !== "POST") {
+ context.sendJson(context.response, 405, { error: { message: "Method not allowed." } });
+ return;
+ }
+
+ const body = await this.readJsonBody(context);
+ if (!body) {
+ return;
+ }
+ const events = normalizeEventInputs(body).map((event) =>
+ this.appendEvent(session, {
+ ...event,
+ direction: "remote",
+ type: event.type || "user.message"
+ }, true)
+ );
+ context.sendJson(context.response, 202, {
+ accepted: events.length,
+ events,
+ session: this.sessionSummary(session, context.endpoint)
+ });
+ }
+
+ private async handlePresenceRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise {
+ const session = this.sessions.get(sessionId);
+ if (!session) {
+ context.sendJson(context.response, 404, { error: { message: "Remote session not found." } });
+ return;
+ }
+
+ if (context.request.method === "GET") {
+ context.sendJson(context.response, 200, { presence: session.presence });
+ return;
+ }
+
+ if (context.request.method !== "POST") {
+ context.sendJson(context.response, 405, { error: { message: "Method not allowed." } });
+ return;
+ }
+
+ const body = await this.readJsonBody(context);
+ if (!body) {
+ return;
+ }
+ const clientId = sanitizeSessionId(readString(body.clientId) || readString(body.id)) || randomUUID();
+ const presence: RemotePresence = {
+ lastSeenAt: new Date().toISOString(),
+ metadata: readRecord(body.metadata) ?? {},
+ name: readString(body.name) || clientId,
+ role: readString(body.role) || "client"
+ };
+ session.presence[clientId] = presence;
+ const event = this.appendEvent(session, {
+ direction: "system",
+ payload: { clientId, presence },
+ source: "ccr-gateway",
+ type: "presence.updated"
+ });
+ context.sendJson(context.response, 202, { event, presence: session.presence });
+ }
+
+ private ensureSession(id: string, title: string, metadata: Record): RemoteSession {
+ const existing = this.sessions.get(id);
+ if (existing) {
+ existing.metadata = { ...existing.metadata, ...metadata };
+ existing.title = title || existing.title;
+ existing.updatedAt = new Date().toISOString();
+ return existing;
+ }
+
+ this.pruneSessions();
+ const now = new Date().toISOString();
+ const session: RemoteSession = {
+ createdAt: now,
+ events: [],
+ id,
+ inboundEvents: [],
+ lastSeq: 0,
+ metadata,
+ presence: {},
+ seenDedupeKeys: new Map(),
+ subscribers: new Set(),
+ title,
+ updatedAt: now
+ };
+ this.sessions.set(id, session);
+ return session;
+ }
+
+ private appendEvent(
+ session: RemoteSession,
+ input: RemoteEventInput,
+ inbound = false
+ ): RemoteEvent {
+ const dedupeKey = input.dedupeKey || input.id;
+ if (dedupeKey) {
+ const duplicate = session.seenDedupeKeys.get(dedupeKey);
+ if (duplicate) {
+ return duplicate;
+ }
+ }
+
+ const event: RemoteEvent = {
+ createdAt: new Date().toISOString(),
+ ...(dedupeKey ? { dedupeKey } : {}),
+ direction: input.direction ?? "local",
+ id: input.id || randomUUID(),
+ payload: input.payload ?? {},
+ ...(input.role ? { role: input.role } : {}),
+ seq: ++session.lastSeq,
+ sessionId: session.id,
+ ...(input.source ? { source: input.source } : {}),
+ ...(input.text ? { text: input.text } : {}),
+ type: input.type || "message"
+ };
+
+ session.events.push(event);
+ trimArray(session.events, maxEventsPerSession);
+ if (inbound || event.direction === "remote" || event.direction === "inbound") {
+ session.inboundEvents.push(event);
+ trimArray(session.inboundEvents, maxInboundEventsPerSession);
+ }
+ if (dedupeKey) {
+ session.seenDedupeKeys.set(dedupeKey, event);
+ while (session.seenDedupeKeys.size > maxEventsPerSession) {
+ const oldest = session.seenDedupeKeys.keys().next().value;
+ if (!oldest) {
+ break;
+ }
+ session.seenDedupeKeys.delete(oldest);
+ }
+ }
+ session.updatedAt = event.createdAt;
+ this.broadcast(session, event);
+ return event;
+ }
+
+ private openSse(
+ context: CcrRemoteControlRequestContext,
+ session: RemoteSession,
+ kind: RemoteSubscriber["kind"],
+ after: number
+ ): void {
+ context.response.writeHead(200, {
+ "cache-control": "no-cache, no-transform",
+ "connection": "keep-alive",
+ "content-type": "text/event-stream; charset=utf-8",
+ "x-accel-buffering": "no"
+ });
+ context.response.write(": connected\n\n");
+ const source = kind === "events" ? session.events : session.inboundEvents;
+ for (const event of source.filter((item) => item.seq > after)) {
+ writeSseEvent(context.response, event);
+ }
+
+ const heartbeat = setInterval(() => {
+ if (!context.response.destroyed) {
+ context.response.write(": keepalive\n\n");
+ }
+ }, sseHeartbeatMs);
+ const subscriber: RemoteSubscriber = {
+ close: () => clearInterval(heartbeat),
+ id: randomUUID(),
+ kind,
+ response: context.response
+ };
+ session.subscribers.add(subscriber);
+ context.request.once("close", () => {
+ subscriber.close();
+ session.subscribers.delete(subscriber);
+ });
+ }
+
+ private broadcast(session: RemoteSession, event: RemoteEvent): void {
+ for (const subscriber of session.subscribers) {
+ if (subscriber.kind === "inbound" && !(event.direction === "remote" || event.direction === "inbound")) {
+ continue;
+ }
+ if (subscriber.response.destroyed) {
+ subscriber.close();
+ session.subscribers.delete(subscriber);
+ continue;
+ }
+ writeSseEvent(subscriber.response, event);
+ }
+ }
+
+ private sessionSnapshot(session: RemoteSession, endpoint: string) {
+ return {
+ ...this.sessionSummary(session, endpoint),
+ events: session.events,
+ inboundEvents: session.inboundEvents,
+ metadata: session.metadata,
+ presence: session.presence
+ };
+ }
+
+ private sessionSummary(session: RemoteSession, endpoint: string) {
+ return {
+ ...(session.archivedAt ? { archivedAt: session.archivedAt } : {}),
+ createdAt: session.createdAt,
+ endpoints: {
+ events: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/events`,
+ inbound: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/inbound`,
+ presence: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/presence`
+ },
+ eventCount: session.events.length,
+ id: session.id,
+ inboundCount: session.inboundEvents.length,
+ lastSeq: session.lastSeq,
+ subscriberCount: session.subscribers.size,
+ title: session.title,
+ updatedAt: session.updatedAt
+ };
+ }
+
+ private pruneSessions(): void {
+ while (this.sessions.size >= maxSessions) {
+ const oldest = [...this.sessions.values()]
+ .sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt))[0];
+ if (!oldest) {
+ return;
+ }
+ for (const subscriber of oldest.subscribers) {
+ subscriber.close();
+ subscriber.response.end();
+ }
+ this.sessions.delete(oldest.id);
+ }
+ }
+
+ private async readJsonBody(context: CcrRemoteControlRequestContext): Promise | undefined> {
+ const body = await context.readBody(context.request);
+ if (body.length === 0) {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(body.toString("utf8")) as unknown;
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
+ return parsed as Record;
+ }
+ context.sendJson(context.response, 400, { error: { message: "Request body must be a JSON object." } });
+ return undefined;
+ } catch {
+ context.sendJson(context.response, 400, { error: { message: "Request body must be valid JSON." } });
+ return undefined;
+ }
+ }
+}
+
+type RemoteEventInput = {
+ dedupeKey?: string;
+ direction?: RemoteDirection;
+ id?: string;
+ payload?: unknown;
+ role?: string;
+ source?: string;
+ text?: string;
+ type?: string;
+};
+
+export const ccrRemoteControlService = new CcrRemoteControlService();
+
+function normalizeEventInputs(body: Record): RemoteEventInput[] {
+ const rawEvents = Array.isArray(body.events) ? body.events : [body];
+ return rawEvents
+ .filter((event): event is Record => typeof event === "object" && event !== null && !Array.isArray(event))
+ .map((event) => {
+ const payload = Object.prototype.hasOwnProperty.call(event, "payload")
+ ? event.payload
+ : Object.prototype.hasOwnProperty.call(event, "message")
+ ? event.message
+ : {};
+ return {
+ dedupeKey: readString(event.dedupeKey) || readString(event.uuid) || readString(event.requestId),
+ direction: readDirection(event.direction),
+ id: readString(event.id),
+ payload,
+ role: readString(event.role),
+ source: readString(event.source),
+ text: readString(event.text) || readString(event.content),
+ type: readString(event.type)
+ };
+ });
+}
+
+function writeSseEvent(response: ServerResponse, event: RemoteEvent): void {
+ response.write(`id: ${event.seq}\n`);
+ response.write(`event: ${event.type.replace(/[\r\n]+/g, "") || "message"}\n`);
+ response.write(`data: ${JSON.stringify(event)}\n\n`);
+}
+
+function wantsSse(request: IncomingMessage): boolean {
+ const url = new URL(request.url || "/", "http://127.0.0.1");
+ return url.searchParams.get("stream") === "1" ||
+ url.searchParams.get("stream") === "true" ||
+ readHeader(request.headers.accept)?.toLowerCase().includes("text/event-stream") === true;
+}
+
+function remoteAfterSeq(request: IncomingMessage): number {
+ const url = new URL(request.url || "/", "http://127.0.0.1");
+ const after = Number(url.searchParams.get("after") ?? readHeader(request.headers["last-event-id"]) ?? 0);
+ return Number.isFinite(after) && after > 0 ? Math.floor(after) : 0;
+}
+
+function remotePathSegments(path: string): string[] {
+ const suffix = path.slice(ccrRemoteControlPathPrefix.length).replace(/^\/+|\/+$/g, "");
+ if (!suffix) {
+ return [];
+ }
+ return suffix.split("/").map((segment) => decodeURIComponent(segment)).filter(Boolean);
+}
+
+function readDirection(value: unknown): RemoteDirection | undefined {
+ return value === "inbound" || value === "local" || value === "remote" || value === "system"
+ ? value
+ : undefined;
+}
+
+function readHeader(value: string | string[] | undefined): string | undefined {
+ if (Array.isArray(value)) {
+ return value[0]?.trim();
+ }
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function readRecord(value: unknown): Record | undefined {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? value as Record
+ : undefined;
+}
+
+function readString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function sanitizeSessionId(value: string | undefined): string | undefined {
+ const sanitized = value?.trim().replace(/[^a-zA-Z0-9:._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
+ return sanitized || undefined;
+}
+
+function trimArray(items: T[], maxLength: number): void {
+ if (items.length > maxLength) {
+ items.splice(0, items.length - maxLength);
+ }
+}
diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts
index 799226d..71c2122 100644
--- a/src/server/gateway/service.ts
+++ b/src/server/gateway/service.ts
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
-import { randomBytes, randomUUID } from "node:crypto";
+import { createHash, randomBytes, randomUUID } from "node:crypto";
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createRequire } from "node:module";
import { networkInterfaces } from "node:os";
@@ -47,6 +47,7 @@ import { proxyService } from "../proxy/service";
import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "../../main/request-log-store";
import { recordGatewayUsageCapture } from "../../main/usage-store";
import { ClaudeCodeRouterPlugin, normalizeRouteSelector } from "./claude-code-router-plugin";
+import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "./remote-control-service";
import {
claudeCodeEffectiveMaxInputTokens,
findModelCatalogEntry,
@@ -428,6 +429,22 @@ class GatewayService {
return;
}
+ if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) {
+ const authorization = authorize(request, response, this.config);
+ if (!authorization.ok) {
+ return;
+ }
+ await ccrRemoteControlService.handleRequest({
+ endpoint: this.status.endpoint,
+ path,
+ readBody: readRequestBody,
+ request,
+ response,
+ sendJson
+ });
+ return;
+ }
+
if (isNetworkCaptureMcpPath(path)) {
if (!this.config.proxy.captureNetwork) {
sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } });
@@ -559,6 +576,7 @@ class GatewayService {
fallbackModel: routedModel,
method,
path,
+ providerName: resolveProviderLogName(responseHeaders, config, routedModel),
providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config),
requestBody: shouldSendBody(method) ? bodyToForward ?? Buffer.alloc(0) : Buffer.alloc(0),
requestHeaders: headers,
@@ -653,6 +671,7 @@ class GatewayService {
fallbackModel: routedModel,
method,
path,
+ providerName: resolveProviderLogName(new Headers(), this.config, routedModel),
providerProtocol: resolveResponseProviderProtocol(new Headers(), this.config),
requestId,
responseHeaders: new Headers(),
@@ -681,6 +700,7 @@ class GatewayService {
fallbackModel: routedModel,
method,
path,
+ providerName: resolveProviderLogName(responseHeaders, this.config, routedModel),
providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config),
requestId,
responseHeaders,
@@ -732,6 +752,7 @@ class GatewayService {
fallbackModel: routedModel,
method,
path,
+ providerName: resolveProviderLogName(responseHeaders, this.config, routedModel),
providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config),
requestId,
responseHeaders,
@@ -1624,7 +1645,7 @@ function rewriteProviderListHeader(
function rewriteProviderSelectorForProtocol(value: string, config: AppConfig, protocol: GatewayProviderProtocol): string {
const provider = findProviderByPublicOrInternalName(config, value);
const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined;
- return provider && capability ? providerCapabilityInternalName(provider.name, capability.type) : value;
+ return provider && capability ? providerCapabilityInternalName(provider, capability.type) : value;
}
function rewriteFallbackForProtocol(fallback: RouterFallbackConfig, config: AppConfig, protocol: GatewayProviderProtocol): RouterFallbackConfig {
@@ -1669,7 +1690,7 @@ function rewriteModelSelectorForProtocol(
const targetModel = publicModel.slice(separator + 1).trim();
const provider = findProviderByPublicOrInternalName(config, providerName);
const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined;
- return provider && capability ? `${providerCapabilityInternalName(provider.name, capability.type)}/${targetModel}` : publicModel;
+ return provider && capability ? `${providerCapabilityInternalName(provider, capability.type)}/${targetModel}` : publicModel;
}
function providerCapabilityForClientProtocol(
@@ -1733,15 +1754,19 @@ function findProviderByPublicOrInternalName(config: AppConfig, name: string): Ga
}
const credentialInternalName = parseProviderCredentialInternalName(name);
if (credentialInternalName) {
+ const internalProviderId = credentialInternalName.providerId.toLowerCase();
return config.Providers.find((provider) =>
- provider.name.trim().toLowerCase() === credentialInternalName.providerName.toLowerCase()
+ provider.name.trim().toLowerCase() === internalProviderId ||
+ providerRuntimeId(provider).toLowerCase() === internalProviderId
);
}
return config.Providers.find((provider) =>
provider.name.trim().toLowerCase() === normalized ||
+ provider.id?.trim().toLowerCase() === normalized ||
provider.provider?.trim().toLowerCase() === normalized ||
+ providerRuntimeId(provider).toLowerCase() === normalized ||
normalizedProviderCapabilities(provider).some((capability) =>
- providerCapabilityInternalName(provider.name, capability.type).toLowerCase() === normalized
+ providerCapabilityNameMatches(provider, capability.type, normalized)
)
);
}
@@ -1753,27 +1778,27 @@ function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig):
}
const credentialInternalName = parseProviderCredentialInternalName(providerName);
if (credentialInternalName) {
- const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerName);
+ const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerId);
if (!provider) {
return headers;
}
const credential = findProviderCredentialBySlug(provider, credentialInternalName.credentialSlug);
const rewritten = new Headers(headers);
- rewritten.set("x-gateway-target-provider-name", provider.name);
+ rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider));
rewritten.set("x-ccr-provider-protocol", credentialInternalName.protocol);
- rewritten.set("x-ccr-provider-credential-provider", provider.name);
- rewritten.set("x-ccr-provider-credential-id", credential ? providerCredentialRuntimeId(provider, credential) : credentialInternalName.credentialSlug);
+ rewritten.set("x-ccr-provider-credential-provider", providerRuntimeId(provider));
+ rewritten.set("x-ccr-provider-credential-id", providerCredentialSlug(credential ? providerCredentialRuntimeId(provider, credential) : credentialInternalName.credentialSlug));
return rewritten;
}
const provider = findProviderByPublicOrInternalName(config, providerName);
- if (!provider || provider.name === providerName) {
+ if (!provider) {
return headers;
}
const capability = normalizedProviderCapabilities(provider).find((item) =>
- providerCapabilityInternalName(provider.name, item.type).toLowerCase() === providerName.toLowerCase()
+ providerCapabilityNameMatches(provider, item.type, providerName)
);
const rewritten = new Headers(headers);
- rewritten.set("x-gateway-target-provider-name", provider.name);
+ rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider));
if (capability) {
rewritten.set("x-ccr-provider-protocol", capability.type);
}
@@ -2008,7 +2033,7 @@ function selectProviderCredentials(
return {
cooldown,
credential,
- credentialId: providerCredentialRuntimeId(provider, credential, providerIndex),
+ credentialId: providerCredentialSlug(providerCredentialRuntimeId(provider, credential, providerIndex)),
index: providerIndex,
internalName: providerCredentialInternalName(provider, protocol, credential),
limitState,
@@ -2378,8 +2403,8 @@ function toCoreGatewayProvider(
name: credential
? providerCredentialInternalName(provider, type, credential)
: capability
- ? providerCapabilityInternalName(provider.name, type)
- : provider.name,
+ ? providerCapabilityInternalName(provider, type)
+ : providerRuntimeId(provider),
type
};
}
@@ -2415,21 +2440,56 @@ function normalizedProviderCapabilities(provider: GatewayProviderConfig): Gatewa
return normalized;
}
-function providerCapabilityInternalName(providerName: string, protocol: GatewayProviderProtocol): string {
+function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string {
+ return `${providerRuntimeId(provider)}::${protocol}`;
+}
+
+function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderProtocol): string {
return `${providerName}::${protocol}`;
}
+function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol, value: string): boolean {
+ const normalized = value.trim().toLowerCase();
+ return providerCapabilityInternalName(provider, protocol).toLowerCase() === normalized ||
+ providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized;
+}
+
+function providerRuntimeId(provider: GatewayProviderConfig): string {
+ const explicit = sanitizeProviderHeaderId(provider.id);
+ if (explicit) {
+ return explicit;
+ }
+ const normalized = provider.name
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9_.-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 48);
+ const hash = createHash("sha256").update(`${provider.name}\n${readBaseUrl(provider) ?? ""}`).digest("hex").slice(0, 10);
+ return `provider-${normalized || "provider"}-${hash}`;
+}
+
+function sanitizeProviderHeaderId(value: string | undefined): string | undefined {
+ const normalized = value
+ ?.trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_.-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return normalized || undefined;
+}
+
function providerCredentialInternalName(
provider: GatewayProviderConfig,
protocol: GatewayProviderProtocol,
credential: ProviderCredentialConfig
): string {
- return `${providerCapabilityInternalName(provider.name, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`;
+ return `${providerCapabilityInternalName(provider, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`;
}
function parseProviderCredentialInternalName(value: string | undefined): {
credentialSlug: string;
- providerName: string;
+ providerId: string;
protocol: GatewayProviderProtocol;
} | undefined {
const marker = "::cred:";
@@ -2444,8 +2504,8 @@ function parseProviderCredentialInternalName(value: string | undefined): {
return undefined;
}
const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2));
- const providerName = baseName.slice(0, protocolSeparator).trim();
- return protocol && providerName ? { credentialSlug, providerName, protocol } : undefined;
+ const providerId = baseName.slice(0, protocolSeparator).trim();
+ return protocol && providerId ? { credentialSlug, providerId, protocol } : undefined;
}
function providerCredentialSlug(value: string | undefined): string {
@@ -2550,7 +2610,7 @@ function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | u
return normalizeProviderProtocol(providerName);
}
const capability = normalizedProviderCapabilities(provider).find((item) =>
- providerCapabilityInternalName(provider.name, item.type).toLowerCase() === providerName.toLowerCase()
+ providerCapabilityNameMatches(provider, item.type, providerName)
);
if (capability) {
return capability.type;
@@ -2558,9 +2618,27 @@ function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | u
return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider);
}
+function resolveProviderLogName(headers: Headers, config: AppConfig | undefined, fallbackModel?: string): string | undefined {
+ const providerSelector =
+ headers.get("x-gateway-target-provider-name")?.trim() ||
+ headers.get("x-gateway-target-provider")?.trim();
+ const headerProvider = providerSelector && config
+ ? findProviderByPublicOrInternalName(config, providerSelector)
+ : undefined;
+ if (headerProvider) {
+ return headerProvider.name;
+ }
+
+ const routeProvider = parseProviderModelSelector(fallbackModel)?.provider;
+ const modelProvider = routeProvider && config
+ ? findProviderByPublicOrInternalName(config, routeProvider)
+ : undefined;
+ return modelProvider?.name;
+}
+
function providerMatchesName(provider: GatewayProviderConfig, name: string): boolean {
const normalizedName = name.trim().toLowerCase();
- return [provider.name, provider.provider]
+ return [provider.id, provider.name, provider.provider]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.some((value) => value.trim().toLowerCase() === normalizedName);
}
@@ -2862,7 +2940,7 @@ function shouldServeGatewayRequest(config: AppConfig, request: IncomingMessage):
function applyCors(response: ServerResponse, config?: AppConfig): void {
const origin = config ? endpoint(config.gateway.host, config.gateway.port) : "*";
response.setHeader("Access-Control-Allow-Origin", origin);
- response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, Anthropic-Version, Anthropic-Beta, Mcp-Session-Id, MCP-Protocol-Version");
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, Last-Event-ID, Anthropic-Version, Anthropic-Beta, Mcp-Session-Id, MCP-Protocol-Version");
response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
}
@@ -2873,7 +2951,7 @@ function authorize(request: IncomingMessage, response: ServerResponse, config: A
return { ok: true };
}
- const token = readAuthToken(request.headers);
+ const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request);
const apiKey = token ? apiKeys.find((item) => item.key === token) : undefined;
if (apiKey) {
if (isApiKeyExpired(apiKey)) {
@@ -3920,6 +3998,14 @@ function readAuthToken(headers: IncomingHttpHeaders): string | undefined {
return raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw;
}
+function readRemoteControlQueryAuthToken(request: IncomingMessage): string | undefined {
+ const url = new URL(request.url || "/", "http://127.0.0.1");
+ if (url.pathname !== ccrRemoteControlPathPrefix && !url.pathname.startsWith(`${ccrRemoteControlPathPrefix}/`)) {
+ return undefined;
+ }
+ return url.searchParams.get("api_key")?.trim() || url.searchParams.get("key")?.trim() || undefined;
+}
+
function forwardHeaders(headers: IncomingHttpHeaders): Record {
const forwarded: Record = {};
for (const [key, value] of Object.entries(headers)) {
diff --git a/src/shared/app.ts b/src/shared/app.ts
index f201679..fd29035 100644
--- a/src/shared/app.ts
+++ b/src/shared/app.ts
@@ -77,6 +77,7 @@ export type GatewayProviderConfig = {
extraBody?: unknown;
extraHeaders?: unknown;
icon?: string;
+ id?: string;
models: string[];
name: string;
provider?: string;
From 8d306c562fb9fa77f922c837d8d516aab656f9a4 Mon Sep 17 00:00:00 2001
From: musistudio
Date: Mon, 29 Jun 2026 10:28:58 +0800
Subject: [PATCH 003/101] Add CLI web service support and install docs
---
README.md | 22 +
README_zh.md | 22 +
build/build.mjs | 3 +-
build/dev.mjs | 28 +-
build/esbuild.config.mjs | 58 +-
package-lock.json | 3 +
package.json | 32 +-
src/main/claude-app-gateway-service.ts | 25 +-
src/main/cli.ts | 571 +++++++++++--
src/main/electron-node-shim.ts | 14 +
src/main/profile-launch-core.ts | 2 +-
src/main/web-client-bridge.ts | 112 +++
src/main/web-management-server.ts | 1014 ++++++++++++++++++++++++
src/server/mcp/network-capture-mcp.ts | 9 +-
src/server/proxy/service.ts | 21 +-
15 files changed, 1837 insertions(+), 99 deletions(-)
create mode 100644 src/main/electron-node-shim.ts
create mode 100644 src/main/web-client-bridge.ts
create mode 100644 src/main/web-management-server.ts
diff --git a/README.md b/README.md
index e53da3b..c123f22 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,28 @@ CCR runs on your machine, keeps provider configuration in your local config dire
## Download And Install
+### npm CLI
+
+Install the CLI package when you want to run CCR without the desktop tray or `ccr://` protocol integration:
+
+```bash
+npm install -g claude-code-router
+ccr start
+```
+
+Common commands:
+
+```bash
+ccr start # start the background CCR service and web management UI
+ccr stop # stop the background CCR service
+ccr cli # launch the saved profile as a CLI
+ccr app # launch the saved profile as an app
+```
+
+The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr start --host --port ` to change it.
+
+### Desktop App
+
1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
2. Download the package for your platform:
- macOS: `Claude Code Router_.dmg` or `.zip`
diff --git a/README_zh.md b/README_zh.md
index dfe6b3a..45f98cf 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -38,6 +38,28 @@ CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默
## 下载和安装
+### npm CLI
+
+如果你只需要 CLI 版本,不需要桌面 Tray 或 `ccr://` 协议集成,可以安装 npm 包:
+
+```bash
+npm install -g claude-code-router
+ccr start
+```
+
+常用命令:
+
+```bash
+ccr start # 后台启动 CCR 服务和 Web 管理端
+ccr stop # 停止后台 CCR 服务
+ccr cli # 以 CLI 方式启动保存的 profile
+ccr app # 以 App 方式启动保存的 profile
+```
+
+Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host --port ` 修改监听地址。
+
+### 桌面应用
+
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
2. 按系统下载对应安装包:
- macOS:`Claude Code Router_.dmg` 或 `.zip`
diff --git a/build/build.mjs b/build/build.mjs
index e0bc9d4..a07412b 100644
--- a/build/build.mjs
+++ b/build/build.mjs
@@ -1,4 +1,4 @@
-import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs";
+import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs";
const mode = process.argv.includes("--dev") ? "development" : "production";
@@ -15,6 +15,7 @@ await Promise.all([
buildBrowserRenderer({ mode }),
buildRenderer({ mode }),
buildTrayRenderer({ mode }),
+ buildWebClientBridge({ mode }),
buildStyles({ minify: mode === "production" })
]);
diff --git a/build/dev.mjs b/build/dev.mjs
index 0511592..a7cf8fd 100644
--- a/build/dev.mjs
+++ b/build/dev.mjs
@@ -16,9 +16,11 @@ import {
copyRendererHtml,
copyTrayRendererHtml,
createBrowserRendererBuildOptions,
+ createCliBuildOptions,
createMainBuildOptions,
createRendererBuildOptions,
createTrayRendererBuildOptions,
+ createWebClientBridgeBuildOptions,
cssInput,
cssOutput,
appAssetsInput,
@@ -38,9 +40,11 @@ const restartDelayMs = 160;
const ignoredSignatureEntries = new Set([".DS_Store"]);
const ready = {
browser: false,
+ cli: false,
main: false,
renderer: false,
- tray: false
+ tray: false,
+ webBridge: false
};
function logDev(message) {
@@ -163,11 +167,11 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on
}
function markReady(name, reason = `${name} esbuild completed`) {
- if (name === "browser" || name === "main" || name === "renderer" || name === "tray") {
+ if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
ready[name] = true;
}
logDev(`build ready: ${reason}; ${readyState()}`);
- if (ready.browser && ready.main && ready.renderer && ready.tray) {
+ if (ready.browser && ready.cli && ready.main && ready.renderer && ready.tray && ready.webBridge) {
scheduleRestart(reason);
}
}
@@ -274,6 +278,13 @@ const mainContext = await esbuild.context(
})
);
+const cliContext = await esbuild.context(
+ createCliBuildOptions({
+ mode: "development",
+ plugins: [watchPlugin("cli", (name) => markReady(name))]
+ })
+);
+
const rendererContext = await esbuild.context(
createRendererBuildOptions({
mode: "development",
@@ -310,7 +321,14 @@ const browserRendererContext = await esbuild.context(
})
);
-await Promise.all([mainContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch()]);
+const webClientBridgeContext = await esbuild.context(
+ createWebClientBridgeBuildOptions({
+ mode: "development",
+ plugins: [watchPlugin("webBridge", (name) => markReady(name))]
+ })
+);
+
+await Promise.all([mainContext.watch(), cliContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch(), webClientBridgeContext.watch()]);
logDev("watchers are active");
async function shutdown() {
@@ -328,7 +346,7 @@ async function shutdown() {
trayHtmlWatcher.close();
appAssetsWatcher.close();
modelCatalogWatcher.close();
- await Promise.all([mainContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose()]);
+ await Promise.all([mainContext.dispose(), cliContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose(), webClientBridgeContext.dispose()]);
process.exit(0);
}
diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs
index 3507935..3283c23 100644
--- a/build/esbuild.config.mjs
+++ b/build/esbuild.config.mjs
@@ -26,6 +26,7 @@ export const trayRendererHtmlInput = path.join(rendererRoot, "pages", "tray", "i
export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", "index.html");
export const cssInput = path.join(rendererRoot, "styles", "globals.css");
export const cssOutput = path.join(rendererAssetsDir, "main.css");
+export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js");
const nodeExternals = [
"electron",
@@ -110,7 +111,6 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
entryPoints: [
path.join(projectRoot, "src", "main", "main.ts"),
path.join(projectRoot, "src", "main", "browser-preload.ts"),
- path.join(projectRoot, "src", "main", "cli.ts"),
path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"),
path.join(projectRoot, "src", "main", "preload.ts")
],
@@ -127,6 +127,25 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
};
}
+export function createCliBuildOptions({ mode = "production", plugins = [] } = {}) {
+ return {
+ absWorkingDir: projectRoot,
+ bundle: true,
+ entryNames: "[name]",
+ entryPoints: [path.join(projectRoot, "src", "main", "cli.ts")],
+ external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
+ format: "cjs",
+ legalComments: "none",
+ logLevel: "info",
+ minify: mode === "production",
+ outdir: mainOutDir,
+ platform: "node",
+ plugins: [electronNodeShimPlugin(), ...plugins],
+ sourcemap: mode !== "production",
+ target: "node22"
+ };
+}
+
export function createRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
@@ -175,6 +194,23 @@ export function createBrowserRendererBuildOptions({ mode = "production", plugins
};
}
+export function createWebClientBridgeBuildOptions({ mode = "production", plugins = [] } = {}) {
+ return {
+ absWorkingDir: projectRoot,
+ bundle: true,
+ entryPoints: [path.join(projectRoot, "src", "main", "web-client-bridge.ts")],
+ format: "iife",
+ legalComments: "none",
+ logLevel: "info",
+ minify: mode === "production",
+ outfile: webClientBridgeOutput,
+ platform: "browser",
+ plugins,
+ sourcemap: mode !== "production",
+ target: "chrome120"
+ };
+}
+
export function watchPlugin(name, onEnd) {
return {
name: `${name}-watch`,
@@ -189,7 +225,10 @@ export function watchPlugin(name, onEnd) {
}
export async function buildMain(options = {}) {
- await esbuild.build(createMainBuildOptions(options));
+ await Promise.all([
+ esbuild.build(createMainBuildOptions(options)),
+ esbuild.build(createCliBuildOptions(options))
+ ]);
}
export async function buildRenderer(options = {}) {
@@ -204,6 +243,10 @@ export async function buildBrowserRenderer(options = {}) {
await esbuild.build(createBrowserRendererBuildOptions(options));
}
+export async function buildWebClientBridge(options = {}) {
+ await esbuild.build(createWebClientBridgeBuildOptions(options));
+}
+
export async function buildStyles({ minify = false } = {}) {
ensureDist();
const args = ["-i", cssInput, "-o", cssOutput];
@@ -249,6 +292,17 @@ function rendererAliasPlugin() {
};
}
+function electronNodeShimPlugin() {
+ return {
+ name: "electron-node-shim",
+ setup(build) {
+ build.onResolve({ filter: /^electron$/ }, () => {
+ return { path: path.join(projectRoot, "src", "main", "electron-node-shim.ts") };
+ });
+ }
+ };
+}
+
function resolveRendererImport(importPath) {
const basePath = path.resolve(rendererRoot, importPath);
const candidates = [
diff --git a/package-lock.json b/package-lock.json
index a741305..f3783f2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,9 @@
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3"
+ },
+ "engines": {
+ "node": ">=22"
}
},
"node_modules/@babel/runtime": {
diff --git a/package.json b/package.json
index 1a070ff..8cc0416 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,39 @@
{
"name": "claude-code-router",
- "private": true,
"version": "3.0.2",
"license": "MIT",
- "description": "Desktop scaffold for Claude Code Router.",
+ "description": "Local Claude Code Router gateway with CLI and web management UI.",
+ "repository": {
+ "type": "git",
+ "url": "git+ssh://git@github.com/musistudio/claude-code-router.git"
+ },
+ "bugs": {
+ "url": "https://github.com/musistudio/claude-code-router/issues"
+ },
+ "homepage": "https://github.com/musistudio/claude-code-router#readme",
+ "keywords": [
+ "claude-code",
+ "codex",
+ "llm",
+ "gateway",
+ "router"
+ ],
"main": "dist/main/main.js",
"bin": {
"ccr": "dist/main/cli.js"
},
+ "files": [
+ "dist",
+ "LICENSE",
+ "README.md",
+ "README_zh.md"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
"scripts": {
"dev": "node build/dev.mjs",
"build": "npm run build:assets && electron-builder",
@@ -16,6 +42,8 @@
"build:app:mac:local": "npm run build:assets && electron-builder --config build/electron-builder.local.cjs --mac --publish never",
"build:app:mac:release": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --publish never",
"build:app:win": "npm run build:assets && electron-builder --win",
+ "prepack": "npm run build:assets",
+ "prepublishOnly": "npm run typecheck",
"preview": "npm run build:assets && electron .",
"typecheck": "tsc --noEmit",
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts
index c9494a8..66da1af 100644
--- a/src/main/claude-app-gateway-service.ts
+++ b/src/main/claude-app-gateway-service.ts
@@ -1,4 +1,4 @@
-import { app } from "electron";
+import * as electron from "electron";
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -236,13 +236,30 @@ function getClaudeAppGatewayPaths(dataDir = getClaudeApp3pDataDir()): ClaudeAppG
function getClaudeApp3pDataDir(): string {
if (process.platform === "darwin") {
- return path.join(app.getPath("home"), "Library", "Application Support", "Claude-3p");
+ return path.join(appPath("home"), "Library", "Application Support", "Claude-3p");
}
if (process.platform === "win32") {
- const localAppData = process.env.LOCALAPPDATA || path.join(app.getPath("appData"), "..", "Local");
+ const localAppData = process.env.LOCALAPPDATA || path.join(appPath("appData"), "..", "Local");
return path.join(localAppData, "Claude-3p");
}
- return path.join(app.getPath("appData") || os.homedir(), "Claude-3p");
+ return path.join(appPath("appData") || os.homedir(), "Claude-3p");
+}
+
+function appPath(name: "appData" | "home"): string {
+ const electronApp = electronAppOrUndefined();
+ if (electronApp) {
+ return electronApp.getPath(name);
+ }
+ if (name === "home") {
+ return os.homedir();
+ }
+ return process.env.APPDATA ||
+ process.env.LOCALAPPDATA ||
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), ".config"));
+}
+
+function electronAppOrUndefined(): Electron.App | undefined {
+ return typeof electron.app?.getPath === "function" ? electron.app : undefined;
}
function backupClaudeAppGatewayConfig(paths: ClaudeAppGatewayPaths): void {
diff --git a/src/main/cli.ts b/src/main/cli.ts
index 9507874..3d745ca 100644
--- a/src/main/cli.ts
+++ b/src/main/cli.ts
@@ -1,38 +1,100 @@
#!/usr/bin/env node
-import { spawn } from "node:child_process";
-import { existsSync, readFileSync } from "node:fs";
-import os from "node:os";
+import { spawn, spawnSync } from "node:child_process";
+import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
-import type { AppConfig, ProfileOpenSurface } from "../shared/app";
+import type { ProfileOpenSurface } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch";
-import { createBetterSqliteDatabase } from "./sqlite-native";
+import { loadAppConfig } from "./config";
+import { CONFIGDIR } from "./constants";
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core";
+import { startWebManagementServer } from "./web-management-server";
-type CliOptions = {
+type ProfileCliOptions = {
agentArgs: string[];
+ command: "profile";
help: boolean;
profileRef: string;
surface?: ProfileOpenSurface;
};
+type WebCliOptions = {
+ command: "start" | "web";
+ daemonChild: boolean;
+ help: boolean;
+ host?: string;
+ open: boolean;
+ port?: number;
+ startGateway: boolean;
+};
+
+type StopCliOptions = {
+ command: "stop";
+ help: boolean;
+};
+
+type CliOptions = ProfileCliOptions | StopCliOptions | WebCliOptions;
+
+type ServiceState = {
+ host?: string;
+ pid: number;
+ startedAt: string;
+ startGateway: boolean;
+ url: string;
+};
+
+const serviceStateFileName = "service.json";
+const serviceStartTimeoutMs = 30_000;
+const serviceStopTimeoutMs = 10_000;
+
async function main(): Promise {
- const options = parseArgs(process.argv.slice(2));
- if (options.help || !options.profileRef) {
- printHelp(options.help ? 0 : 2);
+ const delegatedExitCode = delegateManagedDesktopCliToExternalCli();
+ if (delegatedExitCode !== undefined) {
+ process.exitCode = delegatedExitCode;
return;
}
- const configFile = process.env.CCR_CONFIG_FILE?.trim() || defaultConfigFile();
- const configDir = path.dirname(configFile);
- const config = readConfig(configFile, process.env.CCR_CONFIG_DB_FILE?.trim() || defaultConfigDbFile(configFile));
- const profile = findProfileForOpen(config, options.profileRef);
- const surface = options.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli");
+ const options = parseArgs(process.argv.slice(2));
+ if (options.command === "start") {
+ if (options.help) {
+ printStartHelp(0);
+ return;
+ }
+ await startService(options);
+ return;
+ }
+ if (options.command === "stop") {
+ if (options.help) {
+ printStopHelp(0);
+ return;
+ }
+ await stopService();
+ return;
+ }
+ if (options.command === "web") {
+ if (options.help) {
+ printWebHelp(0);
+ return;
+ }
+ await runWebServer(options);
+ return;
+ }
+
+ const profileOptions = options as ProfileCliOptions;
+ if (profileOptions.help || !profileOptions.profileRef) {
+ printHelp(profileOptions.help ? 0 : 2);
+ return;
+ }
+
+ const configDir = CONFIGDIR;
+ const config = await loadAppConfig();
+ const profile = findProfileForOpen(config, profileOptions.profileRef);
+ const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli");
const resolvedSurface = resolveProfileOpenSurface(profile, surface);
- if (profile.agent === "zcode" && options.agentArgs.length > 0) {
+ if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) {
throw new Error("ZCode profiles can only open the app; agent arguments are not supported.");
}
- if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && options.agentArgs.length === 0) {
+ if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
if (profile.agent === "zcode") {
const launch = launchZcodeAppProfile(configDir, profile, config);
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
@@ -51,7 +113,7 @@ async function main(): Promise {
return;
}
- const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, options.agentArgs);
+ const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs);
if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`);
@@ -75,8 +137,19 @@ async function main(): Promise {
}
function parseArgs(args: string[]): CliOptions {
- const options: CliOptions = {
+ if (args[0] === "start") {
+ return parseWebArgs(args.slice(1), "start");
+ }
+ if (args[0] === "stop") {
+ return parseStopArgs(args.slice(1));
+ }
+ if (args[0] === "serve" || args[0] === "web") {
+ return parseWebArgs(args.slice(1), "web");
+ }
+
+ const options: ProfileCliOptions = {
agentArgs: [],
+ command: "profile",
help: false,
profileRef: ""
};
@@ -98,6 +171,10 @@ function parseArgs(args: string[]): CliOptions {
options.surface = "cli";
continue;
}
+ if (options.profileRef && !options.surface && (arg === "cli" || arg === "app")) {
+ options.surface = arg;
+ continue;
+ }
if (!options.profileRef) {
options.profileRef = arg;
continue;
@@ -107,85 +184,429 @@ function parseArgs(args: string[]): CliOptions {
return options;
}
-function readConfig(jsonFile: string, dbFile: string): AppConfig {
- const sqliteConfig = readSqliteConfig(dbFile);
- if (sqliteConfig) {
- return normalizeCliConfig(sqliteConfig, dbFile);
+function parseStopArgs(args: string[]): StopCliOptions {
+ const options: StopCliOptions = {
+ command: "stop",
+ help: false
+ };
+ for (const arg of args) {
+ if (arg === "--help" || arg === "-h") {
+ options.help = true;
+ continue;
+ }
+ throw new Error(`Unknown stop option: ${arg}`);
}
- if (!existsSync(jsonFile)) {
- throw new Error(`CCR config was not found: ${dbFile}`);
- }
- const parsed = JSON.parse(readFileSync(jsonFile, "utf8")) as Partial;
- return normalizeCliConfig(parsed, jsonFile);
+ return options;
}
-function readSqliteConfig(file: string): Partial | undefined {
- if (!existsSync(file)) {
- return undefined;
- }
- let database: ReturnType | undefined;
- try {
- database = createBetterSqliteDatabase(file);
- const row = database.prepare("SELECT value_json FROM app_config WHERE key = ? LIMIT 1").get("default") as { value_json?: unknown } | undefined;
- return typeof row?.value_json === "string"
- ? JSON.parse(row.value_json) as Partial
- : undefined;
- } catch {
- return undefined;
- } finally {
- database?.close();
+function parseWebArgs(args: string[], command: WebCliOptions["command"]): WebCliOptions {
+ const options: WebCliOptions = {
+ command,
+ daemonChild: false,
+ help: false,
+ open: false,
+ startGateway: true
+ };
+ for (let index = 0; index < args.length; index += 1) {
+ const arg = args[index];
+ if (arg === "--help" || arg === "-h") {
+ options.help = true;
+ continue;
+ }
+ if (arg === "--open") {
+ options.open = true;
+ continue;
+ }
+ if (arg === "--no-open") {
+ options.open = false;
+ continue;
+ }
+ if (arg === "--gateway") {
+ options.startGateway = true;
+ continue;
+ }
+ if (arg === "--no-gateway") {
+ options.startGateway = false;
+ continue;
+ }
+ if (arg === "--daemon-child") {
+ options.daemonChild = true;
+ continue;
+ }
+ if (arg === "--host") {
+ index += 1;
+ options.host = requiredArg(args[index], "--host");
+ continue;
+ }
+ if (arg.startsWith("--host=")) {
+ options.host = requiredArg(arg.slice("--host=".length), "--host");
+ continue;
+ }
+ if (arg === "--port") {
+ index += 1;
+ options.port = parsePort(requiredArg(args[index], "--port"));
+ continue;
+ }
+ if (arg.startsWith("--port=")) {
+ options.port = parsePort(requiredArg(arg.slice("--port=".length), "--port"));
+ continue;
+ }
+ throw new Error(`Unknown web option: ${arg}`);
}
+ return options;
}
-function normalizeCliConfig(parsed: Partial, source: string): AppConfig {
- if (!parsed.profile || !Array.isArray(parsed.profile.profiles)) {
- throw new Error(`CCR config has no profiles: ${source}`);
+async function startService(options: WebCliOptions): Promise {
+ const current = readServiceState();
+ if (current && isProcessRunning(current.pid)) {
+ process.stdout.write(`CCR service is already running at ${current.url} (pid ${current.pid}).\n`);
+ return;
}
- return {
- ...parsed,
- profile: {
- ...parsed.profile,
- profiles: parsed.profile.profiles
- } as AppConfig["profile"]
- } as AppConfig;
-}
+ clearServiceState();
-function defaultConfigDbFile(configFile: string): string {
- return path.join(path.dirname(configFile), "config.sqlite");
-}
-
-function defaultConfigFile(): string {
- return path.join(defaultConfigDir(), "config.json");
-}
-
-function defaultConfigDir(): string {
- if (process.platform === "win32") {
- return path.join(
- process.env.APPDATA ||
- process.env.LOCALAPPDATA ||
- (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming")),
- "Claude Code Router"
- );
+ const childArgs = [
+ currentCliScript(),
+ "serve",
+ "--daemon-child",
+ ...(options.host ? ["--host", options.host] : []),
+ ...(options.port ? ["--port", String(options.port)] : []),
+ ...(options.open ? ["--open"] : ["--no-open"]),
+ ...(options.startGateway ? [] : ["--no-gateway"])
+ ];
+ const child = spawn(process.execPath, childArgs, {
+ detached: true,
+ env: {
+ ...process.env,
+ ELECTRON_RUN_AS_NODE: undefined
+ },
+ stdio: "ignore",
+ windowsHide: true
+ });
+ const spawnError = await waitForImmediateSpawnError(child, 1000);
+ if (spawnError) {
+ throw new Error(`Failed to start CCR service: ${spawnError}`);
}
- return path.join(os.homedir(), ".claude-code-router");
+ child.unref();
+
+ const state = await waitForServiceState(child.pid, serviceStartTimeoutMs);
+ if (!state) {
+ throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`);
+ }
+ process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`);
+}
+
+async function runWebServer(options: WebCliOptions): Promise {
+ const runtime = await startWebManagementServer({
+ host: options.host,
+ open: options.open,
+ port: options.port,
+ startGateway: options.startGateway
+ });
+ if (options.daemonChild) {
+ writeServiceState({
+ host: options.host,
+ pid: process.pid,
+ startedAt: new Date().toISOString(),
+ startGateway: options.startGateway,
+ url: runtime.url
+ });
+ }
+ process.stdout.write(`CCR web management is running at ${runtime.url}\n`);
+
+ let closing = false;
+ const shutdown = (signal: NodeJS.Signals) => {
+ if (closing) {
+ return;
+ }
+ closing = true;
+ void runtime.close().finally(() => {
+ if (options.daemonChild) {
+ clearServiceState(process.pid);
+ }
+ process.exit(signal === "SIGINT" ? 130 : 143);
+ });
+ };
+ process.once("SIGINT", shutdown);
+ process.once("SIGTERM", shutdown);
+ await new Promise(() => undefined);
+}
+
+async function stopService(): Promise {
+ const state = readServiceState();
+ if (!state) {
+ process.stdout.write("CCR service is not running.\n");
+ return;
+ }
+ if (!isProcessRunning(state.pid)) {
+ clearServiceState(state.pid);
+ process.stdout.write("CCR service is not running.\n");
+ return;
+ }
+ process.kill(state.pid, "SIGTERM");
+ const stopped = await waitForProcessExit(state.pid, serviceStopTimeoutMs);
+ if (!stopped && isProcessRunning(state.pid)) {
+ throw new Error(`CCR service pid ${state.pid} did not stop within ${serviceStopTimeoutMs}ms.`);
+ }
+ clearServiceState(state.pid);
+ process.stdout.write("CCR service stopped.\n");
}
function printHelp(exitCode: number): void {
const output = [
"Usage:",
- " ccr [--cli|--app] [-- ]",
+ " ccr start [--host ] [--port ] [--open] [--no-gateway]",
+ " ccr stop",
+ " ccr [-- ]",
"",
"Examples:",
- " ccr Codex",
- " ccr default-codex -- --model gpt-5-codex",
- " ccr default-codex --app",
- " ccr ZCode"
+ " ccr start",
+ " ccr stop",
+ " ccr Codex cli",
+ " ccr default-codex cli -- --model gpt-5-codex",
+ " ccr default-codex app"
].join("\n");
const stream = exitCode === 0 ? process.stdout : process.stderr;
stream.write(`${output}\n`);
process.exitCode = exitCode;
}
+function printStartHelp(exitCode: number): void {
+ const output = [
+ "Usage:",
+ " ccr start [--host ] [--port ] [--open] [--no-gateway]",
+ "",
+ "Options:",
+ " --host Management server host. Defaults to 127.0.0.1.",
+ " --port Management server port. Defaults to 3458.",
+ " --open Open the management page in the default browser.",
+ " --no-open Do not open the management page.",
+ " --no-gateway Start only the web management server."
+ ].join("\n");
+ const stream = exitCode === 0 ? process.stdout : process.stderr;
+ stream.write(`${output}\n`);
+ process.exitCode = exitCode;
+}
+
+function printStopHelp(exitCode: number): void {
+ const output = [
+ "Usage:",
+ " ccr stop",
+ "",
+ "Stops the background CCR service started by `ccr start`."
+ ].join("\n");
+ const stream = exitCode === 0 ? process.stdout : process.stderr;
+ stream.write(`${output}\n`);
+ process.exitCode = exitCode;
+}
+
+function printWebHelp(exitCode: number): void {
+ const output = [
+ "Usage:",
+ " ccr serve [--host ] [--port ] [--open] [--no-gateway]",
+ "",
+ "Options:",
+ " --host Management server host. Defaults to 127.0.0.1.",
+ " --port Management server port. Defaults to 3458.",
+ " --open Open the management page in the default browser.",
+ " --no-gateway Start only the web management server."
+ ].join("\n");
+ const stream = exitCode === 0 ? process.stdout : process.stderr;
+ stream.write(`${output}\n`);
+ process.exitCode = exitCode;
+}
+
+function readServiceState(): ServiceState | undefined {
+ const file = serviceStateFile();
+ if (!existsSync(file)) {
+ return undefined;
+ }
+ try {
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial;
+ const pid = Number(parsed.pid);
+ if (!Number.isInteger(pid) || pid <= 0 || typeof parsed.url !== "string") {
+ return undefined;
+ }
+ return {
+ host: parsed.host,
+ pid,
+ startedAt: parsed.startedAt || "",
+ startGateway: parsed.startGateway !== false,
+ url: parsed.url
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+function writeServiceState(state: ServiceState): void {
+ const file = serviceStateFile();
+ mkdirSync(path.dirname(file), { recursive: true });
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
+}
+
+function clearServiceState(pid?: number): void {
+ const state = readServiceState();
+ if (pid !== undefined && state && state.pid !== pid) {
+ return;
+ }
+ try {
+ unlinkSync(serviceStateFile());
+ } catch {
+ // Stale state cleanup is best effort.
+ }
+}
+
+function serviceStateFile(): string {
+ return path.join(CONFIGDIR, serviceStateFileName);
+}
+
+function currentCliScript(): string {
+ return __filename;
+}
+
+function delegateManagedDesktopCliToExternalCli(): number | undefined {
+ if (!isManagedDesktopCliRuntime()) {
+ return undefined;
+ }
+ if (process.env.CCR_MANAGED_CLI_NO_DELEGATE === "1" || process.env.CCR_MANAGED_CLI_DELEGATED === "1") {
+ return undefined;
+ }
+
+ const externalCcr = findExternalCcrCommand();
+ if (!externalCcr) {
+ return undefined;
+ }
+
+ const launch = profileLaunchSpawnCommand({
+ args: process.argv.slice(2),
+ command: externalCcr
+ });
+ const result = spawnSync(launch.command, launch.args, {
+ env: {
+ ...process.env,
+ CCR_MANAGED_CLI_DELEGATED: "1"
+ },
+ stdio: "inherit",
+ windowsVerbatimArguments: !!launch.windowsVerbatimArguments
+ });
+ if (result.error) {
+ return undefined;
+ }
+ if (typeof result.status === "number") {
+ return result.status;
+ }
+ return result.signal === "SIGINT" ? 130 : 1;
+}
+
+function isManagedDesktopCliRuntime(): boolean {
+ const script = process.argv[1] || __filename;
+ return samePath(path.resolve(script), path.join(CONFIGDIR, "bin", "ccr-cli.js"));
+}
+
+function findExternalCcrCommand(): string | undefined {
+ const pathKey = process.platform === "win32"
+ ? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path"
+ : "PATH";
+ const pathValue = process.env[pathKey] || "";
+ const managedBinDir = path.resolve(CONFIGDIR, "bin");
+ const names = process.platform === "win32"
+ ? ["ccr.cmd", "ccr.exe", "ccr.bat", "ccr"]
+ : ["ccr"];
+
+ for (const rawSegment of pathValue.split(path.delimiter)) {
+ const dir = path.resolve(rawSegment || ".");
+ if (samePath(dir, managedBinDir)) {
+ continue;
+ }
+ for (const name of names) {
+ const candidate = path.join(dir, name);
+ if (isExecutableFile(candidate)) {
+ return candidate;
+ }
+ }
+ }
+ return undefined;
+}
+
+function isExecutableFile(file: string): boolean {
+ try {
+ const stats = statSync(file);
+ if (!stats.isFile() && !stats.isSymbolicLink()) {
+ return false;
+ }
+ if (process.platform === "win32") {
+ return true;
+ }
+ accessSync(file, fsConstants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function samePath(left: string, right: string): boolean {
+ const normalizedLeft = path.normalize(left);
+ const normalizedRight = path.normalize(right);
+ return process.platform === "win32"
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
+ : normalizedLeft === normalizedRight;
+}
+
+async function waitForServiceState(pid: number | undefined, timeoutMs: number): Promise {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < timeoutMs) {
+ const state = readServiceState();
+ if (state && (!pid || state.pid === pid) && isProcessRunning(state.pid)) {
+ return state;
+ }
+ await delay(150);
+ }
+ return undefined;
+}
+
+async function waitForProcessExit(pid: number, timeoutMs: number): Promise {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < timeoutMs) {
+ if (!isProcessRunning(pid)) {
+ return true;
+ }
+ await delay(150);
+ }
+ return !isProcessRunning(pid);
+}
+
+function isProcessRunning(pid: number | undefined): boolean {
+ if (!pid || pid <= 0) {
+ return false;
+ }
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined;
+ return code === "EPERM";
+ }
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function requiredArg(value: string | undefined, option: string): string {
+ if (!value?.trim()) {
+ throw new Error(`${option} requires a value.`);
+ }
+ return value.trim();
+}
+
+function parsePort(value: string): number {
+ const port = Number(value);
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
+ throw new Error(`Invalid port: ${value}`);
+ }
+ return port;
+}
+
function waitForChild(child: ReturnType): Promise {
return new Promise((resolve) => {
child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1)));
diff --git a/src/main/electron-node-shim.ts b/src/main/electron-node-shim.ts
new file mode 100644
index 0000000..212ed6a
--- /dev/null
+++ b/src/main/electron-node-shim.ts
@@ -0,0 +1,14 @@
+export const app = undefined;
+export const BrowserWindow = undefined;
+export const clipboard = undefined;
+export const contextBridge = undefined;
+export const dialog = undefined;
+export const ipcMain = undefined;
+export const ipcRenderer = undefined;
+export const Menu = undefined;
+export const nativeImage = undefined;
+export const screen = undefined;
+export const session = undefined;
+export const shell = undefined;
+export const Tray = undefined;
+export const WebContentsView = undefined;
diff --git a/src/main/profile-launch-core.ts b/src/main/profile-launch-core.ts
index b2d2139..5f50af2 100644
--- a/src/main/profile-launch-core.ts
+++ b/src/main/profile-launch-core.ts
@@ -76,7 +76,7 @@ export function profileOpenCommand(
profileRef = profile.name?.trim() || profile.id
): string {
const quote = process.platform === "win32" ? windowsCommandQuote : shellQuote;
- return [quote(command), quote(profileRef), ...(surface === "app" ? ["--app"] : [])].join(" ");
+ return [quote(command), quote(profileRef), surface].join(" ");
}
export function buildProfileLaunchPlan(
diff --git a/src/main/web-client-bridge.ts b/src/main/web-client-bridge.ts
new file mode 100644
index 0000000..f720d17
--- /dev/null
+++ b/src/main/web-client-bridge.ts
@@ -0,0 +1,112 @@
+const rpcEndpoint = "/api/ccr/rpc";
+
+type RpcResponse =
+ | { ok: true; value: unknown }
+ | { error: { message: string; stack?: string }; ok: false };
+
+async function rpc(method: string, args: unknown[] = []): Promise {
+ const response = await fetch(rpcEndpoint, {
+ body: JSON.stringify({ args, method }),
+ headers: {
+ "content-type": "application/json"
+ },
+ method: "POST"
+ });
+ let payload: RpcResponse | undefined;
+ try {
+ payload = await response.json() as RpcResponse;
+ } catch {
+ payload = undefined;
+ }
+ if (!response.ok || !payload?.ok) {
+ const message = payload && !payload.ok
+ ? payload.error.message
+ : `CCR web API failed with HTTP ${response.status}`;
+ throw new Error(message);
+ }
+ return payload.value;
+}
+
+function noopSubscription(): () => void {
+ return () => undefined;
+}
+
+async function selectPluginDirectory(): Promise {
+ const directory = window.prompt("Plugin directory path");
+ if (!directory?.trim()) {
+ return undefined;
+ }
+ return rpc("selectPluginDirectory", [directory.trim()]);
+}
+
+window.ccr = {
+ applyClaudeAppGateway: (config) => rpc("applyClaudeAppGateway", [config]) as ReturnType["applyClaudeAppGateway"]>,
+ applyProfile: () => rpc("applyProfile") as ReturnType["applyProfile"]>,
+ cancelBotGatewayQrLogin: (request) => rpc("cancelBotGatewayQrLogin", [request]) as ReturnType["cancelBotGatewayQrLogin"]>,
+ checkProviderConnectivity: (request) => rpc("checkProviderConnectivity", [request]) as ReturnType["checkProviderConnectivity"]>,
+ clearProxyNetworkCaptures: () => rpc("clearProxyNetworkCaptures") as ReturnType["clearProxyNetworkCaptures"]>,
+ closeBotGatewayQrWindow: (request) => rpc("closeBotGatewayQrWindow", [request]) as ReturnType["closeBotGatewayQrWindow"]>,
+ closeTray: () => Promise.resolve(),
+ detectProviderIcon: (request) => rpc("detectProviderIcon", [request]) as ReturnType["detectProviderIcon"]>,
+ exportData: () => rpc("exportData") as ReturnType["exportData"]>,
+ fetchProviderManifest: (request) => rpc("fetchProviderManifest", [request]) as ReturnType["fetchProviderManifest"]>,
+ getAgentAnalysis: (filter) => rpc("getAgentAnalysis", [filter]) as ReturnType["getAgentAnalysis"]>,
+ getAgentTracePayload: (request) => rpc("getAgentTracePayload", [request]) as ReturnType["getAgentTracePayload"]>,
+ getAppInfo: () => rpc("getAppInfo") as ReturnType["getAppInfo"]>,
+ getConfig: () => rpc("getConfig") as ReturnType["getConfig"]>,
+ getGatewayStatus: () => rpc("getGatewayStatus") as ReturnType["getGatewayStatus"]>,
+ getLocalAgentProviderCandidates: () => rpc("getLocalAgentProviderCandidates") as ReturnType["getLocalAgentProviderCandidates"]>,
+ getOnboardingFinished: () => rpc("getOnboardingFinished") as ReturnType["getOnboardingFinished"]>,
+ getPendingProviderDeepLinks: () => Promise.resolve([]),
+ getPluginMarketplace: () => rpc("getPluginMarketplace") as ReturnType["getPluginMarketplace"]>,
+ getProfileOpenCommand: (request) => rpc("getProfileOpenCommand", [request]) as ReturnType["getProfileOpenCommand"]>,
+ getProfileRuntimeStatus: () => rpc("getProfileRuntimeStatus") as ReturnType["getProfileRuntimeStatus"]>,
+ getProviderAccountSnapshots: (provider, options) => rpc("getProviderAccountSnapshots", [provider, options]) as ReturnType["getProviderAccountSnapshots"]>,
+ getProviderCatalogModels: (request) => rpc("getProviderCatalogModels", [request]) as ReturnType["getProviderCatalogModels"]>,
+ getProviderPresets: () => rpc("getProviderPresets") as ReturnType["getProviderPresets"]>,
+ getProxyCertificateStatus: () => rpc("getProxyCertificateStatus") as ReturnType["getProxyCertificateStatus"]>,
+ getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType["getProxyNetworkCaptures"]>,
+ getProxyStatus: () => rpc("getProxyStatus") as ReturnType["getProxyStatus"]>,
+ getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType["getRequestLogs"]>,
+ getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType["getUpdateStatus"]>,
+ getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType["getUsageStats"]>,
+ importLocalAgentProvider: (request) => rpc("importLocalAgentProvider", [request]) as ReturnType["importLocalAgentProvider"]>,
+ installProxyCertificate: () => rpc("installProxyCertificate") as ReturnType["installProxyCertificate"]>,
+ listMcpServerTools: (serverName) => rpc("listMcpServerTools", [serverName]) as ReturnType["listMcpServerTools"]>,
+ onBeforeQuit: noopSubscription,
+ onOpenSettingsRequest: noopSubscription,
+ onOpenUpdateRequest: noopSubscription,
+ onProviderDeepLink: noopSubscription,
+ onUpdateStatusChanged: noopSubscription,
+ openBotGatewayQrWindow: (request) => rpc("openBotGatewayQrWindow", [request]) as ReturnType["openBotGatewayQrWindow"]>,
+ openBuiltInBrowser: () => rpc("openBuiltInBrowser") as ReturnType["openBuiltInBrowser"]>,
+ openExternal: (url) => {
+ window.open(url, "_blank", "noopener,noreferrer");
+ return Promise.resolve();
+ },
+ openProfile: (request) => rpc("openProfile", [request]) as ReturnType["openProfile"]>,
+ probeProvider: (request) => rpc("probeProvider", [request]) as ReturnType["probeProvider"]>,
+ probeProviderCandidates: (request) => rpc("probeProviderCandidates", [request]) as ReturnType["probeProviderCandidates"]>,
+ quitApp: () => rpc("quitApp") as ReturnType["quitApp"]>,
+ restartGateway: () => rpc("restartGateway") as ReturnType["restartGateway"]>,
+ restartProxy: () => rpc("restartProxy") as ReturnType["restartProxy"]>,
+ revealProxyCertificate: () => rpc("revealProxyCertificate") as ReturnType["revealProxyCertificate"]>,
+ saveApiKeys: (apiKeys) => rpc("saveApiKeys", [apiKeys]) as ReturnType["saveApiKeys"]>,
+ saveConfig: (config, options) => rpc("saveConfig", [config, options]) as ReturnType["saveConfig"]>,
+ scanBotHandoffBluetoothTargets: () => rpc("scanBotHandoffBluetoothTargets") as ReturnType["scanBotHandoffBluetoothTargets"]>,
+ scanBotHandoffWifiTargets: () => rpc("scanBotHandoffWifiTargets") as ReturnType["scanBotHandoffWifiTargets"]>,
+ selectPluginDirectory: () => selectPluginDirectory() as ReturnType["selectPluginDirectory"]>,
+ setOnboardingFinished: () => rpc("setOnboardingFinished") as ReturnType["setOnboardingFinished"]>,
+ setProxyNetworkCaptureEnabled: (enabled) => rpc("setProxyNetworkCaptureEnabled", [enabled]) as ReturnType["setProxyNetworkCaptureEnabled"]>,
+ setTrayDetailOpen: () => Promise.resolve(),
+ showMainWindow: () => Promise.resolve(),
+ startBotGatewayQrLogin: (request) => rpc("startBotGatewayQrLogin", [request]) as ReturnType["startBotGatewayQrLogin"]>,
+ startGateway: () => rpc("startGateway") as ReturnType["startGateway"]>,
+ stopGateway: () => rpc("stopGateway") as ReturnType["stopGateway"]>,
+ stopProfile: (request) => rpc("stopProfile", [request]) as ReturnType["stopProfile"]>,
+ testProviderAccountConnector: (request) => rpc("testProviderAccountConnector", [request]) as ReturnType["testProviderAccountConnector"]>,
+ updateCheck: () => rpc("updateCheck") as ReturnType["updateCheck"]>,
+ updateDownload: () => rpc("updateDownload") as ReturnType["updateDownload"]>,
+ updateInstall: () => rpc("updateInstall") as ReturnType["updateInstall"]>,
+ waitBotGatewayQrLogin: (request) => rpc("waitBotGatewayQrLogin", [request]) as ReturnType["waitBotGatewayQrLogin"]>
+};
diff --git a/src/main/web-management-server.ts b/src/main/web-management-server.ts
new file mode 100644
index 0000000..c22697f
--- /dev/null
+++ b/src/main/web-management-server.ts
@@ -0,0 +1,1014 @@
+import { spawn } from "node:child_process";
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import packageJson from "../../package.json";
+import { loadPersistedAppSetting, replacePersistedAppSetting } from "./app-config-store";
+import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "./bot-handoff-scan-service";
+import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "./bot-gateway-qr-login-service";
+import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "./claude-app-gateway-service";
+import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config";
+import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants";
+import { detectProviderIcon } from "./provider-icons";
+import { fetchProviderManifest } from "./provider-manifest-service";
+import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "./local-agent-provider-service";
+import { getProviderCatalogModels } from "./provider-model-catalog";
+import { getProviderPresets } from "./presets";
+import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "./provider-probe";
+import { applyProfileConfig } from "./profile-service";
+import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "./profile-launch-service";
+import { ensureProxyCertificateAuthority } from "../server/proxy/certificates";
+import { proxyService } from "../server/proxy/service";
+import { listMcpServerTools } from "../server/mcp/tool-discovery";
+import { getAgentAnalysis, getAgentTracePayload, getRequestLogs } from "./request-log-store";
+import { getUsageStats } from "./usage-store";
+import { gatewayService } from "../server/gateway/service";
+import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, testProviderAccountConnector } from "./provider-account-service";
+import type {
+ AgentAnalysisFilter,
+ AgentAnalysisTracePayloadRequest,
+ ApiKeyConfig,
+ AppConfig,
+ AppDataExportResult,
+ AppInfo,
+ AppSaveConfigOptions,
+ AppUpdateStatus,
+ BotGatewayQrLoginCancelRequest,
+ BotGatewayQrLoginStartRequest,
+ BotGatewayQrLoginWaitRequest,
+ BotGatewayQrWindowOpenRequest,
+ GatewayPluginAppConfig,
+ GatewayProviderConnectivityCheckRequest,
+ GatewayProviderProbeCandidatesRequest,
+ GatewayProviderProbeRequest,
+ GatewayStatus,
+ LocalAgentProviderImportRequest,
+ PluginDependency,
+ PluginDirectorySelection,
+ PluginMarketplaceEntry,
+ ProfileApplyResult,
+ ProfileOpenRequest,
+ ProviderAccountSnapshotRequestOptions,
+ ProviderAccountTestRequest,
+ ProviderCatalogModelsRequest,
+ ProviderIconDetectionRequest,
+ ProviderManifestFetchRequest,
+ RequestLogListFilter,
+ UsageStatsFilter,
+ UsageStatsRange
+} from "../shared/app";
+
+export type WebManagementServerOptions = {
+ host?: string;
+ open?: boolean;
+ port?: number;
+ startGateway?: boolean;
+};
+
+export type WebManagementServerRuntime = {
+ close: () => Promise;
+ server: Server;
+ url: string;
+};
+
+type RpcRequest = {
+ args?: unknown[];
+ method?: string;
+};
+
+type RpcHandler = (...args: unknown[]) => Promise | unknown;
+
+const defaultWebHost = "127.0.0.1";
+const defaultWebPort = 3458;
+const onboardingFinishedAtSettingKey = "onboardingFinishedAt";
+const maxRpcBodyBytes = 8 * 1024 * 1024;
+const staticRoot = path.resolve(__dirname, "..", "renderer");
+const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html");
+const rendererAssetsRoot = path.join(staticRoot, "assets");
+const webBridgeScriptTag = ' ';
+
+const pluginMarketplace: PluginMarketplaceEntry[] = [
+ {
+ capabilities: ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"],
+ dependencies: [],
+ description: "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.",
+ id: "claude-design",
+ modulePath: path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs"),
+ name: "Claude Design"
+ },
+ {
+ capabilities: ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"],
+ dependencies: [],
+ description: "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.",
+ id: "cursor-proxy",
+ modulePath: path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs"),
+ name: "Cursor Proxy"
+ }
+];
+
+export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise {
+ const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost;
+ const requestedPort = options.port ?? readEnvPort("CCR_WEB_PORT") ?? defaultWebPort;
+ const server = createServer((request, response) => {
+ void handleRequest(request, response).catch((error) => {
+ sendJson(response, 500, { error: { message: formatError(error) }, ok: false });
+ });
+ });
+
+ const listenedPort = await listenWithFallback(server, requestedPort, host);
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : listenedPort;
+ const url = `http://${formatListenHost(host)}:${port}/`;
+
+ if (options.startGateway !== false) {
+ await startConfiguredServices("web startup");
+ }
+ if (options.open) {
+ await openSystemExternal(url).catch((error) => {
+ console.warn(`[web] Failed to open ${url}: ${formatError(error)}`);
+ });
+ }
+
+ return {
+ close: async () => {
+ await closeServer(server);
+ await stopConfiguredServices();
+ },
+ server,
+ url
+ };
+}
+
+async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise {
+ const url = requestUrl(request);
+ if (url.pathname === "/api/ccr/rpc") {
+ await handleRpcRequest(request, response);
+ return;
+ }
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ sendText(response, 405, "Method not allowed");
+ return;
+ }
+ if (url.pathname === "/" || url.pathname === "/pages/home/index.html") {
+ sendHomeHtml(response, request.method === "HEAD");
+ return;
+ }
+ if (url.pathname.startsWith("/assets/")) {
+ sendStaticFile(response, rendererAssetsRoot, decodeURIComponent(url.pathname.slice("/assets/".length)), request.method === "HEAD");
+ return;
+ }
+ sendText(response, 404, "Not found");
+}
+
+async function handleRpcRequest(request: IncomingMessage, response: ServerResponse): Promise {
+ if (request.method !== "POST") {
+ sendJson(response, 405, { error: { message: "RPC only supports POST." }, ok: false });
+ return;
+ }
+
+ let payload: RpcRequest;
+ try {
+ payload = JSON.parse((await readRequestBody(request, maxRpcBodyBytes)).toString("utf8")) as RpcRequest;
+ } catch (error) {
+ sendJson(response, 400, { error: { message: `Invalid JSON: ${formatError(error)}` }, ok: false });
+ return;
+ }
+
+ const method = typeof payload.method === "string" ? payload.method.trim() : "";
+ const handler = rpcHandlers[method];
+ if (!method || !handler) {
+ sendJson(response, 404, { error: { message: `Unknown CCR web RPC method: ${method || "(empty)"}` }, ok: false });
+ return;
+ }
+
+ try {
+ const value = await handler(...(Array.isArray(payload.args) ? payload.args : []));
+ sendJson(response, 200, { ok: true, value });
+ } catch (error) {
+ sendJson(response, 500, { error: { message: formatError(error) }, ok: false });
+ }
+}
+
+const unsupportedUpdateStatus: AppUpdateStatus = {
+ canCheck: false,
+ canDownload: false,
+ canInstall: false,
+ currentVersion: packageJson.version,
+ lastError: "Updates are only available in the desktop app.",
+ state: "idle",
+ supported: false
+};
+
+const rpcHandlers: Record = {
+ applyClaudeAppGateway: async (config?: unknown) => {
+ const previousConfig = await loadAppConfig();
+ const baseConfig = config ? await saveAppConfig(config as AppConfig) : previousConfig;
+ const synced = await syncClaudeAppGatewayConfig(baseConfig);
+ const savedConfig = synced.config;
+ let runtimeStatus = gatewayService.getStatus();
+ if (synced.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") {
+ runtimeStatus = await gatewayService.start(savedConfig);
+ } else {
+ gatewayService.updateConfig(savedConfig);
+ }
+ if (config || synced.configChanged) {
+ invalidateProviderAccountSnapshotCache();
+ }
+ const gatewayDetail = runtimeStatus.state === "running"
+ ? "CCR gateway is running."
+ : `CCR gateway did not start: ${runtimeStatus.lastError || "unknown error"}`;
+ const apiKeyDetail = synced.result.apiKeyGenerated ? "Generated a Claude App API key." : "Reused an existing CCR API key.";
+ return {
+ ...synced.result,
+ message: `${synced.result.message}\n${gatewayDetail}\n${apiKeyDetail}`
+ };
+ },
+ applyProfile: async () => applyProfileConfig(await loadAppConfig()),
+ cancelBotGatewayQrLogin: (request) => cancelBotGatewayQrLogin(request as BotGatewayQrLoginCancelRequest),
+ checkProviderConnectivity: (request) => checkGatewayProviderConnectivity(request as GatewayProviderConnectivityCheckRequest),
+ clearProxyNetworkCaptures: () => proxyService.clearNetworkCaptures(),
+ closeBotGatewayQrWindow: (_request) => ({ closed: false }),
+ detectProviderIcon: (request) => detectProviderIcon(request as ProviderIconDetectionRequest),
+ exportData: () => exportAppData(),
+ fetchProviderManifest: (request) => fetchProviderManifest(request as ProviderManifestFetchRequest),
+ getAgentAnalysis: (filter) => getAgentAnalysis(filter as AgentAnalysisFilter | undefined),
+ getAgentTracePayload: (request) => getAgentTracePayload(request as AgentAnalysisTracePayloadRequest),
+ getAppInfo: () => getCliAppInfo(),
+ getConfig: () => loadAppConfig(),
+ getGatewayStatus: () => gatewayService.getStatus(),
+ getLocalAgentProviderCandidates: () => getLocalAgentProviderCandidates(),
+ getOnboardingFinished: async () => Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)),
+ getPluginMarketplace: () => pluginMarketplace,
+ getProfileOpenCommand: async (request) => getProfileOpenCommand(await loadAppConfig(), request as ProfileOpenRequest),
+ getProfileRuntimeStatus: () => getProfileRuntimeStatus(),
+ getProviderAccountSnapshots: (provider, options) => getProviderAccountSnapshots(provider as string | undefined, options as ProviderAccountSnapshotRequestOptions | undefined),
+ getProviderCatalogModels: (request) => getProviderCatalogModels(request as ProviderCatalogModelsRequest),
+ getProviderPresets: () => getProviderPresets(),
+ getProxyCertificateStatus: () => proxyService.getCertificateStatus(),
+ getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(),
+ getProxyStatus: () => proxyService.getStatus(),
+ getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined),
+ getUpdateStatus: () => unsupportedUpdateStatus,
+ getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined),
+ importLocalAgentProvider: (request) => importLocalAgentProvider(request as LocalAgentProviderImportRequest),
+ installProxyCertificate: () => proxyService.installCertificate(),
+ listMcpServerTools: async (serverName) => {
+ const name = typeof serverName === "string" ? serverName.trim() : "";
+ if (!name) {
+ throw new Error("MCP server name is required.");
+ }
+ const config = await loadAppConfig();
+ const server = config.agent.mcpServers.find((candidate) => candidate.name === name);
+ if (!server) {
+ throw new Error("MCP server must be saved before tool discovery.");
+ }
+ return listMcpServerTools(server);
+ },
+ openBotGatewayQrWindow: async (request) => {
+ const qrRequest = request as BotGatewayQrWindowOpenRequest;
+ await openSystemExternal(qrRequest.url);
+ return {
+ message: "Opened the QR login URL in your default browser.",
+ observed: false,
+ opened: true
+ };
+ },
+ openBuiltInBrowser: async () => {
+ const config = await loadAppConfig();
+ const appUrl = firstConfiguredBrowserAppUrl(config) || "about:blank";
+ if (appUrl === "about:blank") {
+ throw new Error("No browser app is configured.");
+ }
+ await openSystemExternal(appUrl);
+ },
+ openProfile: async (request) => {
+ const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
+ const config = syncedClaudeAppConfig.config;
+ const status = await gatewayService.start(config);
+ if (status.state !== "running") {
+ throw new Error(status.lastError || "CCR gateway did not start.");
+ }
+ logProfileApplyResult(await applyProfileConfig(config));
+ return openProfileFromCcr(config, request as ProfileOpenRequest);
+ },
+ probeProvider: (request) => probeGatewayProvider(request as GatewayProviderProbeRequest),
+ probeProviderCandidates: (request) => probeGatewayProviderCandidates(request as GatewayProviderProbeCandidatesRequest),
+ quitApp: async () => {
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 50).unref();
+ },
+ restartGateway: async () => {
+ const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
+ const config = syncedClaudeAppConfig.config;
+ const status = await gatewayService.start(config);
+ await applyProfileIfServiceRunning(config, status);
+ return status;
+ },
+ restartProxy: async () => {
+ const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
+ const config = syncedClaudeAppConfig.config;
+ const status = await gatewayService.start(config);
+ await applyProfileIfServiceRunning(config, status);
+ return proxyService.getStatus();
+ },
+ revealProxyCertificate: async () => {
+ ensureProxyCertificateAuthority();
+ await revealFile(PROXY_CA_CERT_FILE);
+ },
+ saveApiKeys: async (apiKeys) => {
+ const savedConfig = await saveApiKeysConfig(apiKeys as ApiKeyConfig[]);
+ const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
+ const nextConfig = syncedClaudeAppConfig.config;
+ gatewayService.updateConfig(nextConfig);
+ logProfileApplyResult(await applyProfileConfig(nextConfig));
+ invalidateProviderAccountSnapshotCache();
+ return nextConfig;
+ },
+ saveConfig: async (config, options) => {
+ const previousConfig = await loadAppConfig();
+ const nextInput = config as AppConfig;
+ if (nextInput.proxy.enabled) {
+ const certificateStatus = await proxyService.getCertificateStatus();
+ if (!certificateStatus.trusted) {
+ throw new Error(certificateStatus.message);
+ }
+ }
+ let savedConfig = await saveAppConfig(nextInput);
+ const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
+ savedConfig = syncedClaudeAppConfig.config;
+ let runtimeStatus = gatewayService.getStatus();
+ if (syncedClaudeAppConfig.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig)) {
+ runtimeStatus = await gatewayService.start(savedConfig);
+ } else {
+ gatewayService.updateConfig(savedConfig);
+ }
+ if ((options as AppSaveConfigOptions | undefined)?.applyProfile !== false) {
+ await applyProfileIfServiceRunning(savedConfig, runtimeStatus);
+ }
+ invalidateProviderAccountSnapshotCache();
+ return savedConfig;
+ },
+ scanBotHandoffBluetoothTargets: () => scanBotHandoffBluetoothTargets(),
+ scanBotHandoffWifiTargets: () => scanBotHandoffWifiTargets(),
+ selectPluginDirectory: (directory) => inspectPluginDirectory(readRequiredString(directory, "Plugin directory path is required.")),
+ setOnboardingFinished: async () => {
+ await replacePersistedAppSetting(onboardingFinishedAtSettingKey, new Date().toISOString());
+ return true;
+ },
+ setProxyNetworkCaptureEnabled: (enabled) => proxyService.setNetworkCaptureEnabled(Boolean(enabled)),
+ startBotGatewayQrLogin: (request) => startBotGatewayQrLogin(request as BotGatewayQrLoginStartRequest),
+ startGateway: async () => {
+ const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
+ const config = syncedClaudeAppConfig.config;
+ const status = await gatewayService.start(config);
+ await applyProfileIfServiceRunning(config, status);
+ return status;
+ },
+ stopGateway: () => gatewayService.stop(),
+ stopProfile: async (request) => stopProfileFromCcr(await loadAppConfig(), request as ProfileOpenRequest),
+ testProviderAccountConnector: (request) => testProviderAccountConnector(request as ProviderAccountTestRequest),
+ updateCheck: () => unsupportedUpdateStatus,
+ updateDownload: () => unsupportedUpdateStatus,
+ updateInstall: () => {
+ throw new Error("Updates are only available in the desktop app.");
+ },
+ waitBotGatewayQrLogin: (request) => waitBotGatewayQrLogin(request as BotGatewayQrLoginWaitRequest)
+};
+
+async function startConfiguredServices(reason: string): Promise {
+ try {
+ let config = await loadAppConfig();
+ try {
+ config = (await syncClaudeAppGatewayConfig(config)).config;
+ } catch (error) {
+ console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
+ }
+ const status = await gatewayService.start(config);
+ if (status.state === "error") {
+ console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
+ }
+ if (status.state === "running") {
+ const profileResult = await applyProfileConfig(config);
+ logProfileApplyResult(profileResult);
+ }
+ if (config.proxy.enabled && config.proxy.systemProxy) {
+ const proxyStatus = await proxyService.ensureSystemProxyActive();
+ if (proxyStatus.systemProxy.state !== "active") {
+ const details = proxyStatus.systemProxy.lastError ? `: ${proxyStatus.systemProxy.lastError}` : "";
+ console.error(`Proxy mode is enabled, but system proxy is ${proxyStatus.systemProxy.state} during ${reason}${details}`);
+ }
+ }
+ } catch (error) {
+ console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`);
+ }
+}
+
+async function stopConfiguredServices(): Promise {
+ await gatewayService.stop({ proxyRestoreTimeoutMs: 30_000 }).catch((error) => {
+ console.error(`Failed to stop gateway: ${formatError(error)}`);
+ });
+ try {
+ restoreClaudeAppGatewayConfig();
+ } catch (error) {
+ console.error(`Failed to restore Claude App gateway config: ${formatError(error)}`);
+ }
+}
+
+async function applyProfileIfServiceRunning(config: AppConfig, status: GatewayStatus): Promise {
+ if (status.state !== "running") {
+ return;
+ }
+ logProfileApplyResult(await applyProfileConfig(config));
+}
+
+function logProfileApplyResult(result: ProfileApplyResult): void {
+ for (const client of result.clients) {
+ if (!client.ok) {
+ console.warn(`[profile:${client.client}] ${client.message}`);
+ }
+ }
+}
+
+function shouldRestartForRuntimeChange(previousConfig: AppConfig, nextConfig: AppConfig): boolean {
+ return (
+ previousConfig.gateway.enabled !== nextConfig.gateway.enabled ||
+ previousConfig.gateway.host !== nextConfig.gateway.host ||
+ previousConfig.gateway.port !== nextConfig.gateway.port ||
+ previousConfig.gateway.coreHost !== nextConfig.gateway.coreHost ||
+ previousConfig.gateway.corePort !== nextConfig.gateway.corePort ||
+ previousConfig.proxy.enabled !== nextConfig.proxy.enabled ||
+ previousConfig.proxy.host !== nextConfig.proxy.host ||
+ previousConfig.proxy.mode !== nextConfig.proxy.mode ||
+ previousConfig.proxy.port !== nextConfig.proxy.port ||
+ previousConfig.proxy.systemProxy !== nextConfig.proxy.systemProxy ||
+ JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) ||
+ JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) ||
+ JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) ||
+ JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) ||
+ JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) ||
+ JSON.stringify(previousConfig.virtualModelProfiles) !== JSON.stringify(nextConfig.virtualModelProfiles)
+ );
+}
+
+function getCliAppInfo(): AppInfo {
+ return {
+ appConfigDbFile: APP_CONFIG_DB_FILE,
+ apiKeysDbFile: API_KEYS_DB_FILE,
+ configDir: CONFIGDIR,
+ configFile: CONFIG_FILE,
+ dataDir: DATADIR,
+ gatewayConfigFile: GATEWAY_CONFIG_FILE,
+ name: APP_NAME,
+ platform: process.platform,
+ requestLogsDbFile: REQUEST_LOGS_DB_FILE,
+ usageDbFile: USAGE_DB_FILE,
+ version: packageJson.version
+ };
+}
+
+function sendHomeHtml(response: ServerResponse, headOnly: boolean): void {
+ if (!existsSync(homeHtmlFile)) {
+ sendText(response, 500, "CCR renderer assets were not found. Run npm run build:assets first.");
+ return;
+ }
+ let html = readFileSync(homeHtmlFile, "utf8");
+ if (!html.includes("web-client-bridge.js")) {
+ html = html.replace(' ', `${webBridgeScriptTag}\n `);
+ }
+ sendBuffer(response, 200, Buffer.from(html, "utf8"), "text/html; charset=utf-8", headOnly);
+}
+
+function sendStaticFile(response: ServerResponse, root: string, relativePath: string, headOnly: boolean): void {
+ const normalizedRelativePath = relativePath.replace(/^[/\\]+/, "");
+ const file = path.resolve(root, normalizedRelativePath);
+ const resolvedRoot = path.resolve(root);
+ if (!file.startsWith(`${resolvedRoot}${path.sep}`) && file !== resolvedRoot) {
+ sendText(response, 403, "Forbidden");
+ return;
+ }
+ if (!isFile(file)) {
+ sendText(response, 404, "Not found");
+ return;
+ }
+ sendBuffer(response, 200, readFileSync(file), contentTypeForFile(file), headOnly);
+}
+
+function sendBuffer(response: ServerResponse, status: number, body: Buffer, contentType: string, headOnly: boolean): void {
+ response.writeHead(status, {
+ "cache-control": "no-store",
+ "content-length": body.length,
+ "content-type": contentType
+ });
+ if (headOnly) {
+ response.end();
+ return;
+ }
+ response.end(body);
+}
+
+function sendJson(response: ServerResponse, status: number, payload: unknown): void {
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
+ response.writeHead(status, {
+ "cache-control": "no-store",
+ "content-length": body.length,
+ "content-type": "application/json; charset=utf-8"
+ });
+ response.end(body);
+}
+
+function sendText(response: ServerResponse, status: number, text: string): void {
+ sendBuffer(response, status, Buffer.from(`${text}\n`, "utf8"), "text/plain; charset=utf-8", false);
+}
+
+function requestUrl(request: IncomingMessage): URL {
+ return new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
+}
+
+function readRequestBody(request: IncomingMessage, maxBytes: number): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ let totalBytes = 0;
+ request.on("data", (chunk: Buffer) => {
+ totalBytes += chunk.length;
+ if (totalBytes > maxBytes) {
+ reject(new Error(`Request body exceeds ${maxBytes} bytes.`));
+ request.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ request.once("end", () => resolve(Buffer.concat(chunks)));
+ request.once("error", reject);
+ });
+}
+
+async function listenWithFallback(server: Server, port: number, host: string): Promise {
+ let candidate = port;
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ try {
+ await listen(server, candidate, host);
+ return candidate;
+ } catch (error) {
+ if (!isAddressInUseError(error) || candidate >= 65535) {
+ throw error;
+ }
+ candidate += 1;
+ }
+ }
+ throw new Error(`No available CCR web management port found starting at ${port}.`);
+}
+
+function listen(server: Server, port: number, host: string): Promise {
+ return new Promise((resolve, reject) => {
+ const onError = (error: Error) => {
+ server.off("listening", onListening);
+ reject(error);
+ };
+ const onListening = () => {
+ server.off("error", onError);
+ resolve();
+ };
+ server.once("error", onError);
+ server.once("listening", onListening);
+ server.listen(port, host);
+ });
+}
+
+function closeServer(server: Server): Promise {
+ return new Promise((resolve, reject) => {
+ server.close((error) => error ? reject(error) : resolve());
+ });
+}
+
+async function exportAppData(): Promise {
+ const exportedAt = new Date().toISOString();
+ const exportDir = defaultExportDir();
+ mkdirSync(exportDir, { recursive: true });
+ const file = path.join(exportDir, `claude-code-router-data-${fileSafeTimestamp(exportedAt)}.json`);
+ assertExportTargetIsNotInternalDataFile(file);
+ const payload = {
+ app: {
+ name: APP_NAME,
+ platform: process.platform,
+ version: packageJson.version
+ },
+ appState: {
+ onboardingFinished: Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE))
+ },
+ config: await loadAppConfig(),
+ exportedAt,
+ files: readDataExportFiles(),
+ kind: "claude-code-router-data-export",
+ version: 1
+ };
+
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
+ return {
+ canceled: false,
+ exportedAt,
+ file
+ };
+}
+
+function defaultExportDir(): string {
+ const downloads = path.join(os.homedir(), "Downloads");
+ return existsSync(downloads) ? downloads : CONFIGDIR;
+}
+
+function readDataExportFiles(): Array<{ base64: string; name: string; path: string; sizeBytes: number }> {
+ const files: Array<{ base64: string; name: string; path: string; sizeBytes: number }> = [];
+ for (const file of dataExportCandidateFiles()) {
+ try {
+ if (!isFile(file)) {
+ continue;
+ }
+ const stat = statSync(file);
+ files.push({
+ base64: readFileSync(file).toString("base64"),
+ name: path.basename(file),
+ path: file,
+ sizeBytes: stat.size
+ });
+ } catch (error) {
+ console.warn(`[export] Failed to include ${file}: ${formatError(error)}`);
+ }
+ }
+ return files;
+}
+
+function dataExportCandidateFiles(): string[] {
+ return uniqueStrings([
+ ...sqliteDataFiles(APP_CONFIG_DB_FILE),
+ ...sqliteDataFiles(API_KEYS_DB_FILE),
+ ...sqliteDataFiles(REQUEST_LOGS_DB_FILE),
+ ...sqliteDataFiles(USAGE_DB_FILE)
+ ]);
+}
+
+function sqliteDataFiles(file: string): string[] {
+ return [file, `${file}-wal`, `${file}-shm`];
+}
+
+function assertExportTargetIsNotInternalDataFile(file: string): void {
+ const target = path.resolve(file);
+ const reserved = new Set([
+ CONFIG_FILE,
+ LEGACY_CONFIG_FILE,
+ APP_CONFIG_DB_FILE,
+ API_KEYS_DB_FILE,
+ REQUEST_LOGS_DB_FILE,
+ USAGE_DB_FILE,
+ ...dataExportCandidateFiles()
+ ].map((item) => path.resolve(item)));
+ if (reserved.has(target)) {
+ throw new Error("Choose a different export path. Internal CCR data files cannot be overwritten.");
+ }
+}
+
+function inspectPluginDirectory(directory: string): PluginDirectorySelection {
+ const manifest = readFirstJson([
+ path.join(directory, "plugin.json"),
+ path.join(directory, "ccr-plugin.json"),
+ path.join(directory, ".ccr-plugin", "plugin.json"),
+ path.join(directory, ".codex-plugin", "plugin.json")
+ ]);
+ const packageJsonManifest = readFirstJson([path.join(directory, "package.json")]);
+ const moduleValue =
+ readString(manifest?.module) ||
+ readString(manifest?.main) ||
+ readString(manifest?.path) ||
+ readString(readRecord(packageJsonManifest?.ccr)?.module) ||
+ readString(readRecord(packageJsonManifest?.ccrPlugin)?.module) ||
+ readString(packageJsonManifest?.main);
+ const id =
+ pluginIdValue(readString(manifest?.id) || readString(manifest?.key) || readString(packageJsonManifest?.name)) ||
+ pluginIdValue(path.basename(directory)) ||
+ "plugin";
+ const name = readString(manifest?.name) || readString(packageJsonManifest?.displayName) || readString(packageJsonManifest?.name);
+ const apps = readPluginApps(manifest, packageJsonManifest);
+ return {
+ ...(apps.length ? { apps } : {}),
+ dependencies: readPluginDependencies(directory, manifest, packageJsonManifest),
+ directory,
+ id,
+ modulePath: resolvePluginDirectoryModule(directory, moduleValue),
+ ...(name ? { name } : {})
+ };
+}
+
+function readPluginApps(
+ manifest: Record | undefined,
+ packageJsonManifest: Record | undefined
+): GatewayPluginAppConfig[] {
+ const values = [
+ manifest?.apps,
+ readRecord(manifest?.ccr)?.apps,
+ readRecord(manifest?.ccrPlugin)?.apps,
+ readRecord(packageJsonManifest?.ccr)?.apps,
+ readRecord(packageJsonManifest?.ccrPlugin)?.apps
+ ];
+ const apps = values.flatMap(parsePluginApps);
+ const byId = new Map();
+ for (const app of apps) {
+ const key = app.id || `${app.name}:${app.url}`;
+ if (!byId.has(key)) {
+ byId.set(key, app);
+ }
+ }
+ return [...byId.values()];
+}
+
+function parsePluginApps(value: unknown): GatewayPluginAppConfig[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value.map(parsePluginAppItem).filter((item): item is GatewayPluginAppConfig => Boolean(item));
+}
+
+function parsePluginAppItem(value: unknown): GatewayPluginAppConfig | undefined {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return undefined;
+ }
+
+ const record = value as Record;
+ const name = readString(record.name) || readString(record.title);
+ const url = readString(record.url) || readString(record.href) || readString(record.target);
+ if (!name || !url) {
+ return undefined;
+ }
+ const id = pluginIdValue(readString(record.id) || name);
+ const description = readString(record.description);
+ const icon = readString(record.icon);
+ return {
+ ...(description ? { description } : {}),
+ ...(icon ? { icon } : {}),
+ ...(id ? { id } : {}),
+ name,
+ url
+ };
+}
+
+function readPluginDependencies(
+ directory: string,
+ manifest: Record | undefined,
+ packageJsonManifest: Record | undefined
+): PluginDependency[] {
+ const values = [
+ manifest?.dependencies,
+ manifest?.pluginDependencies,
+ readRecord(manifest?.ccr)?.dependencies,
+ readRecord(manifest?.ccrPlugin)?.dependencies,
+ readRecord(packageJsonManifest?.ccr)?.dependencies,
+ readRecord(packageJsonManifest?.ccrPlugin)?.dependencies
+ ];
+ const dependencies = values.flatMap((value) => parsePluginDependencies(value, directory));
+ const byId = new Map();
+ for (const dependency of dependencies) {
+ if (dependency.id && !byId.has(dependency.id)) {
+ byId.set(dependency.id, dependency);
+ }
+ }
+ return [...byId.values()];
+}
+
+function parsePluginDependencies(value: unknown, directory: string): PluginDependency[] {
+ if (Array.isArray(value)) {
+ return value.map((item) => parsePluginDependencyItem(item, directory)).filter((item): item is PluginDependency => Boolean(item));
+ }
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return Object.entries(value as Record)
+ .map(([id, item]) => parsePluginDependencyEntry(id, item, directory))
+ .filter((item): item is PluginDependency => Boolean(item));
+ }
+ return [];
+}
+
+function parsePluginDependencyEntry(idValue: string, value: unknown, directory: string): PluginDependency | undefined {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return parsePluginDependencyItem({ id: idValue, ...(value as Record) }, directory);
+ }
+
+ const id = pluginIdValue(idValue);
+ if (!id) {
+ return undefined;
+ }
+ const specifier = readString(value);
+ const modulePath = specifier && looksLikeDependencyModulePath(specifier) ? resolveDependencyModulePath(directory, specifier) : undefined;
+ return {
+ id,
+ ...(modulePath ? { modulePath } : {})
+ };
+}
+
+function parsePluginDependencyItem(value: unknown, directory: string): PluginDependency | undefined {
+ if (typeof value === "string") {
+ const id = pluginIdValue(value);
+ return id ? { id } : undefined;
+ }
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return undefined;
+ }
+
+ const record = value as Record;
+ const id = pluginIdValue(readString(record.id) || readString(record.key) || readString(record.name));
+ if (!id) {
+ return undefined;
+ }
+ const moduleValue = readString(record.module) || readString(record.path) || readString(record.modulePath);
+ const modulePath = moduleValue ? resolveDependencyModulePath(directory, moduleValue) : undefined;
+ const name = readString(record.name);
+ return {
+ id,
+ ...(modulePath ? { modulePath } : {}),
+ ...(name ? { name } : {})
+ };
+}
+
+function resolveDependencyModulePath(directory: string, value: string): string {
+ if (value === "~" || value.startsWith("~/")) {
+ return path.join(os.homedir(), value.slice(2));
+ }
+ return path.isAbsolute(value) ? value : path.join(directory, value);
+}
+
+function looksLikeDependencyModulePath(value: string): boolean {
+ return value.startsWith(".") || value.startsWith("/") || value.startsWith("~");
+}
+
+function resolvePluginDirectoryModule(directory: string, moduleValue: string | undefined): string {
+ if (moduleValue) {
+ return path.isAbsolute(moduleValue) ? moduleValue : path.join(directory, moduleValue);
+ }
+
+ for (const filename of ["index.cjs", "index.mjs", "index.js", "plugin.cjs", "plugin.mjs", "plugin.js"]) {
+ const candidate = path.join(directory, filename);
+ if (isFile(candidate)) {
+ return candidate;
+ }
+ }
+
+ return directory;
+}
+
+function readFirstJson(files: string[]): Record | undefined {
+ for (const file of files) {
+ if (!isFile(file)) {
+ continue;
+ }
+ try {
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return parsed as Record;
+ }
+ } catch {
+ // Ignore invalid plugin metadata and fall back to directory inference.
+ }
+ }
+ return undefined;
+}
+
+function readRecord(value: unknown): Record | undefined {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined;
+}
+
+function firstConfiguredBrowserAppUrl(config: AppConfig): string | undefined {
+ for (const plugin of config.plugins) {
+ if (plugin.enabled === false) {
+ continue;
+ }
+ const app = plugin.apps?.find((candidate) => readString(candidate.url));
+ if (app?.url) {
+ return app.url;
+ }
+ }
+ return undefined;
+}
+
+async function revealFile(file: string): Promise {
+ if (process.platform === "darwin") {
+ await execDetached("/usr/bin/open", ["-R", file]);
+ return;
+ }
+ if (process.platform === "win32") {
+ await execDetached("explorer.exe", ["/select,", file]);
+ return;
+ }
+ await openSystemExternal(pathToFileURL(path.dirname(file)).toString());
+}
+
+function openSystemExternal(target: string): Promise {
+ if (!target || target === "about:blank") {
+ return Promise.resolve();
+ }
+ if (process.platform === "darwin") {
+ return execDetached("/usr/bin/open", [target]);
+ }
+ if (process.platform === "win32") {
+ return execDetached("cmd.exe", ["/d", "/s", "/c", "start", "", target]);
+ }
+ return execDetached("xdg-open", [target]);
+}
+
+function execDetached(command: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ detached: true,
+ stdio: "ignore",
+ windowsHide: true
+ });
+ child.once("error", reject);
+ child.once("spawn", () => {
+ child.unref();
+ resolve();
+ });
+ });
+}
+
+function contentTypeForFile(file: string): string {
+ switch (path.extname(file).toLowerCase()) {
+ case ".css":
+ return "text/css; charset=utf-8";
+ case ".gif":
+ return "image/gif";
+ case ".html":
+ return "text/html; charset=utf-8";
+ case ".ico":
+ return "image/x-icon";
+ case ".js":
+ return "text/javascript; charset=utf-8";
+ case ".jpg":
+ case ".jpeg":
+ return "image/jpeg";
+ case ".png":
+ return "image/png";
+ case ".svg":
+ return "image/svg+xml";
+ case ".webp":
+ return "image/webp";
+ default:
+ return "application/octet-stream";
+ }
+}
+
+function readRequiredString(value: unknown, message: string): string {
+ const text = readString(value);
+ if (!text) {
+ throw new Error(message);
+ }
+ return text;
+}
+
+function readString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function readEnvString(key: string): string | undefined {
+ return readString(process.env[key]);
+}
+
+function readEnvPort(key: string): number | undefined {
+ const value = Number(process.env[key]);
+ return Number.isInteger(value) && value > 0 && value < 65536 ? value : undefined;
+}
+
+function pluginIdValue(value: string | undefined): string {
+ return value?.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "";
+}
+
+function isFile(file: string): boolean {
+ try {
+ return existsSync(file) && statSync(file).isFile();
+ } catch {
+ return false;
+ }
+}
+
+function uniqueStrings(values: string[]): string[] {
+ const seen = new Set();
+ const result: string[] = [];
+ for (const value of values) {
+ if (!value || seen.has(value)) {
+ continue;
+ }
+ seen.add(value);
+ result.push(value);
+ }
+ return result;
+}
+
+function fileSafeTimestamp(value: string): string {
+ return value.replace(/[:.]/g, "-");
+}
+
+function formatListenHost(host: string): string {
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+}
+
+function isAddressInUseError(error: unknown): boolean {
+ return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EADDRINUSE";
+}
+
+function formatError(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/src/server/mcp/network-capture-mcp.ts b/src/server/mcp/network-capture-mcp.ts
index bbc3601..21759ce 100644
--- a/src/server/mcp/network-capture-mcp.ts
+++ b/src/server/mcp/network-capture-mcp.ts
@@ -1,4 +1,5 @@
-import { app } from "electron";
+import * as electron from "electron";
+import packageJson from "../../../package.json";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ProxyNetworkExchange } from "../../shared/app";
import { proxyService } from "../proxy/service";
@@ -151,7 +152,7 @@ async function handleJsonRpcRequest(payload: unknown): Promise {
if (!proxyService.isNetworkCaptureEnabled()) {
throw new Error("Network capture MCP is disabled.");
diff --git a/src/server/proxy/service.ts b/src/server/proxy/service.ts
index 2af626f..eb8f031 100644
--- a/src/server/proxy/service.ts
+++ b/src/server/proxy/service.ts
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
-import { BrowserWindow, dialog, shell } from "electron";
+import * as electron from "electron";
import { chmodSync, writeFileSync } from "node:fs";
import http, { type ClientRequest, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import https from "node:https";
@@ -398,7 +398,7 @@ class ProxyService {
const installerFile = await openMacosTerminalCertificateInstaller();
terminalMessage = ` Opened Terminal installer: ${installerFile}`;
} catch (terminalError) {
- shell.showItemInFolder(PROXY_CA_CERT_FILE);
+ electron.shell?.showItemInFolder?.(PROXY_CA_CERT_FILE);
terminalMessage = ` Could not open Terminal installer: ${formatError(terminalError)}`;
}
const status = await this.getCertificateStatus();
@@ -1470,7 +1470,10 @@ function execFilePromise(file: string, args: string[]): Promise {
}
async function requestMacosCertificateInstallPermission(): Promise {
- const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
+ if (!electron.dialog || !electron.BrowserWindow) {
+ return true;
+ }
+ const window = electron.BrowserWindow.getFocusedWindow() ?? electron.BrowserWindow.getAllWindows()[0];
const options = {
buttons: ["Continue", "Cancel"],
cancelId: 1,
@@ -1481,7 +1484,7 @@ async function requestMacosCertificateInstallPermission(): Promise {
noLink: true,
type: "warning" as const
};
- const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options);
+ const result = window ? await electron.dialog.showMessageBox(window, options) : await electron.dialog.showMessageBox(options);
return result.response === 0;
}
@@ -1552,10 +1555,14 @@ async function openMacosTerminalCertificateInstaller(): Promise {
const installerFile = path.join(os.tmpdir(), `ccr-install-proxy-ca-${randomUUID()}.command`);
writeFileSync(installerFile, `${macosTerminalCertificateInstallScript()}\n`, "utf8");
chmodSync(installerFile, 0o700);
- const errorMessage = await shell.openPath(installerFile);
- if (errorMessage) {
- throw new Error(errorMessage);
+ if (electron.shell?.openPath) {
+ const errorMessage = await electron.shell.openPath(installerFile);
+ if (errorMessage) {
+ throw new Error(errorMessage);
+ }
+ return installerFile;
}
+ await execFilePromise("/usr/bin/open", [installerFile]);
return installerFile;
}
From 1534059fd6becc8880f6f43b16018d2837e3a5bf Mon Sep 17 00:00:00 2001
From: musistudio
Date: Mon, 29 Jun 2026 16:45:47 +0800
Subject: [PATCH 004/101] Refactor router config and provider handling
---
.github/workflows/release.yml | 106 ++++++-
README.md | 3 +-
README_zh.md | 3 +-
build/esbuild.config.mjs | 14 +-
build/merge-macos-update-metadata.mjs | 78 +++++
src/main/app-paths.ts | 65 ++++
src/main/claude-app-gateway-service.ts | 44 ++-
src/main/cli.ts | 29 +-
src/main/codex-cli-middleware-runtime.ts | 47 ++-
src/main/constants.ts | 28 +-
src/main/electron-node-shim.ts | 14 -
src/main/main-app.ts | 246 +++++++++++++++
src/main/main.ts | 219 +------------
src/main/profile-launch-service.ts | 374 +++++++++++++++++++++--
src/main/profile-service.ts | 9 +
src/server/gateway/service.ts | 174 +++++++++--
src/server/mcp/network-capture-mcp.ts | 3 +-
src/server/proxy/service.ts | 66 ++--
18 files changed, 1148 insertions(+), 374 deletions(-)
create mode 100644 build/merge-macos-update-metadata.mjs
create mode 100644 src/main/app-paths.ts
delete mode 100644 src/main/electron-node-shim.ts
create mode 100644 src/main/main-app.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5f2cbbd..409612d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,8 +14,24 @@ concurrency:
jobs:
macos:
- name: macOS
- runs-on: macos-14
+ name: macOS (${{ matrix.display_name }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: arm64
+ arch_flag: --arm64
+ runner: macos-14
+ display_name: Apple Silicon
+ release_label: Apple-Silicon
+ artifact_name: macos-apple-silicon-arm64
+ - arch: x64
+ arch_flag: --x64
+ runner: macos-15-intel
+ display_name: Intel
+ release_label: Intel
+ artifact_name: macos-intel-x64
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -39,16 +55,92 @@ jobs:
exit 1
fi
- - name: Build and publish ad-hoc macOS artifacts
+ - name: Build ad-hoc macOS artifacts
+ run: |
+ npm run build:assets
+ npx electron-builder \
+ --config build/electron-builder.local.cjs \
+ --mac ${{ matrix.arch_flag }} \
+ --publish never \
+ -c.mac.artifactName='Claude-Code-Router_${version}-mac-${{ matrix.release_label }}-${arch}.${ext}'
+
+ - name: Upload macOS release files
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact_name }}
+ path: |
+ release-local/Claude-Code-Router_*-mac-${{ matrix.release_label }}-${{ matrix.arch }}.*
+ release-local/latest-mac.yml
+ if-no-files-found: error
+
+ publish-macos:
+ name: Publish macOS
+ needs: macos
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install metadata merge dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Download macOS release files
+ uses: actions/download-artifact@v4
+ with:
+ pattern: macos-*
+ path: macos-release-artifacts
+
+ - name: Merge macOS update metadata
+ run: node build/merge-macos-update-metadata.mjs latest-mac.yml macos-release-artifacts/*/latest-mac.yml
+
+ - name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- npm run build:assets
- npx electron-builder --config build/electron-builder.local.cjs --mac --publish always
+ gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \
+ gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes
+
+ - name: Upload macOS release assets
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ shopt -s nullglob
+ assets=(macos-release-artifacts/*/Claude-Code-Router_*-mac-*.*)
+ if (( ${#assets[@]} == 0 )); then
+ echo "No macOS release assets were found."
+ exit 1
+ fi
+
+ upload_args=()
+ for asset in "${assets[@]}"; do
+ name="$(basename "$asset")"
+ case "$name" in
+ *Apple-Silicon-arm64.dmg) label="macOS Apple Silicon DMG (arm64)" ;;
+ *Apple-Silicon-arm64.zip) label="macOS Apple Silicon ZIP (arm64)" ;;
+ *Apple-Silicon-arm64.dmg.blockmap) label="macOS Apple Silicon DMG blockmap (arm64)" ;;
+ *Apple-Silicon-arm64.zip.blockmap) label="macOS Apple Silicon ZIP blockmap (arm64)" ;;
+ *Intel-x64.dmg) label="macOS Intel DMG (x64)" ;;
+ *Intel-x64.zip) label="macOS Intel ZIP (x64)" ;;
+ *Intel-x64.dmg.blockmap) label="macOS Intel DMG blockmap (x64)" ;;
+ *Intel-x64.zip.blockmap) label="macOS Intel ZIP blockmap (x64)" ;;
+ *) label="$name" ;;
+ esac
+ upload_args+=("${asset}#${label}")
+ done
+ upload_args+=("latest-mac.yml#macOS update metadata")
+
+ gh release upload "$GITHUB_REF_NAME" "${upload_args[@]}" --clobber
windows:
name: Windows
- needs: macos
+ needs: publish-macos
runs-on: windows-latest
steps:
- name: Check out repository
@@ -82,7 +174,7 @@ jobs:
linux:
name: Linux
- needs: macos
+ needs: publish-macos
runs-on: ubuntu-latest
steps:
- name: Check out repository
diff --git a/README.md b/README.md
index c123f22..1e4bde0 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,8 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st
1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
2. Download the package for your platform:
- - macOS: `Claude Code Router_.dmg` or `.zip`
+ - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip`
+ - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip`
- Windows: `Claude Code Router_.exe`
- Linux: `Claude Code Router_.AppImage`
3. Install and launch **Claude Code Router**.
diff --git a/README_zh.md b/README_zh.md
index 45f98cf..7c94376 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -62,7 +62,8 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
2. 按系统下载对应安装包:
- - macOS:`Claude Code Router_.dmg` 或 `.zip`
+ - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
+ - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip`
- Windows:`Claude Code Router_.exe`
- Linux:`Claude Code Router_.AppImage`
3. 安装并启动 **Claude Code Router**。
diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs
index 3283c23..9fc975f 100644
--- a/build/esbuild.config.mjs
+++ b/build/esbuild.config.mjs
@@ -140,7 +140,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {}
minify: mode === "production",
outdir: mainOutDir,
platform: "node",
- plugins: [electronNodeShimPlugin(), ...plugins],
+ plugins: [forbidCliElectronPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "node22"
};
@@ -292,12 +292,18 @@ function rendererAliasPlugin() {
};
}
-function electronNodeShimPlugin() {
+function forbidCliElectronPlugin() {
return {
- name: "electron-node-shim",
+ name: "forbid-cli-electron",
setup(build) {
build.onResolve({ filter: /^electron$/ }, () => {
- return { path: path.join(projectRoot, "src", "main", "electron-node-shim.ts") };
+ return {
+ errors: [
+ {
+ text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary."
+ }
+ ]
+ };
});
}
};
diff --git a/build/merge-macos-update-metadata.mjs b/build/merge-macos-update-metadata.mjs
new file mode 100644
index 0000000..d1e2da0
--- /dev/null
+++ b/build/merge-macos-update-metadata.mjs
@@ -0,0 +1,78 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import yaml from "js-yaml";
+
+const [outputFile, ...inputFiles] = process.argv.slice(2);
+
+if (!outputFile || inputFiles.length < 2) {
+ console.error("Usage: node build/merge-macos-update-metadata.mjs