Bundle bot gateway SDK and simplify CLI delegation

This commit is contained in:
musistudio 2026-07-05 22:38:09 +08:00
parent 528a8e0efa
commit 5f92120114
13 changed files with 258 additions and 125 deletions

View file

@ -1,11 +1,12 @@
import esbuild from "esbuild";
import { spawn } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { builtinModules } from "node:module";
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { builtinModules, createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const requireFromHere = createRequire(import.meta.url);
export const projectRoot = path.resolve(__dirname, "..");
export const packagesRoot = path.join(projectRoot, "packages");
@ -27,6 +28,18 @@ export const cliMainOutDir = path.join(cliDistDir, "main");
export const coreMainOutDir = path.join(coreDistDir, "main");
export const electronMainOutDir = path.join(electronDistDir, "main");
export const mainOutDir = electronMainOutDir;
export const gatewayPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json"));
export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js");
export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js");
export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json"));
export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js");
export const botGatewaySdkRunnerInput = path.join(botGatewaySdkPackageRoot, "bin", "bot-gateway-stdio.mjs");
export const electronBotGatewaySdkRootDir = path.join(electronMainOutDir, "bot-gateway-sdk");
export const electronBotGatewaySdkDistDir = path.join(electronBotGatewaySdkRootDir, "dist");
export const electronBotGatewaySdkBinDir = path.join(electronBotGatewaySdkRootDir, "bin");
export const electronBotGatewaySdkPackageOutput = path.join(electronBotGatewaySdkRootDir, "package.json");
export const electronBotGatewaySdkEntryOutput = path.join(electronBotGatewaySdkDistDir, "index.js");
export const electronBotGatewaySdkRunnerOutput = path.join(electronBotGatewaySdkBinDir, "bot-gateway-stdio.mjs");
export const rendererOutDir = path.join(uiDistDir, "renderer");
export const cliRendererOutDir = path.join(cliDistDir, "renderer");
export const coreRendererOutDir = path.join(coreDistDir, "renderer");
@ -86,6 +99,8 @@ export function ensureDist() {
mkdirSync(cliMainOutDir, { recursive: true });
mkdirSync(coreMainOutDir, { recursive: true });
mkdirSync(electronMainOutDir, { recursive: true });
mkdirSync(electronBotGatewaySdkDistDir, { recursive: true });
mkdirSync(electronBotGatewaySdkBinDir, { recursive: true });
mkdirSync(appAssetsDir, { recursive: true });
mkdirSync(cliMarketplacePluginsDir, { recursive: true });
mkdirSync(coreMarketplacePluginsDir, { recursive: true });
@ -178,6 +193,18 @@ function hasScriptTag(html, scriptTag) {
return sourceMatch ? html.includes(sourceMatch[1]) : html.includes(scriptTag);
}
function normalizeDuplicateShebangs(source) {
const lines = source.split("\n");
if (!lines[0]?.startsWith("#!")) {
return source;
}
let index = 1;
while (lines[index]?.startsWith("#!")) {
index += 1;
}
return [lines[0], ...lines.slice(index)].join("\n");
}
export function createMainBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
@ -186,6 +213,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
entryPoints: [
path.join(electronSourceRoot, "main", "main.ts"),
path.join(electronSourceRoot, "main", "browser-preload.ts"),
gatewayRuntimeInput,
path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
@ -316,6 +344,27 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins
};
}
export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
entryPoints: [botGatewaySdkEntryInput],
external: [
...builtinModules,
...builtinModules.map((moduleName) => `node:${moduleName}`)
],
format: "esm",
legalComments: "none",
logLevel: "info",
minify: mode === "production",
outfile: electronBotGatewaySdkEntryOutput,
platform: "node",
plugins,
sourcemap: mode !== "production",
target: "node22"
};
}
export function watchPlugin(name, onEnd) {
return {
name: `${name}-watch`,
@ -332,6 +381,7 @@ export function watchPlugin(name, onEnd) {
export async function buildMain(options = {}) {
const [mainBuildResult] = await Promise.all([
esbuild.build(createMainBuildOptions(options)),
buildBotGatewaySdkRuntime(options),
buildCoreServer(options),
buildCli(options)
]);
@ -339,6 +389,22 @@ export async function buildMain(options = {}) {
validateLightweightMcpBundles(mainBuildResult.metafile);
}
export async function buildBotGatewaySdkRuntime(options = {}) {
ensureDist();
await esbuild.build(createBotGatewaySdkBuildOptions(options));
writeFileSync(
electronBotGatewaySdkPackageOutput,
`${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`,
"utf8"
);
writeFileSync(
electronBotGatewaySdkRunnerOutput,
normalizeDuplicateShebangs(readFileSync(botGatewaySdkRunnerInput, "utf8")),
"utf8"
);
chmodSync(electronBotGatewaySdkRunnerOutput, 0o755);
}
export async function buildCli(options = {}) {
await esbuild.build(createCliBuildOptions(options));
}

View file

@ -9,6 +9,19 @@ const betterSqliteNativeRelativePath = path.join(
"Release",
"better_sqlite3.node"
);
const betterSqlitePackageRelativePath = path.join("app.asar.unpacked", "node_modules", "better-sqlite3");
const betterSqlitePrunablePaths = [
"deps",
"src",
"binding.gyp",
"README.md",
"docs",
"benchmark",
"benchmarks",
"test",
path.join("build", "Release", "obj"),
path.join("build", "Release", "obj.target")
];
module.exports = async function verifyPackagedApp(context) {
const platform = context?.electronPlatformName;
@ -21,6 +34,7 @@ module.exports = async function verifyPackagedApp(context) {
const resourcesDir = findResourcesDir(appOutDir, platform);
assertFile(path.join(resourcesDir, "app.asar"), "Packaged app archive");
cleanupBetterSqlitePackage(resourcesDir);
const nativeModule = path.join(resourcesDir, betterSqliteNativeRelativePath);
assertFile(nativeModule, "better-sqlite3 native module");
@ -41,6 +55,13 @@ module.exports = async function verifyPackagedApp(context) {
}
};
function cleanupBetterSqlitePackage(resourcesDir) {
const packageDir = path.join(resourcesDir, betterSqlitePackageRelativePath);
for (const relativePath of betterSqlitePrunablePaths) {
fs.rmSync(path.join(packageDir, relativePath), { force: true, recursive: true });
}
}
function findResourcesDir(appOutDir, platform) {
if (platform !== "darwin") {
return path.join(appOutDir, "resources");

View file

@ -5,6 +5,7 @@
"asarUnpack": [
"**/*.node"
],
"electronLanguages": ["en-US", "zh-CN", "zh-TW", "zh_CN", "zh_TW"],
"npmRebuild": true,
"publish": [
{
@ -15,6 +16,7 @@
}
],
"directories": {
"app": "packages/electron",
"output": "release/${version}"
},
"afterPack": "build/verify-packaged-app.cjs",
@ -26,8 +28,15 @@
}
],
"files": [
"packages/electron/dist",
"package.json"
"dist",
"package.json",
"!node_modules/better-sqlite3/deps/**",
"!node_modules/better-sqlite3/src/**",
"!node_modules/better-sqlite3/binding.gyp",
"!node_modules/better-sqlite3/README.md",
"!node_modules/better-sqlite3/docs/**",
"!node_modules/better-sqlite3/benchmark/**",
"!node_modules/better-sqlite3/test/**"
],
"mac": {
"icon": "build/icon.icns",

3
package-lock.json generated
View file

@ -9579,6 +9579,9 @@
"name": "@claude-code-router/electron",
"version": "3.0.7",
"dependencies": {
"better-sqlite3": "^12.11.1"
},
"devDependencies": {
"electron-updater": "^6.8.9"
}
},

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
@ -56,14 +56,9 @@ const serviceStartTimeoutMs = 30_000;
const serviceStopTimeoutMs = 10_000;
const webAuthHeader = "x-ccr-web-auth";
const webAuthQueryParam = "ccr_web_token";
const defaultCliCommandName = "ccr";
async function main(): Promise<void> {
const delegatedExitCode = delegateManagedDesktopCliToExternalCli();
if (delegatedExitCode !== undefined) {
process.exitCode = delegatedExitCode;
return;
}
const options = parseArgs(process.argv.slice(2));
if (options.command === "start") {
if (options.help) {
@ -455,20 +450,21 @@ async function stopService(): Promise<void> {
}
function printHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
" ccr start [--host <host>] [--port <port>] [--open] [--no-gateway]",
" ccr ui [--host <host>] [--port <port>] [--no-gateway]",
" ccr stop",
" ccr <profile-name-or-id> [cli|app] [-- <agent args>]",
` ${command} start [--host <host>] [--port <port>] [--open] [--no-gateway]`,
` ${command} ui [--host <host>] [--port <port>] [--no-gateway]`,
` ${command} stop`,
` ${command} <profile-name-or-id> [cli|app] [-- <agent args>]`,
"",
"Examples:",
" ccr start",
" ccr ui",
" ccr stop",
" ccr Codex",
" ccr default-codex -- --model gpt-5-codex",
" ccr default-codex app"
` ${command} start`,
` ${command} ui`,
` ${command} stop`,
` ${command} Codex`,
` ${command} default-codex -- --model gpt-5-codex`,
` ${command} default-codex app`
].join("\n");
const stream = exitCode === 0 ? process.stdout : process.stderr;
stream.write(`${output}\n`);
@ -476,9 +472,10 @@ function printHelp(exitCode: number): void {
}
function printStartHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
" ccr start [--host <host>] [--port <port>] [--open] [--no-gateway]",
` ${command} start [--host <host>] [--port <port>] [--open] [--no-gateway]`,
"",
"Options:",
" --host <host> Management server host. Defaults to 127.0.0.1.",
@ -496,9 +493,10 @@ function printStartHelp(exitCode: number): void {
}
function printUiHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
" ccr ui [--host <host>] [--port <port>] [--no-gateway]",
` ${command} ui [--host <host>] [--port <port>] [--no-gateway]`,
"",
"Starts the background CCR service if needed and opens the management UI in the default browser.",
"",
@ -517,11 +515,12 @@ function printUiHelp(exitCode: number): void {
}
function printStopHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
" ccr stop",
` ${command} stop`,
"",
"Stops the background CCR service started by `ccr start`."
`Stops the background CCR service started by \`${command} start\`.`
].join("\n");
const stream = exitCode === 0 ? process.stdout : process.stderr;
stream.write(`${output}\n`);
@ -529,9 +528,10 @@ function printStopHelp(exitCode: number): void {
}
function printWebHelp(exitCode: number): void {
const command = cliCommandName();
const output = [
"Usage:",
" ccr serve [--host <host>] [--port <port>] [--open] [--no-gateway]",
` ${command} serve [--host <host>] [--port <port>] [--open] [--no-gateway]`,
"",
"Options:",
" --host <host> Management server host. Defaults to 127.0.0.1.",
@ -547,6 +547,13 @@ function printWebHelp(exitCode: number): void {
process.exitCode = exitCode;
}
function cliCommandName(): string {
const configured = process.env.CCR_CLI_COMMAND_NAME?.trim();
return configured && /^[A-Za-z0-9._-]+$/.test(configured)
? configured
: defaultCliCommandName;
}
function readServiceState(): ServiceState | undefined {
const file = serviceStateFile();
if (!existsSync(file)) {
@ -597,94 +604,6 @@ 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) {

View file

@ -1,5 +1,6 @@
import os from "node:os";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import path from "node:path";
import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app";
import { CONFIGDIR } from "@ccr/core/config/constants";
@ -126,6 +127,11 @@ function botGatewaySdkEnv(): Record<string, string> {
}
function resolveBotGatewaySdkModule(): string {
const bundled = resolveBundledBotGatewaySdkModule();
if (bundled) {
return bundled;
}
try {
return path.join(path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")), "dist", "index.js");
} catch {
@ -133,6 +139,20 @@ function resolveBotGatewaySdkModule(): string {
}
}
function resolveBundledBotGatewaySdkModule(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
const candidates = [
path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"),
...(resourcesPath
? [
path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"),
path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js")
]
: [])
];
return candidates.find((candidate) => existsSync(candidate)) ?? "";
}
function normalizeBotGatewayForWebSocket(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig {
const platform = normalizeBotGatewayPlatform(bot.platform);
return {

View file

@ -244,6 +244,7 @@ async function loadBotGatewaySdk(): Promise<BotGatewaySdkModule> {
async function importBotGatewaySdk(): Promise<BotGatewaySdkModule> {
const candidates = [
process.env.CCR_BOT_GATEWAY_SDK_MODULE,
resolveBundledBotGatewaySdkModule(),
"@the-next-ai/bot-gateway-sdk"
].filter((value): value is string => Boolean(value?.trim()));
const errors: string[] = [];
@ -261,6 +262,20 @@ async function importBotGatewaySdk(): Promise<BotGatewaySdkModule> {
throw new Error(`Unable to load @the-next-ai/bot-gateway-sdk. ${errors.join("; ")}`);
}
function resolveBundledBotGatewaySdkModule(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
const candidates = [
path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"),
...(resourcesPath
? [
path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"),
path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js")
]
: [])
];
return candidates.find((candidate) => existsSync(candidate)) ?? "";
}
function botGatewaySdkImportSpecifier(value: string): string {
const trimmed = value.trim();
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {

View file

@ -3246,6 +3246,10 @@ async function importBotGatewaySdk() {
if (configured) {
candidates.push(configured);
}
const bundled = bundledBotGatewaySdkModule();
if (bundled) {
candidates.push(bundled);
}
candidates.push("@the-next-ai/bot-gateway-sdk");
const errors = [];
for (const candidate of candidates) {
@ -3262,6 +3266,20 @@ async function importBotGatewaySdk() {
throw new Error("Unable to load @the-next-ai/bot-gateway-sdk. " + errors.join("; "));
}
function bundledBotGatewaySdkModule() {
const resourcesPath = process["resourcesPath"];
const candidates = [
path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"),
...(resourcesPath
? [
path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"),
path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js")
]
: [])
];
return candidates.find((candidate) => fs.existsSync(candidate)) || "";
}
function botGatewaySdkImportSpecifier(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return "@the-next-ai/bot-gateway-sdk";

View file

@ -6071,6 +6071,11 @@ function resolveGatewayEntry(): string {
return entry;
}
const bundledEntry = resolveBundledGatewayEntry();
if (bundledEntry) {
return bundledEntry;
}
for (const packageName of gatewayPackageCandidates) {
try {
return requireFromHere.resolve(packageName);
@ -6081,6 +6086,19 @@ function resolveGatewayEntry(): string {
return requireFromHere.resolve(gatewayPackageCandidates[0]);
}
function resolveBundledGatewayEntry(): string | undefined {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
return [
pathJoin(__dirname, "next-ai-gateway.js"),
...(resourcesPath
? [
pathJoin(resourcesPath, "app.asar", "dist", "main", "next-ai-gateway.js"),
pathJoin(resourcesPath, "app", "dist", "main", "next-ai-gateway.js")
]
: [])
].find((candidate) => existsSync(candidate));
}
function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,

View file

@ -1,5 +1,5 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { assertAvailableGatewayModels, type AppConfig, type ProfileOpenCommandResult, type ProfileOpenRequest, type ProfileOpenResult, type ProfileRuntimeEntry, type ProfileRuntimeStatus, type ProfileStopResult } from "@ccr/core/contracts/app";
@ -17,9 +17,17 @@ import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "@ccr/c
const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>";
const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<";
export const desktopCliCommandName = "ccr-app";
const desktopCliRuntimeFileName = "ccr-cli.js";
const desktopCliCommandNameEnv = "CCR_CLI_COMMAND_NAME";
let claudeAppBotWorker: ChildProcess | undefined;
let claudeAppBotWorkerProfileId: string | undefined;
type ProfileOpenCommandOptions = {
commandName?: string;
ensureLauncher?: boolean;
};
type ProfileAppLaunchResult = {
child: ChildProcess;
claudeDesignProxy?: boolean;
@ -41,14 +49,16 @@ type RunningProfileApp = ProfileRuntimeEntry & {
process.once("exit", () => stopClaudeAppBotWorker());
export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileOpenCommandResult> {
export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest, options: ProfileOpenCommandOptions = {}): Promise<ProfileOpenCommandResult> {
assertAvailableGatewayModels(config);
await applyProfileConfig(config);
const profile = findProfileForOpen(config, request.profileId);
const surface = resolveProfileOpenSurface(profile, request.surface);
ensureCcrCliLauncher();
if (options.ensureLauncher) {
ensureCcrCliLauncher();
}
return {
command: profileOpenCommand(profile, surface, "ccr", commandProfileRef(config, profile)),
command: profileOpenCommand(profile, surface, options.commandName ?? "ccr", commandProfileRef(config, profile)),
profileId: profile.id,
profileName: profile.name,
surface
@ -1083,13 +1093,14 @@ export function ensureCcrCliLauncher(): string {
const binDir = path.join(CONFIGDIR, "bin");
mkdirSync(binDir, { recursive: true });
cleanupGeneratedBinBackups();
cleanupLegacyCcrCliLauncher(binDir);
const runtimeFile = path.join(binDir, "ccr-cli.js");
const runtimeFile = path.join(binDir, desktopCliRuntimeFileName);
const runtimeSource = findBundledCcrCliSource();
writeFileIfChanged(runtimeFile, readFileSync(runtimeSource, "utf8"));
chmodSafe(runtimeFile);
const launcherFile = path.join(binDir, process.platform === "win32" ? "ccr.cmd" : "ccr");
const launcherFile = path.join(binDir, process.platform === "win32" ? `${desktopCliCommandName}.cmd` : desktopCliCommandName);
const launcherContent = process.platform === "win32"
? windowsCcrLauncher(runtimeFile)
: posixCcrLauncher(runtimeFile);
@ -1100,6 +1111,29 @@ export function ensureCcrCliLauncher(): string {
return launcherFile;
}
function cleanupLegacyCcrCliLauncher(binDir: string): void {
const legacyLauncherFile = path.join(binDir, process.platform === "win32" ? "ccr.cmd" : "ccr");
if (!existsSync(legacyLauncherFile)) {
return;
}
try {
const source = readFileSync(legacyLauncherFile, "utf8");
if (!isLegacyManagedCcrCliLauncher(source)) {
return;
}
rmSync(legacyLauncherFile, { force: true });
} catch (error) {
console.warn(`[profile] Failed to remove legacy ccr launcher: ${formatError(error)}`);
}
}
function isLegacyManagedCcrCliLauncher(source: string): boolean {
return source.includes("CCR_CLI_NODE_PATH") &&
source.includes(desktopCliRuntimeFileName) &&
source.includes("ELECTRON_RUN_AS_NODE=1") &&
source.includes("CCR_NODE_BIN");
}
function findBundledCcrCliSource(): string {
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
const candidates = [
@ -1123,6 +1157,8 @@ function posixCcrLauncher(runtimeFile: string): string {
const nodePath = bundledNodePath();
return [
"#!/bin/sh",
`${desktopCliCommandNameEnv}=${shQuote(desktopCliCommandName)}`,
`export ${desktopCliCommandNameEnv}`,
`CCR_CLI_NODE_PATH=${shQuote(nodePath)}`,
'if [ -n "$NODE_PATH" ]; then',
' export NODE_PATH="$CCR_CLI_NODE_PATH:$NODE_PATH"',
@ -1141,6 +1177,7 @@ function windowsCcrLauncher(runtimeFile: string): string {
return [
"@echo off",
"setlocal",
`set "${desktopCliCommandNameEnv}=${desktopCliCommandName}"`,
`set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`,
`set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`,
"if defined NODE_PATH (",
@ -1316,7 +1353,7 @@ function shellRcPathBlock(): string {
const binDir = "$HOME/.claude-code-router/bin";
return [
ccrPathBlockStart,
"# Added by Claude Code Router. Enables the ccr command in new shells.",
"# Added by Claude Code Router. Enables the ccr-app command in new shells.",
'case ":$PATH:" in',
` *":${binDir}:"*) ;;`,
` *) export PATH="${binDir}:$PATH" ;;`,
@ -1349,7 +1386,7 @@ function ensureFishPathBlock(file: string, binDir: string): void {
function fishPathBlock(): string {
return [
ccrPathBlockStart,
"# Added by Claude Code Router. Enables the ccr command in new shells.",
"# Added by Claude Code Router. Enables the ccr-app command in new shells.",
'set -l ccr_bin "$HOME/.claude-code-router/bin"',
"if not contains $ccr_bin $PATH",
" set -gx PATH $ccr_bin $PATH",

View file

@ -1068,6 +1068,7 @@ function generatedBinBackupBaseName(entry: string): string | undefined {
function isManagedGeneratedBinFile(fileName: string): boolean {
const normalized = fileName.replace(/\.cmd$/i, "");
return normalized === "ccr" ||
normalized === "ccr-app" ||
normalized === "ccr-cli.js" ||
normalized === codexMiddlewareRuntimeFilename() ||
normalized.startsWith("ccr-claude-code-api-key-") ||

View file

@ -5,6 +5,9 @@
"description": "Claude Code Router Electron desktop shell.",
"main": "dist/main/main.js",
"dependencies": {
"better-sqlite3": "^12.11.1"
},
"devDependencies": {
"electron-updater": "^6.8.9"
}
}

View file

@ -23,7 +23,7 @@ import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog";
import { getProviderPresets } from "@ccr/core/providers/presets/index";
import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe";
import { applyProfileConfig } from "@ccr/core/profiles/service";
import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service";
import { desktopCliCommandName, getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service";
import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates";
import { proxyService } from "@ccr/core/proxy/service";
import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery";
@ -93,7 +93,10 @@ ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => {
ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests());
ipcMain.handle(IPC_CHANNELS.appGetLocalAgentProviderCandidates, () => getLocalAgentProviderCandidates());
ipcMain.handle(IPC_CHANNELS.appGetProfileOpenCommand, async (_event, request: ProfileOpenRequest) => {
return getProfileOpenCommand(await loadAppConfig(), request);
return getProfileOpenCommand(await loadAppConfig(), request, {
commandName: desktopCliCommandName,
ensureLauncher: true
});
});
ipcMain.handle(IPC_CHANNELS.appGetProfileRuntimeStatus, () => {
return getProfileRuntimeStatus();