diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..86b7318
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+.git
+.DS_Store
+.env
+.env.*
+node_modules
+dist
+packages/*/dist
+release
+release-local
+logs
+tmp
+test-results
+playwright-report
+blob-report
+docs/node_modules
+docs/dist
+docs/.astro
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index e2f2965..00463e0 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -29,8 +29,8 @@ jobs:
- name: Build and upload docs
uses: withastro/action@v6
env:
- ASTRO_SITE: https://musistudio.github.io
- ASTRO_BASE: /claude-code-router
+ ASTRO_SITE: https://ccrdesk.top
+ ASTRO_BASE: /
with:
path: docs
node-version: 24
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5f2cbbd..409612d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,8 +14,24 @@ concurrency:
jobs:
macos:
- name: macOS
- runs-on: macos-14
+ name: macOS (${{ matrix.display_name }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: arm64
+ arch_flag: --arm64
+ runner: macos-14
+ display_name: Apple Silicon
+ release_label: Apple-Silicon
+ artifact_name: macos-apple-silicon-arm64
+ - arch: x64
+ arch_flag: --x64
+ runner: macos-15-intel
+ display_name: Intel
+ release_label: Intel
+ artifact_name: macos-intel-x64
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -39,16 +55,92 @@ jobs:
exit 1
fi
- - name: Build and publish ad-hoc macOS artifacts
+ - name: Build ad-hoc macOS artifacts
+ run: |
+ npm run build:assets
+ npx electron-builder \
+ --config build/electron-builder.local.cjs \
+ --mac ${{ matrix.arch_flag }} \
+ --publish never \
+ -c.mac.artifactName='Claude-Code-Router_${version}-mac-${{ matrix.release_label }}-${arch}.${ext}'
+
+ - name: Upload macOS release files
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact_name }}
+ path: |
+ release-local/Claude-Code-Router_*-mac-${{ matrix.release_label }}-${{ matrix.arch }}.*
+ release-local/latest-mac.yml
+ if-no-files-found: error
+
+ publish-macos:
+ name: Publish macOS
+ needs: macos
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install metadata merge dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Download macOS release files
+ uses: actions/download-artifact@v4
+ with:
+ pattern: macos-*
+ path: macos-release-artifacts
+
+ - name: Merge macOS update metadata
+ run: node build/merge-macos-update-metadata.mjs latest-mac.yml macos-release-artifacts/*/latest-mac.yml
+
+ - name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- npm run build:assets
- npx electron-builder --config build/electron-builder.local.cjs --mac --publish always
+ gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \
+ gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes
+
+ - name: Upload macOS release assets
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ shopt -s nullglob
+ assets=(macos-release-artifacts/*/Claude-Code-Router_*-mac-*.*)
+ if (( ${#assets[@]} == 0 )); then
+ echo "No macOS release assets were found."
+ exit 1
+ fi
+
+ upload_args=()
+ for asset in "${assets[@]}"; do
+ name="$(basename "$asset")"
+ case "$name" in
+ *Apple-Silicon-arm64.dmg) label="macOS Apple Silicon DMG (arm64)" ;;
+ *Apple-Silicon-arm64.zip) label="macOS Apple Silicon ZIP (arm64)" ;;
+ *Apple-Silicon-arm64.dmg.blockmap) label="macOS Apple Silicon DMG blockmap (arm64)" ;;
+ *Apple-Silicon-arm64.zip.blockmap) label="macOS Apple Silicon ZIP blockmap (arm64)" ;;
+ *Intel-x64.dmg) label="macOS Intel DMG (x64)" ;;
+ *Intel-x64.zip) label="macOS Intel ZIP (x64)" ;;
+ *Intel-x64.dmg.blockmap) label="macOS Intel DMG blockmap (x64)" ;;
+ *Intel-x64.zip.blockmap) label="macOS Intel ZIP blockmap (x64)" ;;
+ *) label="$name" ;;
+ esac
+ upload_args+=("${asset}#${label}")
+ done
+ upload_args+=("latest-mac.yml#macOS update metadata")
+
+ gh release upload "$GITHUB_REF_NAME" "${upload_args[@]}" --clobber
windows:
name: Windows
- needs: macos
+ needs: publish-macos
runs-on: windows-latest
steps:
- name: Check out repository
@@ -82,7 +174,7 @@ jobs:
linux:
name: Linux
- needs: macos
+ needs: publish-macos
runs-on: ubuntu-latest
steps:
- name: Check out repository
diff --git a/.gitignore b/.gitignore
index 291dafe..6efc5ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,4 +13,8 @@ release
tmp
release-local
logs
-.opencat
\ No newline at end of file
+.opencat
+test-results
+playwright-report
+blob-report
+.tmp
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4c0c868
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,75 @@
+ARG NODE_IMAGE=node:22-bookworm
+ARG RUNTIME_NODE_IMAGE=node:22-bookworm-slim
+
+FROM ${NODE_IMAGE} AS build
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+COPY packages/cli/package.json packages/cli/package.json
+COPY packages/core/package.json packages/core/package.json
+COPY packages/electron/package.json packages/electron/package.json
+COPY packages/ui/package.json packages/ui/package.json
+RUN npm ci
+
+COPY . .
+RUN npm run build:docker
+
+FROM ${NODE_IMAGE} AS production-deps
+WORKDIR /app
+ENV NODE_ENV=production
+
+COPY package.json package-lock.json ./
+COPY packages/core/package.json packages/core/package.json
+RUN npm ci --omit=dev --workspace=@claude-code-router/core --include-workspace-root=false \
+ && npm cache clean --force
+
+FROM ${RUNTIME_NODE_IMAGE} AS runtime
+ENV NODE_ENV=production \
+ CCR_DATA_DIR=/data \
+ CCR_WEB_HOST=127.0.0.1 \
+ CCR_WEB_PORT=3459 \
+ CCR_NGINX_PORT=8080 \
+ CCR_GATEWAY_HOST=127.0.0.1 \
+ CCR_GATEWAY_PORT=3456 \
+ CCR_GATEWAY_CORE_PORT=3457 \
+ CCR_PUBLIC_HOST=127.0.0.1 \
+ CCR_PUBLIC_PORT=3458
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates libstdc++6 nginx \
+ && rm -rf /var/lib/apt/lists/* \
+ && rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \
+ && rm -rf \
+ /opt/yarn-* \
+ /usr/local/bin/corepack \
+ /usr/local/bin/npm \
+ /usr/local/bin/npx \
+ /usr/local/bin/yarn \
+ /usr/local/bin/yarnpkg \
+ /usr/local/include/node \
+ /usr/local/lib/node_modules/corepack \
+ /usr/local/lib/node_modules/npm \
+ /usr/local/share/doc \
+ /usr/local/share/man
+
+COPY package.json package-lock.json ./
+COPY packages/core/package.json packages/core/package.json
+COPY --from=production-deps /app/node_modules node_modules
+
+COPY --from=build /app/packages/core/dist packages/core/dist
+COPY --from=build /app/packages/ui/dist/renderer /usr/share/nginx/html
+COPY docker/entrypoint.sh /usr/local/bin/ccr-docker-entrypoint
+COPY docker/pm2.config.cjs docker/pm2.config.cjs
+
+RUN chmod +x /usr/local/bin/ccr-docker-entrypoint \
+ && mkdir -p /data /run/nginx /var/lib/nginx /var/log/nginx
+
+VOLUME ["/data"]
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD node -e "fetch('http://127.0.0.1:' + (process.env.CCR_NGINX_PORT || '8080') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
+
+ENTRYPOINT ["ccr-docker-entrypoint"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ea7e8ac
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 musistudio
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index f079394..10f4c32 100644
--- a/README.md
+++ b/README.md
@@ -1,56 +1,89 @@
-
Claude Code Router Desktop
+Claude Code Router
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+ Kimi Code Subscription
+ ·
+ API Global
+ ·
+ API China
+
+
+
+
+
+
+ Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
+
+
+ CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global ) to try the API, or explore the cost-effective Coding Plan .
+
+
+
+
+
+
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
-CCR runs on your machine, keeps provider configuration in your local config directory, and exposes a local gateway at `http://127.0.0.1:3456`.
+
+
+
## Why Use CCR
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
-- Route different workloads to different models, such as fast background work, reasoning tasks, long-context requests, image tasks, or web-search-capable models.
-- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Mistral, Z.AI, Bailian, and custom providers.
+- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand.
+- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers.
- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs.
-- Manage everything from a desktop UI instead of editing JSON by hand.
-- Extend the gateway with plugins, proxy routes, local HTTP backends, and provider deeplinks.
## Features
-- **Desktop dashboard**: start or stop the local gateway, inspect usage, configure the tray window, and manage runtime settings.
-- **Provider management**: add provider presets or custom endpoints, test connectivity, manage credentials, and monitor supported account balances where available.
-- **Routing rules**: set default, background, thinking, long-context, image, web-search, subagent, model-prefix, and conditional routing rules.
-- **Agent profiles**: configure Claude Code, Codex, and ZCode profiles that point to the CCR gateway.
-- **Gateway compatibility**: translate client requests through the local CCR wrapper and the core gateway runtime.
+- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards.
+- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available.
+- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites.
+- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles.
+- **Gateway compatibility**: translate supported client requests through the local CCR model gateway.
- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture.
-- **Plugins**: install or load wrapper plugins, including routes for Claude Design and Cursor Proxy style integrations.
-- **Virtual models**: expose aliases or composed model profiles for clients that expect a specific model name.
-- **Provider deeplinks**: import provider configuration through `ccr://provider?...` links after user confirmation.
+- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model.
+
+## Documentation
+
+Read the full documentation at [ccrdesk.top](https://ccrdesk.top/).
## Download And Install
1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
2. Download the package for your platform:
- - macOS: `Claude Code Router_.dmg` or `.zip`
+ - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip`
+ - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip`
- Windows: `Claude Code Router_.exe`
- Linux: `Claude Code Router_.AppImage`
3. Install and launch **Claude Code Router**.
-4. On first launch, CCR creates its local configuration:
- - macOS/Linux: `~/.claude-code-router/config.json`
- - Windows: `%APPDATA%\Claude Code Router\config.json`
+4. On first launch, CCR creates its local configuration database:
+ - macOS/Linux: `~/.claude-code-router/config.sqlite`
+ - Windows: `%APPDATA%\Claude Code Router\config.sqlite`
-CCR starts two local services when the gateway is enabled:
+CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
-- CCR wrapper gateway: `http://127.0.0.1:3456`
-- Core gateway runtime: `http://127.0.0.1:3457`
+After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
## Quick Start
@@ -58,226 +91,286 @@ CCR can be configured entirely from the desktop UI. Use this setup order for a c
### 1. Add a provider
-Open **Providers**, click **Add Provider**, then choose a built-in preset or create a custom provider. Fill in the provider name, endpoint, protocol, API key, and model list in the form. Use the connectivity check when available, then save the provider.
+Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
### 2. Configure routing
-Open **Routing** and select which provider/model should handle the default route. Then fill optional routes for background work, thinking requests, long-context requests, image tasks, and web search if you want different models for those scenarios.
+Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior.
-Use **Add Routing Rule** when you need more control, such as model-prefix routing, subagent routing, request conditions, or fallback behavior.
+Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets.
### 3. Start the gateway
-Open **Server** and click **Start**. Enable auto start if you want CCR to start the local gateway whenever the desktop app opens.
+Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
### 4. Connect your agent tool
-Open **Profiles** and choose the client you want to use. Configure the Claude Code, Codex, or ZCode profile from the form, select the target model, and apply the profile. For app-based profiles, use the profile action button to open the target app through CCR.
+Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR.
### 5. Monitor and adjust
-Use **Dashboard** for usage and provider health, the tray window for quick token and account status, **Network Logs** for debugging provider behavior, and **Extensions** for plugin configuration.
-
-## Provider Deeplink
-
-Provider websites can open CCR and import a model provider with a custom protocol link:
-
-```text
-ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions
-```
-
-Supported query parameters:
-
-- `name`: display name for the provider.
-- `base_url`: provider API base URL. Aliases: `baseUrl`, `api_base_url`, `url`, `endpoint`.
-- `api_key`: optional provider API key. Aliases: `apiKey`, `apikey`, `key`, `token`.
-- `models`: comma-separated or newline-separated model list. You can also repeat `model=...`.
-- `protocol`: one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, or `gemini_generate_content`.
-
-For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields. CCR always opens a confirmation dialog before writing a provider imported from an external link.
-
-## Plugins
-
-CCR has two plugin layers:
-
-- Core gateway plugins: use `providerPlugins` and `virtualModelProfiles`; these are passed through to the core gateway.
-- Wrapper plugins: use top-level `plugins` to extend the Electron wrapper, register local HTTP backends, add gateway routes, and route proxy-mode traffic to plugin backends.
-
-Example wrapper plugin route:
-
-```json
-{
- "plugins": [
- {
- "id": "local-admin-api",
- "enabled": true,
- "proxy": {
- "routes": [
- {
- "id": "admin-api",
- "host": "api.example.com",
- "paths": ["/v1/admin"],
- "upstream": "http://127.0.0.1:4510",
- "stripPathPrefix": false
- }
- ]
- }
- }
- ]
-}
-```
-
-Plugin modules export a function or object with `setup(ctx)`. The context supports:
-
-- `ctx.registerGatewayRoute({ method, path, auth, handler })`
-- `ctx.registerHttpBackend({ id, host, port, handler })`
-- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
-- `ctx.openSqliteStore({ filename, migrate })`
-- `ctx.registerCoreGatewayProviderPlugin(plugin)`
-- `ctx.registerCoreGatewayVirtualModelProfile(profile)`
-
-Local plugin examples are available in [examples/plugins](examples/plugins).
-
-## Development
-
-```bash
-npm install
-npm run dev
-npm run typecheck
-npm run build:assets
-npm run build:app:mac
-npm run build:app:win
-```
-
-`npm run build:assets` compiles the Electron main process and renderer assets into `dist/`.
-
-`npm run build` packages the app for the current platform and writes installer artifacts to `release/`.
-
-`npm run build:app:mac` and `npm run build:app:win` package platform-specific app artifacts. Linux AppImage packaging is configured in `electron-builder.json`.
-
-`npm run build:app:mac` creates a local macOS test package in `release-local/` using ad-hoc signing. It is useful with a free Apple Account or Apple Development certificate, but it is not suitable for public distribution because downloaded copies will not pass Gatekeeper notarization checks.
-
-macOS release builds are signed and notarized for distribution. Before running `npm run build:app:mac:release`, the build machine must have a `Developer ID Application` certificate available through the keychain or `CSC_LINK`/`CSC_KEY_PASSWORD`, full Xcode selected with `xcode-select`, and one notarization credential set:
-
-- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`
-- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`
-- `APPLE_KEYCHAIN_PROFILE`, optionally with `APPLE_KEYCHAIN`
-
-The macOS packaging hook validates codesigning, the stapled notarization ticket, and Gatekeeper assessment before writing distributable artifacts.
-
-Packaged builds check GitHub Releases for updates through `electron-updater`. For local update feed testing, set `CCR_UPDATE_FEED_URL` to a generic electron-updater feed URL before starting the app. `CCR_UPDATE_ALLOW_PRERELEASE=1` enables prerelease updates.
-
-## Further Reading
-
-- [Project Motivation and How It Works](blog/en/project-motivation-and-how-it-works.md)
-- [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md)
+Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status.
## Acknowledgements
-Codex support and Bot handoff are powered by [musistudio/codexl](https://github.com/musistudio/codexl).
+Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl).
## Support & Sponsoring
-If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.
+
-[](https://ko-fi.com/F1F31GN2GM)
-
-[Paypal](https://paypal.me/musistudio1999)
+
If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.
-
-
+
+
+
+
+
+ One-time support via Ko-fi
+
+
+
+
+
+
+ International sponsorship
+
+
+
+
+ Alipay
+
+
+
+
+ WeChat Pay
+
+
+
+
+
+
+
+
### Our Sponsors
-A huge thank you to all our sponsors for their generous support.
+
-- [AIHubmix](https://aihubmix.com/)
-- [BurnCloud](https://ai.burncloud.com)
-- @Simon Leischnig
-- [@duanshuaimin](https://github.com/duanshuaimin)
-- [@vrgitadmin](https://github.com/vrgitadmin)
-- @\*o
-- [@ceilwoo](https://github.com/ceilwoo)
-- @\*说
-- @\*更
-- @K\*g
-- @R\*R
-- [@bobleer](https://github.com/bobleer)
-- @\*苗
-- @\*划
-- [@Clarence-pan](https://github.com/Clarence-pan)
-- [@carter003](https://github.com/carter003)
-- @S\*r
-- @\*晖
-- @\*敏
-- @Z\*z
-- @\*然
-- [@cluic](https://github.com/cluic)
-- @\*苗
-- [@PromptExpert](https://github.com/PromptExpert)
-- @\*应
-- [@yusnake](https://github.com/yusnake)
-- @\*飞
-- @董\*
-- @\*汀
-- @\*涯
-- @\*:-)
-- @\*\*磊
-- @\*琢
-- @\*成
-- @Z\*o
-- @\*琨
-- [@congzhangzh](https://github.com/congzhangzh)
-- @\*\_
-- @Z\*m
-- @\*鑫
-- @c\*y
-- @\*昕
-- [@witsice](https://github.com/witsice)
-- @b\*g
-- @\*亿
-- @\*辉
-- @JACK
-- @\*光
-- @W\*l
-- [@kesku](https://github.com/kesku)
-- [@biguncle](https://github.com/biguncle)
-- @二吉吉
-- @a\*g
-- @\*林
-- @\*咸
-- @\*明
-- @S\*y
-- @f\*o
-- @\*智
-- @F\*t
-- @r\*c
-- [@qierkang](http://github.com/qierkang)
-- @\*军
-- [@snrise-z](http://github.com/snrise-z)
-- @\*王
-- [@greatheart1000](http://github.com/greatheart1000)
-- @\*王
-- @zcutlip
-- [@Peng-YM](http://github.com/Peng-YM)
-- @\*更
-- @\*.
-- @F\*t
-- @\*政
-- @\*铭
-- @\*叶
-- @七\*o
-- @\*青
-- @\*\*晨
-- @\*远
-- @\*霄
-- @\*\*吉
-- @\*\*飞
-- @\*\*驰
-- @x\*g
+
A huge thank you to all our sponsors for their generous support.
-(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)
+
+
+
Community Sponsors
+
+
+
+
If your name is masked, please contact me via my homepage email to update it with your GitHub username.
+
+
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).
diff --git a/README_zh.md b/README_zh.md
index 4c07395..183df2d 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -1,56 +1,88 @@
-Claude Code Router Desktop
+Claude Code Router
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+ Kimi Code 订阅
+ ·
+ API 中文站
+ ·
+ API Global
+
+
+
+
+
+
+ 感谢 Kimi 赞助本项目! Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
+
+
+ CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站 |Global )体验 API,或了解高性价比 Coding Plan 套餐。
+
+
+
+
+
+
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
-CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://127.0.0.1:3456`。
+
+
+
## 为什么使用 CCR
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
-- 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。
-- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Mistral、Z.AI、百炼以及自定义 Provider。
+- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。
- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。
-- 使用桌面 UI 管理配置,减少手写 JSON。
-- 通过插件、代理路由、本地 HTTP 后端和 Provider deeplink 扩展网关能力。
## 功能和特性
-- **桌面控制台**:启动或停止本地网关,查看用量,配置托盘窗口和运行时设置。
-- **Provider 管理**:添加预设或自定义端点,检测连通性,管理凭据,并在可用时查看账号余额。
-- **路由规则**:配置默认、后台、thinking、长上下文、图片、Web Search、Subagent、模型前缀和条件路由。
-- **Agent Profiles**:为 Claude Code、Codex 和 ZCode 配置指向 CCR 网关的 Profile。
-- **网关兼容层**:通过本地 CCR wrapper 和 core gateway runtime 转换客户端请求。
+- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。
+- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。
+- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。
+- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。
+- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。
- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。
-- **插件系统**:安装或加载 wrapper 插件,包括 Claude Design、Cursor Proxy 这类集成路由。
-- **虚拟模型**:为客户端暴露模型别名或组合模型配置,适配固定模型名场景。
-- **Provider Deeplink**:通过 `ccr://provider?...` 链接导入 Provider 配置,写入前会弹出确认。
+- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。
+
+## 文档
+
+完整文档见 [ccrdesk.top](https://ccrdesk.top/)。
## 下载和安装
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
2. 按系统下载对应安装包:
- - macOS:`Claude Code Router_.dmg` 或 `.zip`
+ - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
+ - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip`
- Windows:`Claude Code Router_.exe`
- Linux:`Claude Code Router_.AppImage`
3. 安装并启动 **Claude Code Router**。
-4. 首次启动后,CCR 会创建本地配置:
- - macOS/Linux:`~/.claude-code-router/config.json`
- - Windows:`%APPDATA%\Claude Code Router\config.json`
+4. 首次启动后,CCR 会创建本地配置数据库:
+ - macOS/Linux:`~/.claude-code-router/config.sqlite`
+ - Windows:`%APPDATA%\Claude Code Router\config.sqlite`
-启用网关后,CCR 会启动两个本地服务:
+CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
-- CCR wrapper gateway:`http://127.0.0.1:3456`
-- Core gateway runtime:`http://127.0.0.1:3457`
+从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
## 快速开始
@@ -58,226 +90,286 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序
### 1. 添加 Provider
-打开 **Providers**,点击 **Add Provider**,选择内置预设或创建自定义 Provider。按表单填写 Provider 名称、端点、协议、API Key 和模型列表。可用时先运行连通性检测,然后保存 Provider。
+打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
### 2. 设置路由
-打开 **Routing**,先选择默认路由要使用的 provider/model。然后根据需要设置后台任务、Thinking、长上下文、图片任务和 Web Search 等场景的专用模型。
+打开 **路由**,添加条件规则,配置请求改写和失败降级。
-如果需要更细粒度控制,使用 **Add Routing Rule** 添加模型前缀、Subagent、请求条件或 fallback 规则。
+如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
### 3. 启动网关
-打开 **Server**,点击 **Start** 启动本地网关。如果希望每次打开桌面应用时自动启动网关,可以启用 auto start。
+打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
### 4. 连接 Agent 工具
-打开 **Profiles**,选择要使用的客户端。通过表单配置 Claude Code、Codex 或 ZCode Profile,选择目标模型并应用配置。对于 App 类型的 Profile,可以使用页面里的操作按钮通过 CCR 打开目标应用。
+打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。
### 5. 日常查看和调整
-使用 **Dashboard** 查看用量和 Provider 状态,使用托盘窗口快速查看 Token 和账号状态,使用 **Network Logs** 调试 Provider 行为,使用 **Extensions** 配置插件。
-
-## Provider Deeplink
-
-Provider 网站可以通过自定义协议打开 CCR 并导入模型服务配置:
-
-```text
-ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions
-```
-
-支持的 query 参数:
-
-- `name`:Provider 展示名称。
-- `base_url`:Provider API Base URL。别名:`baseUrl`、`api_base_url`、`url`、`endpoint`。
-- `api_key`:可选 Provider API Key。别名:`apiKey`、`apikey`、`key`、`token`。
-- `models`:逗号或换行分隔的模型列表,也可以重复传入 `model=...`。
-- `protocol`:`openai_chat_completions`、`openai_responses`、`anthropic_messages` 或 `gemini_generate_content`。
-
-更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段。CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。
-
-## 插件
-
-CCR 有两层插件:
-
-- Core gateway plugins:使用 `providerPlugins` 和 `virtualModelProfiles`,会透传给 core gateway。
-- Wrapper plugins:使用顶层 `plugins` 扩展 Electron wrapper,注册本地 HTTP 后端、添加 gateway route,或把代理模式流量路由到插件后端。
-
-Wrapper plugin route 示例:
-
-```json
-{
- "plugins": [
- {
- "id": "local-admin-api",
- "enabled": true,
- "proxy": {
- "routes": [
- {
- "id": "admin-api",
- "host": "api.example.com",
- "paths": ["/v1/admin"],
- "upstream": "http://127.0.0.1:4510",
- "stripPathPrefix": false
- }
- ]
- }
- }
- ]
-}
-```
-
-插件模块需要导出函数或包含 `setup(ctx)` 的对象。上下文支持:
-
-- `ctx.registerGatewayRoute({ method, path, auth, handler })`
-- `ctx.registerHttpBackend({ id, host, port, handler })`
-- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })`
-- `ctx.openSqliteStore({ filename, migrate })`
-- `ctx.registerCoreGatewayProviderPlugin(plugin)`
-- `ctx.registerCoreGatewayVirtualModelProfile(profile)`
-
-本地插件示例见 [examples/plugins](examples/plugins)。
-
-## 开发
-
-```bash
-npm install
-npm run dev
-npm run typecheck
-npm run build:assets
-npm run build:app:mac
-npm run build:app:win
-```
-
-`npm run build:assets` 会把 Electron main process 和 renderer assets 编译到 `dist/`。
-
-`npm run build` 会为当前平台打包应用,并把安装包写入 `release/`。
-
-`npm run build:app:mac` 和 `npm run build:app:win` 会分别打包对应平台的应用产物。Linux AppImage 打包配置在 `electron-builder.json` 中。
-
-`npm run build:app:mac` 会在 `release-local/` 生成本地测试用 macOS 包,使用 ad-hoc 签名。它适合免费 Apple Account 或只有 Apple Development 证书的本机测试,但不适合公开分发,因为用户下载后仍无法通过 Gatekeeper 公证检查。
-
-macOS 发布包会使用 Developer ID 签名并提交 Apple 公证。运行 `npm run build:app:mac:release` 前,打包机器必须具备:可用的 `Developer ID Application` 证书(在 keychain 中,或通过 `CSC_LINK`/`CSC_KEY_PASSWORD` 提供)、已通过 `xcode-select` 选择完整 Xcode,以及下面任意一组公证凭据:
-
-- `APPLE_API_KEY`、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`
-- `APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID`
-- `APPLE_KEYCHAIN_PROFILE`,可选 `APPLE_KEYCHAIN`
-
-macOS 打包 hook 会在产物生成前验证代码签名、公证票据 stapling 和 Gatekeeper 评估,避免发布未公证的安装包。
-
-打包后的应用会通过 `electron-updater` 检查 GitHub Releases。测试本地更新源时,可以在启动应用前设置 `CCR_UPDATE_FEED_URL` 为 generic electron-updater feed URL。`CCR_UPDATE_ALLOW_PRERELEASE=1` 可以启用 prerelease 更新。
-
-## 深入阅读
-
-- [项目动机和工作原理](blog/zh/项目初衷及原理.md)
-- [也许我们可以用路由器做更多事情](blog/zh/或许我们能在Router中做更多事情.md)
+到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。
## 致谢
-对 Codex 的支持以及 Bot handoff 来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
+对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
## 支持与赞助
-如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。
+
-[](https://ko-fi.com/F1F31GN2GM)
-
-[Paypal](https://paypal.me/musistudio1999)
+
如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。
-
-
+
+
+
+
+
+ 通过 Ko-fi 单次赞助
+
+
+
+
+
+
+ 国际赞助通道
+
+
+
+
+ 支付宝
+
+
+
+
+ 微信支付
+
+
+
+
+
+
+
+
### 我们的赞助商
-非常感谢所有赞助商的慷慨支持。
+
-- [AIHubmix](https://aihubmix.com/)
-- [BurnCloud](https://ai.burncloud.com)
-- @Simon Leischnig
-- [@duanshuaimin](https://github.com/duanshuaimin)
-- [@vrgitadmin](https://github.com/vrgitadmin)
-- @\*o
-- [@ceilwoo](https://github.com/ceilwoo)
-- @\*说
-- @\*更
-- @K\*g
-- @R\*R
-- [@bobleer](https://github.com/bobleer)
-- @\*苗
-- @\*划
-- [@Clarence-pan](https://github.com/Clarence-pan)
-- [@carter003](https://github.com/carter003)
-- @S\*r
-- @\*晖
-- @\*敏
-- @Z\*z
-- @\*然
-- [@cluic](https://github.com/cluic)
-- @\*苗
-- [@PromptExpert](https://github.com/PromptExpert)
-- @\*应
-- [@yusnake](https://github.com/yusnake)
-- @\*飞
-- @董\*
-- @\*汀
-- @\*涯
-- @\*:-)
-- @\*\*磊
-- @\*琢
-- @\*成
-- @Z\*o
-- @\*琨
-- [@congzhangzh](https://github.com/congzhangzh)
-- @\*\_
-- @Z\*m
-- @\*鑫
-- @c\*y
-- @\*昕
-- [@witsice](https://github.com/witsice)
-- @b\*g
-- @\*亿
-- @\*辉
-- @JACK
-- @\*光
-- @W\*l
-- [@kesku](https://github.com/kesku)
-- [@biguncle](https://github.com/biguncle)
-- @二吉吉
-- @a\*g
-- @\*林
-- @\*咸
-- @\*明
-- @S\*y
-- @f\*o
-- @\*智
-- @F\*t
-- @r\*c
-- [@qierkang](http://github.com/qierkang)
-- @\*军
-- [@snrise-z](http://github.com/snrise-z)
-- @\*王
-- [@greatheart1000](http://github.com/greatheart1000)
-- @\*王
-- @zcutlip
-- [@Peng-YM](http://github.com/Peng-YM)
-- @\*更
-- @\*.
-- @F\*t
-- @\*政
-- @\*铭
-- @\*叶
-- @七\*o
-- @\*青
-- @\*\*晨
-- @\*远
-- @\*霄
-- @\*\*吉
-- @\*\*飞
-- @\*\*驰
-- @x\*g
+
非常感谢所有赞助商的慷慨支持。
-(如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。)
+
+
+
社区赞助者
+
+
+
+
如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。
+
+
+
+## 许可证
+
+本项目基于 [MIT License](LICENSE) 发布。
diff --git a/build/build.mjs b/build/build.mjs
index e0bc9d4..ba43565 100644
--- a/build/build.mjs
+++ b/build/build.mjs
@@ -1,4 +1,4 @@
-import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs";
+import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
const mode = process.argv.includes("--dev") ? "development" : "production";
@@ -15,7 +15,10 @@ await Promise.all([
buildBrowserRenderer({ mode }),
buildRenderer({ mode }),
buildTrayRenderer({ mode }),
+ buildWebClientBridge({ mode }),
buildStyles({ minify: mode === "production" })
]);
-console.log(`Built Electron app assets in ${mode} mode.`);
+syncUiRendererToRuntimeDists();
+
+console.log(`Built monorepo package assets in ${mode} mode.`);
diff --git a/build/dev.mjs b/build/dev.mjs
index 0511592..b91a341 100644
--- a/build/dev.mjs
+++ b/build/dev.mjs
@@ -5,26 +5,30 @@ import { spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
import path from "node:path";
import {
- binPath,
buildStyles,
cleanDist,
browserRendererHtmlInput,
+ cliSourceRoot,
+ coreSourceRoot,
copyAppAssets,
copyBrowserRendererHtml,
+ copyCliRuntimeToElectronDist,
copyMarketplacePlugins,
copyModelCatalog,
copyRendererHtml,
copyTrayRendererHtml,
createBrowserRendererBuildOptions,
+ createCliBuildOptions,
createMainBuildOptions,
createRendererBuildOptions,
createTrayRendererBuildOptions,
- cssInput,
- cssOutput,
+ createWebClientBridgeBuildOptions,
appAssetsInput,
modelCatalogInput,
projectRoot,
+ rendererRoot,
rendererHtmlInput,
+ syncUiRendererToRuntimeDists,
trayRendererHtmlInput,
watchPlugin
} from "./esbuild.config.mjs";
@@ -35,13 +39,46 @@ let pendingRestartReasons = [];
const watchSignatures = new Map();
let shuttingDown = false;
const restartDelayMs = 160;
+const styleBuildDelayMs = 160;
+const stylePollIntervalMs = 1000;
const ignoredSignatureEntries = new Set([".DS_Store"]);
+let styleBuildTimer = null;
+let styleBuildInFlight = false;
+let queuedStyleBuildReason = null;
const ready = {
browser: false,
+ cli: false,
main: false,
renderer: false,
- tray: false
+ tray: false,
+ webBridge: false
};
+const devTarget = parseDevTarget(process.argv.slice(2));
+const enabled = {
+ cli: devTarget === "cli" || devTarget === "electron",
+ electron: devTarget === "electron",
+ ui: true
+};
+const coreSharedSourceRoot = path.join(coreSourceRoot, "shared");
+const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot));
+const activeReadyNames = new Set([
+ ...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []),
+ ...(enabled.cli ? ["cli"] : []),
+ ...(enabled.electron ? ["main"] : [])
+]);
+
+function parseDevTarget(args) {
+ const target = args[0] ?? "electron";
+ if (target === "--help" || target === "-h") {
+ console.log("Usage: node build/dev.mjs [ui|cli|electron]");
+ process.exit(0);
+ }
+ if (target === "ui" || target === "cli" || target === "electron") {
+ return target;
+ }
+ console.error(`Unknown dev target "${target}". Expected ui, cli, or electron.`);
+ process.exit(2);
+}
function logDev(message) {
console.log(`[dev] ${new Date().toISOString()} ${message}`);
@@ -53,6 +90,7 @@ function relativePath(file) {
function readyState() {
return Object.entries(ready)
+ .filter(([name]) => activeReadyNames.has(name))
.map(([name, value]) => `${name}:${value ? "ready" : "pending"}`)
.join(" ");
}
@@ -159,15 +197,71 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on
}
onChange();
- scheduleRestart(reason);
+ if (enabled.electron && options?.restart !== false) {
+ scheduleRestart(reason);
+ }
+}
+
+function scheduleStyleBuild(reason) {
+ queuedStyleBuildReason = reason;
+ if (styleBuildTimer) {
+ clearTimeout(styleBuildTimer);
+ }
+ styleBuildTimer = setTimeout(() => {
+ styleBuildTimer = null;
+ void rebuildStyles(queuedStyleBuildReason ?? reason);
+ }, styleBuildDelayMs);
+}
+
+async function rebuildStyles(reason) {
+ if (styleBuildInFlight) {
+ queuedStyleBuildReason = reason;
+ return;
+ }
+
+ styleBuildInFlight = true;
+ queuedStyleBuildReason = null;
+ try {
+ logDev(`rebuilding styles: ${reason}`);
+ await buildStyles({ minify: false });
+ syncUiRendererToRuntimeDists();
+ if (enabled.electron) {
+ scheduleRestart(`styles rebuilt: ${reason}`);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logDev(`style rebuild failed: ${message}`);
+ } finally {
+ styleBuildInFlight = false;
+ if (queuedStyleBuildReason) {
+ const queuedReason = queuedStyleBuildReason;
+ queuedStyleBuildReason = null;
+ scheduleStyleBuild(queuedReason);
+ }
+ }
+}
+
+function pollStyleWatchRoots() {
+ for (const styleWatchRoot of styleWatchRoots) {
+ const label = `styles ${relativePath(styleWatchRoot)}`;
+ const signature = contentSignature(styleWatchRoot);
+ const previousSignature = watchSignatures.get(label);
+ if (previousSignature === signature.key) {
+ continue;
+ }
+
+ watchSignatures.set(label, signature.key);
+ logDev(`watch event: ${label}; ${signature.summary}; content=changed`);
+ scheduleStyleBuild(label);
+ }
}
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 (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) {
scheduleRestart(reason);
}
}
@@ -217,100 +311,153 @@ function restartElectron() {
});
}
-logDev("starting dev build");
+logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`);
cleanDist();
-copyAppAssets();
-copyMarketplacePlugins();
-copyModelCatalog();
+if (enabled.electron) {
+ copyAppAssets();
+}
+if (enabled.cli || enabled.electron) {
+ copyMarketplacePlugins();
+ copyModelCatalog();
+}
copyBrowserRendererHtml();
copyRendererHtml();
copyTrayRendererHtml();
await buildStyles({ minify: false });
-
-const tailwindProcess = spawn(binPath("tailwindcss"), ["-i", cssInput, "-o", cssOutput, "--watch"], {
- cwd: projectRoot,
- stdio: "inherit",
- shell: process.platform === "win32"
-});
-logDev(`Tailwind watcher started pid=${tailwindProcess.pid ?? "unknown"} input=${relativePath(cssInput)} output=${relativePath(cssOutput)}`);
-tailwindProcess.on("exit", (code, signal) => {
- logDev(`Tailwind watcher exited code=${code ?? "null"} signal=${signal ?? "null"}`);
-});
+syncUiRendererToRuntimeDists();
rememberWatchSignature("home html", rendererHtmlInput);
rememberWatchSignature("browser html", browserRendererHtmlInput);
rememberWatchSignature("tray html", trayRendererHtmlInput);
-rememberWatchSignature("app assets", appAssetsInput);
-if (existsSync(modelCatalogInput)) {
+for (const styleWatchRoot of styleWatchRoots) {
+ rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot);
+}
+if (enabled.electron) {
+ rememberWatchSignature("app assets", appAssetsInput);
+}
+if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
rememberWatchSignature("model catalog", modelCatalogInput);
}
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml);
+ handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => {
+ copyRendererHtml();
+ syncUiRendererToRuntimeDists();
+ });
});
const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, copyBrowserRendererHtml);
+ handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => {
+ copyBrowserRendererHtml();
+ syncUiRendererToRuntimeDists();
+ });
});
const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, copyTrayRendererHtml);
+ handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => {
+ copyTrayRendererHtml();
+ syncUiRendererToRuntimeDists();
+ });
});
-const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
- handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
-});
+const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs);
-const modelCatalogWatcher = existsSync(modelCatalogInput)
+const appAssetsWatcher = enabled.electron
+ ? watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
+ handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
+ })
+ : { close: () => undefined };
+
+const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput)
? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => {
handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog);
})
: { close: () => undefined };
-const mainContext = await esbuild.context(
- createMainBuildOptions({
- mode: "development",
- plugins: [watchPlugin("main", (name) => markReady(name))]
- })
-);
+const contexts = [];
-const rendererContext = await esbuild.context(
- createRendererBuildOptions({
- mode: "development",
- plugins: [
- watchPlugin("renderer", (name) => {
- copyRendererHtml();
- markReady(name);
+if (enabled.electron) {
+ contexts.push(
+ await esbuild.context(
+ createMainBuildOptions({
+ mode: "development",
+ plugins: [watchPlugin("main", (name) => markReady(name))]
})
- ]
- })
-);
+ )
+ );
+}
-const trayRendererContext = await esbuild.context(
- createTrayRendererBuildOptions({
- mode: "development",
- plugins: [
- watchPlugin("tray", (name) => {
- copyTrayRendererHtml();
- markReady(name);
+if (enabled.cli) {
+ contexts.push(
+ await esbuild.context(
+ createCliBuildOptions({
+ mode: "development",
+ plugins: [
+ watchPlugin("cli", (name) => {
+ if (enabled.electron) {
+ copyCliRuntimeToElectronDist();
+ }
+ markReady(name);
+ })
+ ]
})
- ]
- })
-);
+ )
+ );
+}
-const browserRendererContext = await esbuild.context(
- createBrowserRendererBuildOptions({
- mode: "development",
- plugins: [
- watchPlugin("browser", (name) => {
- copyBrowserRendererHtml();
- markReady(name);
+if (enabled.ui) {
+ contexts.push(
+ await esbuild.context(
+ createRendererBuildOptions({
+ mode: "development",
+ plugins: [
+ watchPlugin("renderer", (name) => {
+ copyRendererHtml();
+ syncUiRendererToRuntimeDists();
+ markReady(name);
+ })
+ ]
})
- ]
- })
-);
+ ),
+ await esbuild.context(
+ createTrayRendererBuildOptions({
+ mode: "development",
+ plugins: [
+ watchPlugin("tray", (name) => {
+ copyTrayRendererHtml();
+ syncUiRendererToRuntimeDists();
+ markReady(name);
+ })
+ ]
+ })
+ ),
+ await esbuild.context(
+ createBrowserRendererBuildOptions({
+ mode: "development",
+ plugins: [
+ watchPlugin("browser", (name) => {
+ copyBrowserRendererHtml();
+ syncUiRendererToRuntimeDists();
+ markReady(name);
+ })
+ ]
+ })
+ ),
+ await esbuild.context(
+ createWebClientBridgeBuildOptions({
+ mode: "development",
+ plugins: [
+ watchPlugin("webBridge", (name) => {
+ syncUiRendererToRuntimeDists();
+ markReady(name);
+ })
+ ]
+ })
+ )
+ );
+}
-await Promise.all([mainContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch()]);
+await Promise.all(contexts.map((context) => context.watch()));
logDev("watchers are active");
async function shutdown() {
@@ -322,13 +469,16 @@ async function shutdown() {
if (electronProcess) {
electronProcess.kill();
}
- tailwindProcess.kill();
+ if (styleBuildTimer) {
+ clearTimeout(styleBuildTimer);
+ }
htmlWatcher.close();
browserHtmlWatcher.close();
trayHtmlWatcher.close();
+ clearInterval(stylePoller);
appAssetsWatcher.close();
modelCatalogWatcher.close();
- await Promise.all([mainContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose()]);
+ await Promise.all(contexts.map((context) => context.dispose()));
process.exit(0);
}
diff --git a/build/docker-build.mjs b/build/docker-build.mjs
new file mode 100644
index 0000000..75d4ed1
--- /dev/null
+++ b/build/docker-build.mjs
@@ -0,0 +1,37 @@
+import {
+ buildBrowserRenderer,
+ buildCoreServer,
+ buildRenderer,
+ buildStyles,
+ buildTrayRenderer,
+ buildWebClientBridge,
+ cleanDist,
+ copyBrowserRendererHtml,
+ copyMarketplacePlugins,
+ copyModelCatalog,
+ copyRendererHtml,
+ copyTrayRendererHtml,
+ syncUiRendererToRuntimeDists
+} from "./esbuild.config.mjs";
+
+const mode = process.argv.includes("--dev") ? "development" : "production";
+
+cleanDist();
+copyMarketplacePlugins();
+copyModelCatalog();
+copyBrowserRendererHtml();
+copyRendererHtml();
+copyTrayRendererHtml();
+
+await Promise.all([
+ buildCoreServer({ mode }),
+ buildBrowserRenderer({ mode }),
+ buildRenderer({ mode }),
+ buildTrayRenderer({ mode }),
+ buildWebClientBridge({ mode }),
+ buildStyles({ minify: mode === "production" })
+]);
+
+syncUiRendererToRuntimeDists();
+
+console.log(`Built Docker core server and UI assets in ${mode} mode.`);
diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs
index 3507935..e4a05f6 100644
--- a/build/esbuild.config.mjs
+++ b/build/esbuild.config.mjs
@@ -1,23 +1,63 @@
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 distDir = path.join(projectRoot, "dist");
-export const mainOutDir = path.join(distDir, "main");
-export const rendererOutDir = path.join(distDir, "renderer");
-export const appAssetsDir = path.join(distDir, "assets");
+export const packagesRoot = path.join(projectRoot, "packages");
+export const cliRoot = path.join(packagesRoot, "cli");
+export const coreRoot = path.join(packagesRoot, "core");
+export const electronRoot = path.join(packagesRoot, "electron");
+export const uiRoot = path.join(packagesRoot, "ui");
+export const cliSourceRoot = path.join(cliRoot, "src");
+export const coreSourceRoot = path.join(coreRoot, "src");
+export const electronSourceRoot = path.join(electronRoot, "src");
+export const uiSourceRoot = path.join(uiRoot, "src");
+export const legacyDistDir = path.join(projectRoot, "dist");
+export const cliDistDir = path.join(cliRoot, "dist");
+export const coreDistDir = path.join(coreRoot, "dist");
+export const electronDistDir = path.join(electronRoot, "dist");
+export const uiDistDir = path.join(uiRoot, "dist");
+export const distDir = electronDistDir;
+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");
+export const electronRendererOutDir = path.join(electronDistDir, "renderer");
+export const runtimeRendererOutDirs = [cliRendererOutDir, coreRendererOutDir, electronRendererOutDir];
+export const appAssetsDir = path.join(electronDistDir, "assets");
export const rendererAssetsDir = path.join(rendererOutDir, "assets");
-export const marketplacePluginsDir = path.join(distDir, "marketplace", "plugins");
-export const appAssetsInput = path.join(projectRoot, "assets");
-export const modelCatalogInput = path.join(projectRoot, "models.json");
-export const modelCatalogOutput = path.join(distDir, "models.json");
-export const rendererRoot = path.join(projectRoot, "src", "renderer");
+export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "plugins");
+export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins");
+export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins");
+export const marketplacePluginsDir = electronMarketplacePluginsDir;
+export const appAssetsInput = path.join(electronRoot, "assets");
+export const modelCatalogInput = path.join(coreRoot, "models.json");
+export const cliModelCatalogOutput = path.join(cliDistDir, "models.json");
+export const coreModelCatalogOutput = path.join(coreDistDir, "models.json");
+export const electronModelCatalogOutput = path.join(electronDistDir, "models.json");
+export const modelCatalogOutput = electronModelCatalogOutput;
+export const rendererRoot = uiSourceRoot;
export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html");
export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html");
export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html");
@@ -26,6 +66,19 @@ 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");
+export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
+const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
+const lightweightMcpBundleMaxBytes = 128 * 1024;
+const forbiddenLightweightMcpInputs = [
+ { prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" },
+ { prefix: "packages/core/src/storage/", reason: "native SQLite storage is not allowed in lightweight MCP subprocesses" },
+ { prefix: "packages/electron/src/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" },
+ { prefix: "packages/ui/src/", reason: "UI modules do not belong in stdio MCP subprocesses" },
+ { prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" },
+ { prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" }
+];
+const forbiddenLightweightMcpExternalImports = new Set(["better-sqlite3", "electron"]);
const nodeExternals = [
"electron",
@@ -35,15 +88,28 @@ const nodeExternals = [
];
export function cleanDist() {
- rmSync(distDir, { force: true, recursive: true });
+ rmSync(legacyDistDir, { force: true, recursive: true });
+ rmSync(cliDistDir, { force: true, recursive: true });
+ rmSync(coreDistDir, { force: true, recursive: true });
+ rmSync(electronDistDir, { force: true, recursive: true });
+ rmSync(uiDistDir, { force: true, recursive: true });
ensureDist();
}
export function ensureDist() {
- mkdirSync(mainOutDir, { recursive: true });
+ 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(marketplacePluginsDir, { recursive: true });
+ mkdirSync(cliMarketplacePluginsDir, { recursive: true });
+ mkdirSync(coreMarketplacePluginsDir, { recursive: true });
+ mkdirSync(electronMarketplacePluginsDir, { recursive: true });
mkdirSync(rendererAssetsDir, { recursive: true });
+ for (const outputDir of runtimeRendererOutDirs) {
+ mkdirSync(path.join(outputDir, "assets"), { recursive: true });
+ }
mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true });
mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true });
mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true });
@@ -59,12 +125,16 @@ export function copyAppAssets() {
export function copyModelCatalog() {
ensureDist();
if (existsSync(modelCatalogInput)) {
- cpSync(modelCatalogInput, modelCatalogOutput);
+ cpSync(modelCatalogInput, cliModelCatalogOutput);
+ cpSync(modelCatalogInput, coreModelCatalogOutput);
+ cpSync(modelCatalogInput, electronModelCatalogOutput);
}
}
export function copyRendererHtml() {
- copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js");
+ copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", {
+ beforeModuleScriptTags: [' ']
+ });
}
export function copyTrayRendererHtml() {
@@ -79,14 +149,25 @@ export function copyMarketplacePlugins() {
ensureDist();
for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) {
const source = path.join(projectRoot, "examples", "plugins", filename);
- const target = path.join(marketplacePluginsDir, filename);
if (existsSync(source)) {
- cpSync(source, target);
+ cpSync(source, path.join(cliMarketplacePluginsDir, filename));
+ cpSync(source, path.join(coreMarketplacePluginsDir, filename));
+ cpSync(source, path.join(electronMarketplacePluginsDir, filename));
}
}
}
-function copyRendererPageHtml(input, output, scriptName) {
+export function syncUiRendererToRuntimeDists() {
+ ensureDist();
+ for (const outputDir of runtimeRendererOutDirs) {
+ rmSync(outputDir, { force: true, recursive: true });
+ if (existsSync(rendererOutDir)) {
+ cpSync(rendererOutDir, outputDir, { recursive: true });
+ }
+ }
+}
+
+function copyRendererPageHtml(input, output, scriptName, options = {}) {
ensureDist();
const source = readFileSync(input, "utf8");
const styleTag = ' ';
@@ -95,6 +176,12 @@ function copyRendererPageHtml(input, output, scriptName) {
? source.replace(' ', scriptTag)
: source.replace("