Add CLI web service support and install docs

This commit is contained in:
musistudio
2026-06-29 10:28:58 +08:00
parent 1b0c481376
commit 8d306c562f
15 changed files with 1837 additions and 99 deletions
+22
View File
@@ -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 <profile-name> cli # launch the saved profile as a CLI
ccr <profile-name> 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 <host> --port <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_<version>.dmg` or `.zip`
+22
View File
@@ -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 <profile-name> cli # 以 CLI 方式启动保存的 profile
ccr <profile-name> app # 以 App 方式启动保存的 profile
```
Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host <host> --port <port>` 修改监听地址。
### 桌面应用
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
2. 按系统下载对应安装包:
- macOS`Claude Code Router_<version>.dmg``.zip`
+2 -1
View File
@@ -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" })
]);
+23 -5
View File
@@ -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);
}
+56 -2
View File
@@ -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 = [
+3
View File
@@ -44,6 +44,9 @@
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@babel/runtime": {
+30 -2
View File
@@ -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"
+21 -4
View File
@@ -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 {
+496 -75
View File
@@ -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<void> {
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<void> {
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<void> {
}
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<AppConfig>;
return normalizeCliConfig(parsed, jsonFile);
return options;
}
function readSqliteConfig(file: string): Partial<AppConfig> | undefined {
if (!existsSync(file)) {
return undefined;
}
let database: ReturnType<typeof createBetterSqliteDatabase> | 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<AppConfig>
: 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<AppConfig>, 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<void> {
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<void> {
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<void> {
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 <profile-name-or-id> [--cli|--app] [-- <agent args>]",
" ccr start [--host <host>] [--port <port>] [--open] [--no-gateway]",
" ccr stop",
" ccr <profile-name-or-id> <cli|app> [-- <agent args>]",
"",
"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 <host>] [--port <port>] [--open] [--no-gateway]",
"",
"Options:",
" --host <host> Management server host. Defaults to 127.0.0.1.",
" --port <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 <host>] [--port <port>] [--open] [--no-gateway]",
"",
"Options:",
" --host <host> Management server host. Defaults to 127.0.0.1.",
" --port <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<ServiceState>;
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<ServiceState | undefined> {
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<boolean> {
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<void> {
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<typeof spawn>): Promise<number> {
return new Promise((resolve) => {
child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1)));
+14
View File
@@ -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;
+1 -1
View File
@@ -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(
+112
View File
@@ -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<unknown> {
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<unknown> {
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<NonNullable<typeof window.ccr>["applyClaudeAppGateway"]>,
applyProfile: () => rpc("applyProfile") as ReturnType<NonNullable<typeof window.ccr>["applyProfile"]>,
cancelBotGatewayQrLogin: (request) => rpc("cancelBotGatewayQrLogin", [request]) as ReturnType<NonNullable<typeof window.ccr>["cancelBotGatewayQrLogin"]>,
checkProviderConnectivity: (request) => rpc("checkProviderConnectivity", [request]) as ReturnType<NonNullable<typeof window.ccr>["checkProviderConnectivity"]>,
clearProxyNetworkCaptures: () => rpc("clearProxyNetworkCaptures") as ReturnType<NonNullable<typeof window.ccr>["clearProxyNetworkCaptures"]>,
closeBotGatewayQrWindow: (request) => rpc("closeBotGatewayQrWindow", [request]) as ReturnType<NonNullable<typeof window.ccr>["closeBotGatewayQrWindow"]>,
closeTray: () => Promise.resolve(),
detectProviderIcon: (request) => rpc("detectProviderIcon", [request]) as ReturnType<NonNullable<typeof window.ccr>["detectProviderIcon"]>,
exportData: () => rpc("exportData") as ReturnType<NonNullable<typeof window.ccr>["exportData"]>,
fetchProviderManifest: (request) => rpc("fetchProviderManifest", [request]) as ReturnType<NonNullable<typeof window.ccr>["fetchProviderManifest"]>,
getAgentAnalysis: (filter) => rpc("getAgentAnalysis", [filter]) as ReturnType<NonNullable<typeof window.ccr>["getAgentAnalysis"]>,
getAgentTracePayload: (request) => rpc("getAgentTracePayload", [request]) as ReturnType<NonNullable<typeof window.ccr>["getAgentTracePayload"]>,
getAppInfo: () => rpc("getAppInfo") as ReturnType<NonNullable<typeof window.ccr>["getAppInfo"]>,
getConfig: () => rpc("getConfig") as ReturnType<NonNullable<typeof window.ccr>["getConfig"]>,
getGatewayStatus: () => rpc("getGatewayStatus") as ReturnType<NonNullable<typeof window.ccr>["getGatewayStatus"]>,
getLocalAgentProviderCandidates: () => rpc("getLocalAgentProviderCandidates") as ReturnType<NonNullable<typeof window.ccr>["getLocalAgentProviderCandidates"]>,
getOnboardingFinished: () => rpc("getOnboardingFinished") as ReturnType<NonNullable<typeof window.ccr>["getOnboardingFinished"]>,
getPendingProviderDeepLinks: () => Promise.resolve([]),
getPluginMarketplace: () => rpc("getPluginMarketplace") as ReturnType<NonNullable<typeof window.ccr>["getPluginMarketplace"]>,
getProfileOpenCommand: (request) => rpc("getProfileOpenCommand", [request]) as ReturnType<NonNullable<typeof window.ccr>["getProfileOpenCommand"]>,
getProfileRuntimeStatus: () => rpc("getProfileRuntimeStatus") as ReturnType<NonNullable<typeof window.ccr>["getProfileRuntimeStatus"]>,
getProviderAccountSnapshots: (provider, options) => rpc("getProviderAccountSnapshots", [provider, options]) as ReturnType<NonNullable<typeof window.ccr>["getProviderAccountSnapshots"]>,
getProviderCatalogModels: (request) => rpc("getProviderCatalogModels", [request]) as ReturnType<NonNullable<typeof window.ccr>["getProviderCatalogModels"]>,
getProviderPresets: () => rpc("getProviderPresets") as ReturnType<NonNullable<typeof window.ccr>["getProviderPresets"]>,
getProxyCertificateStatus: () => rpc("getProxyCertificateStatus") as ReturnType<NonNullable<typeof window.ccr>["getProxyCertificateStatus"]>,
getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType<NonNullable<typeof window.ccr>["getProxyNetworkCaptures"]>,
getProxyStatus: () => rpc("getProxyStatus") as ReturnType<NonNullable<typeof window.ccr>["getProxyStatus"]>,
getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType<NonNullable<typeof window.ccr>["getRequestLogs"]>,
getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType<NonNullable<typeof window.ccr>["getUpdateStatus"]>,
getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType<NonNullable<typeof window.ccr>["getUsageStats"]>,
importLocalAgentProvider: (request) => rpc("importLocalAgentProvider", [request]) as ReturnType<NonNullable<typeof window.ccr>["importLocalAgentProvider"]>,
installProxyCertificate: () => rpc("installProxyCertificate") as ReturnType<NonNullable<typeof window.ccr>["installProxyCertificate"]>,
listMcpServerTools: (serverName) => rpc("listMcpServerTools", [serverName]) as ReturnType<NonNullable<typeof window.ccr>["listMcpServerTools"]>,
onBeforeQuit: noopSubscription,
onOpenSettingsRequest: noopSubscription,
onOpenUpdateRequest: noopSubscription,
onProviderDeepLink: noopSubscription,
onUpdateStatusChanged: noopSubscription,
openBotGatewayQrWindow: (request) => rpc("openBotGatewayQrWindow", [request]) as ReturnType<NonNullable<typeof window.ccr>["openBotGatewayQrWindow"]>,
openBuiltInBrowser: () => rpc("openBuiltInBrowser") as ReturnType<NonNullable<typeof window.ccr>["openBuiltInBrowser"]>,
openExternal: (url) => {
window.open(url, "_blank", "noopener,noreferrer");
return Promise.resolve();
},
openProfile: (request) => rpc("openProfile", [request]) as ReturnType<NonNullable<typeof window.ccr>["openProfile"]>,
probeProvider: (request) => rpc("probeProvider", [request]) as ReturnType<NonNullable<typeof window.ccr>["probeProvider"]>,
probeProviderCandidates: (request) => rpc("probeProviderCandidates", [request]) as ReturnType<NonNullable<typeof window.ccr>["probeProviderCandidates"]>,
quitApp: () => rpc("quitApp") as ReturnType<NonNullable<typeof window.ccr>["quitApp"]>,
restartGateway: () => rpc("restartGateway") as ReturnType<NonNullable<typeof window.ccr>["restartGateway"]>,
restartProxy: () => rpc("restartProxy") as ReturnType<NonNullable<typeof window.ccr>["restartProxy"]>,
revealProxyCertificate: () => rpc("revealProxyCertificate") as ReturnType<NonNullable<typeof window.ccr>["revealProxyCertificate"]>,
saveApiKeys: (apiKeys) => rpc("saveApiKeys", [apiKeys]) as ReturnType<NonNullable<typeof window.ccr>["saveApiKeys"]>,
saveConfig: (config, options) => rpc("saveConfig", [config, options]) as ReturnType<NonNullable<typeof window.ccr>["saveConfig"]>,
scanBotHandoffBluetoothTargets: () => rpc("scanBotHandoffBluetoothTargets") as ReturnType<NonNullable<typeof window.ccr>["scanBotHandoffBluetoothTargets"]>,
scanBotHandoffWifiTargets: () => rpc("scanBotHandoffWifiTargets") as ReturnType<NonNullable<typeof window.ccr>["scanBotHandoffWifiTargets"]>,
selectPluginDirectory: () => selectPluginDirectory() as ReturnType<NonNullable<typeof window.ccr>["selectPluginDirectory"]>,
setOnboardingFinished: () => rpc("setOnboardingFinished") as ReturnType<NonNullable<typeof window.ccr>["setOnboardingFinished"]>,
setProxyNetworkCaptureEnabled: (enabled) => rpc("setProxyNetworkCaptureEnabled", [enabled]) as ReturnType<NonNullable<typeof window.ccr>["setProxyNetworkCaptureEnabled"]>,
setTrayDetailOpen: () => Promise.resolve(),
showMainWindow: () => Promise.resolve(),
startBotGatewayQrLogin: (request) => rpc("startBotGatewayQrLogin", [request]) as ReturnType<NonNullable<typeof window.ccr>["startBotGatewayQrLogin"]>,
startGateway: () => rpc("startGateway") as ReturnType<NonNullable<typeof window.ccr>["startGateway"]>,
stopGateway: () => rpc("stopGateway") as ReturnType<NonNullable<typeof window.ccr>["stopGateway"]>,
stopProfile: (request) => rpc("stopProfile", [request]) as ReturnType<NonNullable<typeof window.ccr>["stopProfile"]>,
testProviderAccountConnector: (request) => rpc("testProviderAccountConnector", [request]) as ReturnType<NonNullable<typeof window.ccr>["testProviderAccountConnector"]>,
updateCheck: () => rpc("updateCheck") as ReturnType<NonNullable<typeof window.ccr>["updateCheck"]>,
updateDownload: () => rpc("updateDownload") as ReturnType<NonNullable<typeof window.ccr>["updateDownload"]>,
updateInstall: () => rpc("updateInstall") as ReturnType<NonNullable<typeof window.ccr>["updateInstall"]>,
waitBotGatewayQrLogin: (request) => rpc("waitBotGatewayQrLogin", [request]) as ReturnType<NonNullable<typeof window.ccr>["waitBotGatewayQrLogin"]>
};
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -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<JsonRpcResponse |
serverInfo: {
name: "ccr-network-capture",
title: "CCR Network Capture",
version: app.getVersion()
version: appVersion()
}
});
case "ping":
@@ -168,6 +169,10 @@ async function handleJsonRpcRequest(payload: unknown): Promise<JsonRpcResponse |
}
}
function appVersion(): string {
return typeof electron.app?.getVersion === "function" ? electron.app.getVersion() : packageJson.version;
}
async function callTool(params: unknown): Promise<JsonValue> {
if (!proxyService.isNetworkCaptureEnabled()) {
throw new Error("Network capture MCP is disabled.");
+14 -7
View File
@@ -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<void> {
}
async function requestMacosCertificateInstallPermission(): Promise<boolean> {
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<boolean> {
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<string> {
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;
}