mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Add CLI web service support and install docs
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Generated
+3
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user