musistudio/claude-code-router/main 6.6M tokens More Tools
```
├── .dockerignore
├── .github/
   ├── workflows/
      ├── docker-release.yml (500 tokens)
      ├── docs.yml (200 tokens)
      ├── release.yml (1500 tokens)
├── .gitignore (100 tokens)
├── .npmrc
├── Dockerfile (500 tokens)
├── LICENSE (omitted)
├── README.md (4.8k tokens)
├── README_zh.md (4.2k tokens)
├── blog/
   ├── en/
      ├── maybe-we-can-do-more-with-the-route.md (1400 tokens)
      ├── progressive-disclosure-of-agent-tools-from-the-perspective-of-cli-tool-style.md (2.1k tokens)
      ├── project-motivation-and-how-it-works.md (1400 tokens)
   ├── images/
      ├── alipay.jpg
      ├── chrome-devtools.png
      ├── chrome-inspect.png
      ├── claude-code-router-img.png
      ├── claude-code-router.png
      ├── claude-code.png
      ├── roadmap.svg (600 tokens)
      ├── search.png
      ├── sponsors/
         ├── glm-en.jpg
         ├── glm-zh.jpg
      ├── statusline-config.png
      ├── statusline.png
      ├── ui.png
      ├── webstorm-formate-file.png
      ├── wechat.jpg
      ├── wechat_group.jpg
   ├── zh/
      ├── 从CLI工具风格看工具渐进式披露.md (3.1k tokens)
      ├── 或许我们能在Router中做更多事情.md (1000 tokens)
      ├── 项目初衷及原理.md (900 tokens)
├── build/
   ├── benchmark-request-logs.mjs (1000 tokens)
   ├── build.mjs (200 tokens)
   ├── dev.mjs (3.2k tokens)
   ├── docker-build.mjs (200 tokens)
   ├── docker-compose.mjs (100 tokens)
   ├── docker-image.mjs (100 tokens)
   ├── docker-local-gateway.mjs (800 tokens)
   ├── electron-builder.local.cjs (100 tokens)
   ├── entitlements.mac.inherit.plist (100 tokens)
   ├── entitlements.mac.plist (100 tokens)
   ├── esbuild.config.mjs (5.6k tokens)
   ├── icon.icns
   ├── icon.ico
   ├── icon.png
   ├── macos-release-preflight.mjs (500 tokens)
   ├── merge-macos-update-metadata.mjs (500 tokens)
   ├── run-tests.mjs (600 tokens)
   ├── test.mjs (1400 tokens)
   ├── verify-macos-notarization.cjs (200 tokens)
   ├── verify-packaged-app.cjs (1300 tokens)
   ├── verify-update-metadata.cjs (400 tokens)
   ├── windows-package-preflight.mjs (200 tokens)
├── cdn/
   ├── README.md (300 tokens)
   ├── package.json (100 tokens)
   ├── public/
      ├── _headers (100 tokens)
      ├── ccr-icon.png
      ├── ccr-provider-buttons.js (2.8k tokens)
   ├── wrangler.toml
├── components.json (100 tokens)
├── docker-compose.yml (100 tokens)
├── docker/
   ├── README.md (2.9k tokens)
   ├── entrypoint.sh (1300 tokens)
   ├── local-ai-gateway/
      ├── README.md (100 tokens)
   ├── pm2.config.cjs (100 tokens)
├── docs/
   ├── .gitignore
   ├── README.md (200 tokens)
   ├── astro.config.mjs (700 tokens)
   ├── issue-reviews/
      ├── 2026-09-09.md (2.1k tokens)
   ├── package-lock.json (49.7k tokens)
   ├── package.json (200 tokens)
   ├── public/
      ├── CNAME
      ├── ccr-icon.png
      ├── favicon.svg (100 tokens)
      ├── logo.png
      ├── platform-icons/
         ├── linux.png
         ├── macos.png
         ├── windows.png
      ├── provider-icons/
         ├── anthropic.png
         ├── bailian.ico
         ├── claudeapi.png
         ├── code0.png
         ├── deepseek.ico
         ├── fenno.jpg
         ├── gemini.svg (5.6k tokens)
         ├── infistar-ai.jpg
         ├── minimax.ico
         ├── mistral.webp
         ├── moonshot.ico
         ├── nvidia.svg (200 tokens)
         ├── openai.png
         ├── openrouter.ico
         ├── qiniu-ai.png
         ├── runapi.jpg
         ├── siliconflow.png
         ├── teamorouter.png
         ├── unity2.jpg
         ├── zai-global-coding.svg (2.2k tokens)
         ├── zai-global-general.svg (2.2k tokens)
         ├── zhipu-cn-coding.png
         ├── zhipu-cn-general.png
   ├── src/
      ├── agentclaw-docs.ts (200 tokens)
      ├── components/
         ├── DocPage.astro (2.5k tokens)
         ├── Redirect.astro (200 tokens)
         ├── interactive/
            ├── LogsViewDemo.tsx (300 tokens)
            ├── ObservabilityViewDemo.tsx (500 tokens)
            ├── ProfileViewDemo.tsx (1300 tokens)
            ├── ProvidersViewDemo.tsx (2k tokens)
            ├── demo.css (1500 tokens)
            ├── demoRuntime.tsx (2k tokens)
            ├── fixtures-agent-analysis.json (178.8k tokens)
            ├── fixtures-request-logs.json (4.8k tokens)
            ├── mockData.ts (100 tokens)
      ├── configuration-docs.ts (200 tokens)
      ├── content/
         ├── docs/
            ├── en/
               ├── agentclaw.md (1300 tokens)
               ├── agentclaw/
                  ├── dingtalk.md (800 tokens)
                  ├── discord.md (900 tokens)
                  ├── feishu.md (900 tokens)
                  ├── line.md (700 tokens)
                  ├── setup.md (1000 tokens)
                  ├── slack.md (800 tokens)
                  ├── telegram.md (700 tokens)
                  ├── wecom.md (700 tokens)
                  ├── weixin-ilink.md (700 tokens)
               ├── configuration.md (500 tokens)
               ├── configuration/
                  ├── agents/
                     ├── claude-code.md (1100 tokens)
                     ├── claude-design.md (500 tokens)
                     ├── codex.md (900 tokens)
                     ├── grok.md (600 tokens)
                     ├── kilo.md (600 tokens)
                     ├── kimi.md (600 tokens)
                     ├── opencode.md (800 tokens)
                     ├── pi.md (500 tokens)
                     ├── workbuddy.md (900 tokens)
                     ├── zcode.md (700 tokens)
                  ├── api-keys.md (400 tokens)
                  ├── configuration-file.md (200 tokens)
                  ├── extensions.md (2.4k tokens)
                  ├── fusion-mcp-tool.md (1000 tokens)
                  ├── fusion-vision.md (400 tokens)
                  ├── fusion-web-search.md (300 tokens)
                  ├── fusion.md (400 tokens)
                  ├── observability.md (600 tokens)
                  ├── overview.md (1500 tokens)
                  ├── profiles.md (2.2k tokens)
                  ├── provider-deeplink.md (4.9k tokens)
                  ├── providers.md (3.1k tokens)
                  ├── routing.md (5.8k tokens)
                  ├── server.md (700 tokens)
                  ├── toolhub.md (2.1k tokens)
                  ├── tray.md (400 tokens)
               ├── fusion/
                  ├── image-generation.md (600 tokens)
                  ├── video-generation.md (600 tokens)
               ├── guides.md (1000 tokens)
               ├── guides/
                  ├── agent-profile.md (1100 tokens)
                  ├── cli.md (2000 tokens)
                  ├── docker.md (2.6k tokens)
                  ├── install.md (700 tokens)
                  ├── observability.md (400 tokens)
                  ├── provider.md (600 tokens)
               ├── index.md (800 tokens)
               ├── troubleshooting.md (700 tokens)
            ├── zh/
               ├── agentclaw.md (800 tokens)
               ├── agentclaw/
                  ├── dingtalk.md (400 tokens)
                  ├── discord.md (500 tokens)
                  ├── feishu.md (400 tokens)
                  ├── line.md (400 tokens)
                  ├── setup.md (500 tokens)
                  ├── slack.md (500 tokens)
                  ├── telegram.md (400 tokens)
                  ├── wecom.md (400 tokens)
                  ├── weixin-ilink.md (400 tokens)
               ├── configuration.md (200 tokens)
               ├── configuration/
                  ├── agents/
                     ├── claude-code.md (600 tokens)
                     ├── claude-design.md (300 tokens)
                     ├── codex.md (500 tokens)
                     ├── grok.md (300 tokens)
                     ├── kilo.md (300 tokens)
                     ├── kimi.md (300 tokens)
                     ├── opencode.md (500 tokens)
                     ├── pi.md (300 tokens)
                     ├── workbuddy.md (500 tokens)
                     ├── zcode.md (400 tokens)
                  ├── api-keys.md (200 tokens)
                  ├── configuration-file.md (100 tokens)
                  ├── extensions.md (1600 tokens)
                  ├── fusion-mcp-tool.md (500 tokens)
                  ├── fusion-vision.md (200 tokens)
                  ├── fusion-web-search.md (200 tokens)
                  ├── fusion.md (200 tokens)
                  ├── observability.md (200 tokens)
                  ├── overview.md (700 tokens)
                  ├── profiles.md (1200 tokens)
                  ├── provider-deeplink.md (4.4k tokens)
                  ├── providers.md (1600 tokens)
                  ├── routing.md (3.3k tokens)
                  ├── server.md (300 tokens)
                  ├── toolhub.md (1100 tokens)
                  ├── tray.md (200 tokens)
               ├── fusion/
                  ├── image-generation.md (300 tokens)
                  ├── video-generation.md (300 tokens)
               ├── guides.md (500 tokens)
               ├── guides/
                  ├── agent-profile.md (600 tokens)
                  ├── cli.md (1000 tokens)
                  ├── docker.md (1400 tokens)
                  ├── install.md (400 tokens)
                  ├── observability.md (200 tokens)
                  ├── provider.md (300 tokens)
               ├── index.md (400 tokens)
               ├── troubleshooting.md (300 tokens)
      ├── docs-structure.ts (5.1k tokens)
      ├── i18n/
         ├── content.ts (400 tokens)
      ├── layouts/
         ├── DocsLayout.astro (7.3k tokens)
      ├── pages/
         ├── agentclaw.astro
         ├── agentclaw/
            ├── [slug].astro (100 tokens)
         ├── bot-与-im-接力-agent/
            ├── [platform].astro (100 tokens)
         ├── configuration.astro
         ├── configuration/
            ├── [slug].astro (100 tokens)
            ├── agents/
               ├── [slug].astro (100 tokens)
            ├── bot-relay.astro
            ├── bot-setup.astro
            ├── config-file.astro
            ├── extensions.astro
            ├── fusion-mcp-tool.astro
            ├── fusion-vision.astro
            ├── fusion-web-search.astro
            ├── fusion.astro
            ├── profile.astro
            ├── provider-deeplink.astro
            ├── provider.astro
            ├── routing.astro
            ├── toolhub.astro
         ├── en/
            ├── agentclaw.astro
            ├── agentclaw/
               ├── [slug].astro (100 tokens)
            ├── configuration.astro
            ├── configuration/
               ├── [slug].astro (100 tokens)
               ├── agents/
                  ├── [slug].astro (100 tokens)
               ├── bot-setup.astro
               ├── bots.astro
               ├── extensions.astro
               ├── fusion-mcp-tool.astro
               ├── fusion-models.astro
               ├── fusion-vision.astro
               ├── fusion-web-search.astro
               ├── provider-deeplink.astro
               ├── routing.astro
               ├── toolhub.astro
            ├── extensions.astro
            ├── fusion.astro
            ├── fusion/
               ├── [slug].astro (100 tokens)
            ├── guides.astro
            ├── guides/
               ├── [slug].astro (200 tokens)
               ├── agent-profile.astro (100 tokens)
               ├── observability.astro (200 tokens)
               ├── provider.astro (100 tokens)
            ├── index.astro
            ├── provider-import.astro
            ├── relay-agents-in-im-with-bots/
               ├── [platform].astro (100 tokens)
            ├── routing.astro
            ├── toolhub.astro
            ├── troubleshooting.astro
         ├── extensions.astro
         ├── fusion.astro
         ├── fusion/
            ├── [slug].astro (100 tokens)
         ├── guides.astro
         ├── guides/
            ├── [slug].astro (200 tokens)
            ├── agent-profile-app.astro (200 tokens)
            ├── agent-profile.astro (100 tokens)
            ├── logs-app.astro (200 tokens)
            ├── observability-app.astro (200 tokens)
            ├── observability.astro (200 tokens)
            ├── provider-app.astro (200 tokens)
            ├── provider.astro (100 tokens)
         ├── index.astro
         ├── provider-import.astro
         ├── routing.astro
         ├── search-index-[locale].json.ts (700 tokens)
         ├── toolhub.astro
         ├── troubleshooting.astro
      ├── section-docs.ts (100 tokens)
      ├── styles/
         ├── global.css (9.5k tokens)
   ├── tsconfig.json
├── electron-builder.json (400 tokens)
├── extension/
   ├── chrome/
      ├── README.md (300 tokens)
      ├── background.js (1500 tokens)
      ├── confirm.js (300 tokens)
      ├── manifest.json (100 tokens)
      ├── popup.html (1200 tokens)
      ├── popup.js (1900 tokens)
├── package-lock.json (omitted)
├── package.json (1000 tokens)
├── packages/
   ├── cli/
      ├── LICENSE (200 tokens)
      ├── README.md (1500 tokens)
      ├── README_zh.md (800 tokens)
      ├── package.json (300 tokens)
      ├── src/
         ├── cli.ts (6.9k tokens)
      ├── test/
         ├── integration/
            ├── cli-help.test.mjs (500 tokens)
      ├── tsconfig.json
   ├── core/
      ├── benchmark/
         ├── request-body-routing.bench.mjs (500 tokens)
         ├── request-log-runtime.bench.mjs (3.2k tokens)
         ├── request-log-web-load.mjs (700 tokens)
      ├── models.json (4309.3k tokens)
      ├── package.json (200 tokens)
      ├── src/
         ├── agents/
            ├── app-info-paths.ts (200 tokens)
            ├── bot-gateway/
               ├── env.ts (2.3k tokens)
               ├── handoff-scan-service.ts (1900 tokens)
               ├── qr-login-service.ts (3.5k tokens)
               ├── sdk-import.ts (100 tokens)
            ├── cdp-client.ts (1000 tokens)
            ├── claude-app/
               ├── cdp.ts (2k tokens)
               ├── gateway-routes.ts (3.2k tokens)
               ├── gateway-service.ts (3.4k tokens)
               ├── launch.ts (2.8k tokens)
               ├── vm-storage.ts (2.6k tokens)
            ├── claude-code/
               ├── auth-mode.ts (300 tokens)
               ├── environment.ts (800 tokens)
            ├── codex/
               ├── app-launch.ts (8.2k tokens)
               ├── base-instructions.ts (400 tokens)
               ├── cli-middleware-runtime.ts (60.1k tokens)
               ├── media-preview-bridge.ts (6.3k tokens)
               ├── model-catalog.ts (6.6k tokens)
            ├── kilo/
               ├── profile-config.ts (1800 tokens)
            ├── local-providers/
               ├── claude-code.ts (3.2k tokens)
               ├── codex.ts (5.9k tokens)
               ├── grok.ts (6.2k tokens)
               ├── kimi.ts (5.5k tokens)
               ├── opencode.ts (3.7k tokens)
               ├── service.ts (700 tokens)
               ├── shared.ts (2000 tokens)
               ├── zcode.ts (2.2k tokens)
            ├── opencode/
               ├── app-launch.ts (2k tokens)
               ├── profile-config.ts (2.6k tokens)
            ├── pi/
               ├── profile-config.ts (1700 tokens)
            ├── request-enricher.ts (100 tokens)
            ├── zcode/
               ├── model-catalog.ts (500 tokens)
               ├── profile-config.ts (2.4k tokens)
         ├── config/
            ├── config-repository.ts (5.5k tokens)
            ├── config.ts (31k tokens)
            ├── constants.ts (500 tokens)
            ├── default-config.ts (1300 tokens)
            ├── onboarding-state.ts (200 tokens)
         ├── contracts/
            ├── app.ts (13.9k tokens)
            ├── deep-link.ts (6k tokens)
            ├── i18n.ts (3k tokens)
            ├── ipc-channels.ts (1100 tokens)
         ├── entrypoints/
            ├── server.ts (700 tokens)
         ├── gateway/
            ├── application/
               ├── gateway-service.ts (6.2k tokens)
            ├── auth/
               ├── api-key-authorizer.ts (1700 tokens)
            ├── claude-code-router-plugin.ts (12.3k tokens)
            ├── context-archive.ts (5.6k tokens)
            ├── context-archive/
               ├── protocol.ts (6.1k tokens)
               ├── store.ts (2.1k tokens)
            ├── core-runtime/
               ├── config-compiler.ts (6.5k tokens)
               ├── gateway-bootstrap.ts (800 tokens)
               ├── local-agent-auth-provider-hook.ts (2.8k tokens)
               ├── meta-token-floor.ts (200 tokens)
               ├── responses-session-affinity.ts (900 tokens)
               ├── responses-tool-strictness.ts (500 tokens)
               ├── router-plugin-contract.ts (700 tokens)
               ├── router-plugin.ts (8.9k tokens)
               ├── supervisor.ts (6.6k tokens)
               ├── upstream-header-sanitizer.ts (1700 tokens)
            ├── existing-gateway-probe.ts (1900 tokens)
            ├── features/
               ├── anthropic-response-model.ts (700 tokens)
               ├── codex-multi-agent-bridge.ts (2.8k tokens)
               ├── codex-patch-bridge.ts (3.2k tokens)
               ├── context-archive-continuation.ts (5.3k tokens)
               ├── cursor-compat.ts (1300 tokens)
               ├── hosted-web-search/
                  ├── discovery.ts (5.5k tokens)
                  ├── evidence.ts (4.2k tokens)
                  ├── index.ts (200 tokens)
                  ├── request-transform.ts (3.1k tokens)
                  ├── response-transform.ts (7.9k tokens)
                  ├── sse.ts (400 tokens)
               ├── model-discovery.ts (7.2k tokens)
            ├── http/
               ├── body.ts (400 tokens)
               ├── io.ts (2.5k tokens)
               ├── request-handler.ts (2.6k tokens)
            ├── internal/
               ├── clock.ts (100 tokens)
               ├── collections.ts (100 tokens)
               ├── shared.ts (2000 tokens)
               ├── value.ts (200 tokens)
            ├── limits/
               ├── window-limiter.ts (900 tokens)
            ├── model-catalog.ts (3.1k tokens)
            ├── remote-control-service.ts (4.1k tokens)
            ├── request/
               ├── pipeline.ts (11.2k tokens)
            ├── runtime-change.ts (300 tokens)
            ├── runtime-config-control.ts (200 tokens)
            ├── service.ts (500 tokens)
            ├── upstream/
               ├── executor.ts (9.1k tokens)
               ├── retry-policy.ts (400 tokens)
         ├── mcp/
            ├── browser-web-search-proxy-mcp.ts (900 tokens)
            ├── fusion-config.ts (5.9k tokens)
            ├── fusion-tool-fallback-mcp.ts (1300 tokens)
            ├── fusion-vision-mcp.ts (9.1k tokens)
            ├── grok-media-config.ts (600 tokens)
            ├── grok-media-mcp.ts (1700 tokens)
            ├── media-tools-proxy-mcp.ts (1500 tokens)
            ├── network-capture-mcp.ts (2.5k tokens)
            ├── tool-discovery.ts (3.6k tokens)
            ├── toolhub-config.ts (1600 tokens)
            ├── toolhub-mcp.ts (20.5k tokens)
         ├── media/
            ├── contracts.ts (400 tokens)
            ├── executors.ts (3.8k tokens)
            ├── models.ts (1000 tokens)
            ├── service.ts (7k tokens)
            ├── storage.ts (1700 tokens)
            ├── tools.ts (2.6k tokens)
         ├── models/
            ├── catalog-file.ts (300 tokens)
            ├── pricing-service.ts (3.8k tokens)
         ├── observability/
            ├── raw-trace-sync.ts (14.2k tokens)
            ├── request-log-admission-store.ts (3.1k tokens)
            ├── request-log-body.ts (1600 tokens)
            ├── request-log-limits.ts (200 tokens)
            ├── request-log-model.ts (400 tokens)
            ├── request-log-runtime.ts (9k tokens)
            ├── request-log-store.ts (43.5k tokens)
            ├── request-log-worker.ts (2.3k tokens)
            ├── route-trace.ts (2.3k tokens)
            ├── sensitive-headers.ts (200 tokens)
         ├── platform/
            ├── socket-compat.ts (300 tokens)
            ├── windows-app-discovery.ts (2.5k tokens)
            ├── windows-system.ts (400 tokens)
         ├── plugins/
            ├── backend-service.ts (2.4k tokens)
            ├── built-ins/
               ├── openrouter-discount-provider-router.ts (8.5k tokens)
            ├── marketplace.ts (4.2k tokens)
            ├── service.ts (10.5k tokens)
         ├── profiles/
            ├── api-key.ts (700 tokens)
            ├── launch-core.ts (2.9k tokens)
            ├── launch-service.ts (15.2k tokens)
            ├── model-allowlist.ts (1200 tokens)
            ├── service.ts (32.4k tokens)
         ├── providers/
            ├── account-service.ts (17.8k tokens)
            ├── account-webcontent.ts (600 tokens)
            ├── credential-pool.ts (1000 tokens)
            ├── icons.ts (2k tokens)
            ├── manifest-service.ts (2.3k tokens)
            ├── model-auto-refresh.ts (5k tokens)
            ├── model-catalog.ts (5.2k tokens)
            ├── new-api.ts (600 tokens)
            ├── oauth-plugin.ts (200 tokens)
            ├── openrouter-provider-catalog.ts (1900 tokens)
            ├── presets/
               ├── anthropic/
                  ├── index.ts (100 tokens)
               ├── bailian/
                  ├── index.ts (100 tokens)
               ├── claudeapi/
                  ├── index.ts (100 tokens)
               ├── code0/
                  ├── index.ts (100 tokens)
               ├── deepseek/
                  ├── index.ts (300 tokens)
               ├── fenno/
                  ├── index.ts (100 tokens)
               ├── gemini/
                  ├── index.ts (100 tokens)
               ├── index.ts (1000 tokens)
               ├── infistar-ai/
                  ├── index.ts (100 tokens)
               ├── kimi-coding/
                  ├── index.ts (200 tokens)
               ├── minimax/
                  ├── index.ts (300 tokens)
               ├── mistral/
                  ├── index.ts (200 tokens)
               ├── moonshot/
                  ├── index.ts (600 tokens)
               ├── nvidia/
                  ├── index.ts (100 tokens)
               ├── openai/
                  ├── index.ts (100 tokens)
               ├── openrouter/
                  ├── index.ts (300 tokens)
               ├── qiniu-ai/
                  ├── index.ts (200 tokens)
               ├── runapi/
                  ├── index.ts (100 tokens)
               ├── siliconflow/
                  ├── index.ts (300 tokens)
               ├── teamorouter/
                  ├── index.ts (100 tokens)
               ├── types.ts (200 tokens)
               ├── unity2/
                  ├── index.ts (100 tokens)
               ├── utils.ts (800 tokens)
               ├── xiaomi/
                  ├── index.ts (500 tokens)
               ├── zai-global-coding/
                  ├── index.ts (400 tokens)
               ├── zai-global-general/
                  ├── index.ts (400 tokens)
               ├── zhipu-cn-coding/
                  ├── index.ts (400 tokens)
               ├── zhipu-cn-general/
                  ├── index.ts (400 tokens)
            ├── probe.ts (13.1k tokens)
            ├── runtime-topology.ts (4k tokens)
            ├── url.ts (1600 tokens)
         ├── proxy/
            ├── certificates.ts (1600 tokens)
            ├── service.ts (12.3k tokens)
            ├── system-proxy-fetch.ts (1900 tokens)
            ├── system-proxy.ts (6.9k tokens)
            ├── undici-proxy-agent.ts
         ├── routing/
            ├── config-compiler.ts (2000 tokens)
            ├── contracts.ts (400 tokens)
            ├── execution-plan.ts (400 tokens)
            ├── failure-classifier.ts (200 tokens)
            ├── model-registry.ts (1800 tokens)
            ├── model-resolution.ts (100 tokens)
            ├── policy-engine.ts (100 tokens)
            ├── protocol-adapter.ts (300 tokens)
            ├── protocol-endpoints.ts (300 tokens)
            ├── rewrite.ts (2.7k tokens)
            ├── route-script-context.ts (900 tokens)
            ├── route-script-result.ts (1200 tokens)
            ├── route-script-runtime.ts (2.8k tokens)
            ├── route-script-worker-protocol.ts (100 tokens)
            ├── route-script-worker.ts (2.7k tokens)
         ├── runtime/
            ├── app-paths.ts (500 tokens)
            ├── desktop-app.ts (100 tokens)
         ├── storage/
            ├── migration.ts (200 tokens)
            ├── sqlite-native.ts (200 tokens)
         ├── usage/
            ├── billing-sync.ts (2.3k tokens)
            ├── model-attribution.ts (900 tokens)
            ├── normalization.ts (800 tokens)
            ├── store.ts (9.5k tokens)
         ├── web/
            ├── management-server.ts (11.5k tokens)
      ├── test/
         ├── architecture/
            ├── gateway-service-architecture.test.mjs (400 tokens)
         ├── integration/
            ├── agents/
               ├── codex-cli-middleware-runtime.test.mjs (10.8k tokens)
            ├── gateway/
               ├── anthropic-tool-ids.test.mjs (1200 tokens)
               ├── gateway-client-disconnect.test.mjs (1300 tokens)
               ├── gateway-virtual-models.test.mjs (12.5k tokens)
               ├── profile-model-allowlist-http.test.mjs (2.1k tokens)
            ├── mcp/
               ├── fusion-vision-mcp.test.mjs (4.7k tokens)
               ├── grok-media-service.test.mjs (6k tokens)
               ├── local-ai-gateway-media-live.test.mjs (3k tokens)
               ├── media-tools-proxy-mcp.test.mjs (1000 tokens)
               ├── toolhub-mcp-runtime.test.mjs (5.4k tokens)
            ├── observability/
               ├── request-log-pricing-transaction.test.mjs (1900 tokens)
               ├── request-log-runtime.test.mjs (14k tokens)
               ├── request-log-store.test.mjs (14.6k tokens)
            ├── plugins/
               ├── backend-service.test.mjs (300 tokens)
               ├── plugin-service.test.mjs (3.3k tokens)
            ├── profiles/
               ├── profile-service.test.mjs (16.8k tokens)
            ├── usage/
               ├── usage-store.test.mjs (5.1k tokens)
         ├── support/
            ├── loopback-listener.mjs (200 tokens)
         ├── unit/
            ├── agents/
               ├── app-info-paths.test.mjs (400 tokens)
               ├── bot-gateway-env.test.mjs (1300 tokens)
               ├── claude-app-cdp.test.mjs (200 tokens)
               ├── claude-app-gateway-models.test.mjs (5.9k tokens)
               ├── claude-app-gateway-service.test.mjs (1200 tokens)
               ├── claude-app-gateway-sync.test.mjs (2.3k tokens)
               ├── claude-app-launch.test.mjs (1100 tokens)
               ├── claude-app-vm-storage.test.mjs (1100 tokens)
               ├── claude-code-auth-mode.test.mjs (200 tokens)
               ├── claude-environment.test.mjs (700 tokens)
               ├── codex-app-model-catalog.test.mjs (4.1k tokens)
               ├── codex-media-preview-bridge.test.mjs (1300 tokens)
               ├── codex-model-catalog.test.mjs (5.6k tokens)
               ├── context-archive.test.mjs (9.2k tokens)
               ├── gateway-upstream-error-log.test.mjs (700 tokens)
               ├── kilo-profile-config.test.mjs (800 tokens)
               ├── local-agent-provider-claude-code.test.mjs (3.6k tokens)
               ├── local-agent-provider-codex.test.mjs (2.6k tokens)
               ├── local-agent-provider-grok.test.mjs (3.8k tokens)
               ├── local-agent-provider-kimi.test.mjs (2.3k tokens)
               ├── local-agent-provider-opencode.test.mjs (2.5k tokens)
               ├── opencode-profile-config.test.mjs (2000 tokens)
               ├── pi-profile-config.test.mjs (1200 tokens)
               ├── plugin-permissions.test.mjs (2.4k tokens)
               ├── plugin-stop-reason.test.mjs (400 tokens)
               ├── provider-model-metadata.test.mjs (700 tokens)
               ├── provider-plugin-runtime-identity.test.mjs (1600 tokens)
               ├── socket-compat.test.mjs (500 tokens)
               ├── zcode-profile-config.test.mjs (1100 tokens)
            ├── config/
               ├── config-credential-writes.test.mjs (600 tokens)
               ├── config-env-interpolation.test.mjs (500 tokens)
               ├── config-repository-backup-deduplication.test.mjs (900 tokens)
               ├── config-repository-cleanup-retry.test.mjs (1000 tokens)
               ├── config-repository-existing-data-migration.test.mjs (500 tokens)
               ├── config-repository-init-retry.test.mjs (300 tokens)
               ├── config-repository-migration.test.mjs (300 tokens)
               ├── legacy-json-preservation.test.mjs (300 tokens)
               ├── legacy-json-selected-source.test.mjs (300 tokens)
               ├── onboarding-state.test.mjs (200 tokens)
               ├── plugin-config-migration.test.mjs (2000 tokens)
               ├── profile-legacy-sync.test.mjs (900 tokens)
               ├── provider-protocol-capability.test.mjs (700 tokens)
               ├── theme-preference.test.mjs (300 tokens)
            ├── contracts/
               ├── app.test.mjs (100 tokens)
               ├── deep-link.test.mjs (1400 tokens)
            ├── gateway/
               ├── anthropic-response-model.test.mjs (1800 tokens)
               ├── api-key-authorizer.test.mjs (1100 tokens)
               ├── codex-multi-agent-bridge.test.mjs (900 tokens)
               ├── codex-patch-bridge.test.mjs (2.6k tokens)
               ├── gateway-billing-sync.test.mjs (700 tokens)
               ├── gateway-claude-code-oauth.test.mjs (600 tokens)
               ├── gateway-fetch-preload.test.mjs (1000 tokens)
               ├── gateway-media-config.test.mjs (600 tokens)
               ├── gateway-runtime-change.test.mjs (1100 tokens)
               ├── gateway-status.test.mjs (2.8k tokens)
               ├── http-boundary.test.mjs (1300 tokens)
               ├── local-agent-auth-provider-hook.test.mjs (2.9k tokens)
               ├── meta-token-floor.test.mjs (300 tokens)
               ├── request-pipeline-query.test.mjs (400 tokens)
               ├── responses-session-affinity.test.mjs (1200 tokens)
               ├── responses-tool-strictness.test.mjs (1200 tokens)
               ├── router-builtins.test.mjs (18.6k tokens)
               ├── router-plugin.test.mjs (5.5k tokens)
               ├── router-subagent-virtual-suffix.test.mjs (1200 tokens)
               ├── routing-architecture.test.mjs (3.4k tokens)
               ├── single-runtime-blockers.test.mjs (800 tokens)
               ├── sse-utf8-chunk-boundary.test.mjs (800 tokens)
               ├── upstream-executor.test.mjs (3.8k tokens)
               ├── upstream-header-sanitizer.test.mjs (1600 tokens)
               ├── window-limiter.test.mjs (600 tokens)
            ├── mcp/
               ├── toolhub-browser-automation-config.test.mjs (1600 tokens)
            ├── media/
               ├── media-executor-download.test.mjs (500 tokens)
            ├── models/
               ├── model-catalog-file.test.mjs (500 tokens)
               ├── pricing-service.test.mjs (500 tokens)
            ├── observability/
               ├── raw-trace-sync.test.mjs (8k tokens)
               ├── request-log-body.test.mjs (500 tokens)
               ├── route-trace.test.mjs (900 tokens)
            ├── plugins/
               ├── openrouter-discount-provider-router.test.mjs (6.7k tokens)
            ├── profiles/
               ├── ccr-cli-runtime.test.mjs (500 tokens)
               ├── profile-api-key.test.mjs (700 tokens)
               ├── profile-app-process-detection.test.mjs (500 tokens)
               ├── profile-gateway-probe.test.mjs (1400 tokens)
               ├── profile-launch-core.test.mjs (2.5k tokens)
               ├── profile-model-allowlist.test.mjs (1400 tokens)
               ├── windows-ccr-launcher.test.mjs (300 tokens)
            ├── providers/
               ├── credential-pool.test.mjs (500 tokens)
               ├── gemini-protocol-selection.test.mjs (400 tokens)
               ├── opencode-session.test.mjs (500 tokens)
               ├── openrouter-compatibility.test.mjs (500 tokens)
               ├── openrouter-provider-catalog.test.mjs (900 tokens)
               ├── provider-account-service.test.mjs (3.7k tokens)
               ├── provider-catalog-cache.test.mjs (500 tokens)
               ├── provider-model-auto-refresh.test.mjs (3.1k tokens)
               ├── provider-model-catalog.test.mjs (400 tokens)
               ├── provider-preset-utils.test.mjs (2.4k tokens)
               ├── provider-probe.test.mjs (6k tokens)
               ├── provider-url.test.mjs (700 tokens)
            ├── proxy/
               ├── proxy-upstream.test.mjs (300 tokens)
            ├── routing/
               ├── model-registry-cache.test.mjs (400 tokens)
               ├── protocol-endpoints.test.mjs (400 tokens)
               ├── route-script-runtime.test.mjs (6.1k tokens)
            ├── runtime/
               ├── app-paths.test.mjs (400 tokens)
            ├── storage/
               ├── migration.test.mjs (400 tokens)
            ├── usage/
               ├── usage-normalization.test.mjs (800 tokens)
            ├── web/
               ├── web-management-server.test.mjs (2k tokens)
      ├── tsconfig.json
   ├── electron/
      ├── assets/
         ├── logo.png
         ├── tray-cyan.png
         ├── tray-orange.png
         ├── tray-violet.png
         ├── tray.png
      ├── bundled-plugins/
         ├── claude-design/
            ├── README.md (300 tokens)
            ├── index.cjs (119.5k tokens)
            ├── plugin.json (100 tokens)
         ├── claude-ship/
            ├── README.md (600 tokens)
            ├── index.cjs
            ├── plugin.json (100 tokens)
         ├── new-api-account/
            ├── README.md (300 tokens)
            ├── index.cjs (1800 tokens)
            ├── plugin.json (100 tokens)
      ├── package.json (100 tokens)
      ├── src/
         ├── main/
            ├── app-menu.ts (800 tokens)
            ├── bot-gateway-qr-window-service.ts (700 tokens)
            ├── browser-automation-mcp.ts (26.2k tokens)
            ├── browser-preload.ts (400 tokens)
            ├── built-in-browser.ts (6.5k tokens)
            ├── chrome-login-import.ts (4k tokens)
            ├── claude-design-window.ts (5.4k tokens)
            ├── deep-link.ts (3k tokens)
            ├── electron-web-search-mcp.ts (9.9k tokens)
            ├── ipc.ts (12k tokens)
            ├── launch-at-login.ts (200 tokens)
            ├── main-app.ts (2.4k tokens)
            ├── main.ts (700 tokens)
            ├── native-theme.ts (100 tokens)
            ├── plugin-app-url.ts (1200 tokens)
            ├── preload.ts (3k tokens)
            ├── provider-account-webcontent.ts (2.2k tokens)
            ├── tray-controller.ts (5.2k tokens)
            ├── update-service.ts (2.4k tokens)
            ├── windows.ts (4.9k tokens)
      ├── test/
         ├── unit/
            ├── claude-design-chat.test.ts (1600 tokens)
            ├── claude-design-plugin-assets.test.ts (2.3k tokens)
            ├── claude-design-window.test.ts (2.8k tokens)
            ├── launch-at-login.test.ts (100 tokens)
            ├── native-theme.test.ts (200 tokens)
            ├── new-api-account-plugin.test.mjs (500 tokens)
            ├── plugin-app-url.test.ts (1300 tokens)
            ├── provider-account-webcontent.test.ts (1100 tokens)
      ├── tsconfig.json
   ├── ui/
      ├── package.json (200 tokens)
      ├── src/
         ├── assets/
            ├── agent-logos/
               ├── claude-code.png
               ├── codex.png
               ├── grok.ico
               ├── kilo.svg (100 tokens)
               ├── opencode.ico
               ├── pi.svg (100 tokens)
               ├── workbuddy.png
               ├── zcode.png
            ├── logo.png
            ├── onboarding/
               ├── mascot-transition.svg (1400 tokens)
            ├── provider-icons/
               ├── anthropic.png
               ├── bailian.ico
               ├── claudeapi.png
               ├── code0.png
               ├── deepseek.ico
               ├── fenno.jpg
               ├── gemini.svg (5.6k tokens)
               ├── infistar-ai.jpg
               ├── minimax.ico
               ├── mistral.webp
               ├── moonshot.ico
               ├── nvidia.svg (200 tokens)
               ├── openai.png
               ├── openrouter.ico
               ├── qiniu-ai.png
               ├── runapi.jpg
               ├── siliconflow.png
               ├── teamorouter.png
               ├── unity2.jpg
               ├── xiaomi-mimo.png
               ├── zai-global-coding.svg (2.2k tokens)
               ├── zai-global-general.svg (2.2k tokens)
               ├── zhipu-cn-coding.png
               ├── zhipu-cn-general.png
            ├── tray-cyan.png
            ├── tray-orange.png
            ├── tray-violet.png
         ├── components/
            ├── ui/
               ├── badge.tsx (300 tokens)
               ├── button.tsx (500 tokens)
               ├── card.tsx (300 tokens)
               ├── checkbox.tsx (300 tokens)
               ├── dialog.tsx (1800 tokens)
               ├── input.tsx (200 tokens)
               ├── label.tsx (100 tokens)
               ├── popover.tsx (200 tokens)
               ├── select.tsx (400 tokens)
               ├── switch.tsx (300 tokens)
               ├── tabs.tsx (600 tokens)
               ├── textarea.tsx (200 tokens)
               ├── tooltip.tsx (1600 tokens)
         ├── generated/
            ├── claude-code-config-options.json (87.9k tokens)
         ├── lib/
            ├── baseui-provider.tsx (100 tokens)
            ├── morph-icon.ts (100 tokens)
            ├── usage-activity.ts (1700 tokens)
            ├── utils.ts
         ├── pages/
            ├── browser/
               ├── index.html (100 tokens)
               ├── main.tsx (4.4k tokens)
            ├── home/
               ├── App.tsx (28.6k tokens)
               ├── components/
                  ├── api-keys.tsx (3.6k tokens)
                  ├── dashboard.tsx (52.6k tokens)
                  ├── dialog-stack.tsx (1000 tokens)
                  ├── extensions.tsx (4.4k tokens)
                  ├── feedback.tsx (800 tokens)
                  ├── index.ts (200 tokens)
                  ├── layout.tsx (3.4k tokens)
                  ├── model-selector.tsx (3.5k tokens)
                  ├── network-logs.tsx (22.2k tokens)
                  ├── onboarding.tsx (3.6k tokens)
                  ├── profiles.tsx (18.5k tokens)
                  ├── providers.tsx (44.8k tokens)
                  ├── routing.tsx (6.8k tokens)
                  ├── settings.tsx (26.3k tokens)
                  ├── share-cards.tsx (6.7k tokens)
                  ├── unsaved-changes.tsx (300 tokens)
                  ├── update.tsx (2.7k tokens)
                  ├── virtual-models.tsx (12.1k tokens)
               ├── index.html (100 tokens)
               ├── main.tsx (100 tokens)
               ├── shared/
                  ├── api-keys.ts (2.3k tokens)
                  ├── common.ts (4.7k tokens)
                  ├── config-persistence.ts (700 tokens)
                  ├── config.ts (1900 tokens)
                  ├── controls.tsx (3.9k tokens)
                  ├── extensions.ts (5k tokens)
                  ├── external.tsx (3.9k tokens)
                  ├── fallbacks.ts (400 tokens)
                  ├── i18n.tsx (24.2k tokens)
                  ├── index.tsx (200 tokens)
                  ├── log-body-worker-protocol.ts (800 tokens)
                  ├── log-body.worker.ts (600 tokens)
                  ├── logs.ts (8.3k tokens)
                  ├── motion.tsx (1000 tokens)
                  ├── network.ts (1300 tokens)
                  ├── options.ts (3.8k tokens)
                  ├── polling.ts (500 tokens)
                  ├── profiles.ts (14.5k tokens)
                  ├── provider-accounts.ts (1600 tokens)
                  ├── provider-references.ts (800 tokens)
                  ├── providers.ts (17.8k tokens)
                  ├── routing.ts (4.6k tokens)
                  ├── services.tsx (1200 tokens)
                  ├── types.ts (2.6k tokens)
                  ├── usage.ts (1400 tokens)
                  ├── virtual-models.ts (10k tokens)
            ├── tray/
               ├── TrayApp.tsx (2.3k tokens)
               ├── TrayDetailApp.tsx (800 tokens)
               ├── components/
                  ├── account-panel.tsx (1600 tokens)
                  ├── index.ts (100 tokens)
                  ├── overview-panel.tsx (600 tokens)
                  ├── source-grid.tsx (400 tokens)
                  ├── status-strip.tsx (300 tokens)
                  ├── usage-detail.tsx (1000 tokens)
                  ├── widgets.tsx (4.4k tokens)
               ├── index.html (100 tokens)
               ├── main.tsx (100 tokens)
               ├── shared.tsx (6.8k tokens)
         ├── styles/
            ├── globals.css (9k tokens)
         ├── types/
            ├── assets.d.ts (omitted)
            ├── electron.d.ts (omitted)
         ├── web-client-bridge.ts (2.2k tokens)
      ├── test/
         ├── component/
            ├── components.test.tsx (1500 tokens)
            ├── extensions.test.ts (900 tokens)
            ├── layout.test.tsx (1600 tokens)
            ├── observability.test.tsx (2.5k tokens)
            ├── onboarding.test.tsx (800 tokens)
            ├── overview-components.test.tsx (6.3k tokens)
            ├── profiles.test.tsx (5.6k tokens)
            ├── tray-components.test.tsx (2.8k tokens)
            ├── ux-feedback.test.tsx (800 tokens)
         ├── fixtures/
            ├── index.ts (1200 tokens)
         ├── integration/
            ├── providers.test.ts (10.7k tokens)
         ├── unit/
            ├── config-persistence.test.ts (1100 tokens)
            ├── mcp-server-config.test.mjs (200 tokens)
            ├── model-selector-format.test.ts (400 tokens)
            ├── network-format.test.ts (600 tokens)
            ├── polling.test.ts (300 tokens)
            ├── provider-references.test.ts (1000 tokens)
            ├── routing.test.ts (600 tokens)
            ├── tray-shared.test.ts (600 tokens)
            ├── usage-activity.test.mjs (500 tokens)
            ├── usage-format.test.ts (700 tokens)
            ├── virtual-models.test.ts (1800 tokens)
      ├── tsconfig.json
├── playwright.config.ts (100 tokens)
├── pnpm-lock.yaml (omitted)
├── scripts/
   ├── generate-claude-code-config-options.mjs (3.7k tokens)
   ├── generate-models-json.mjs (9k tokens)
├── tests/
   ├── architecture/
      ├── package-boundaries.test.mjs (600 tokens)
      ├── ui-build-contract.test.mjs (100 tokens)
   ├── e2e/
      ├── cli-web-runtime.ts (800 tokens)
      ├── cli-web.spec.ts (700 tokens)
      ├── electron-cdp.spec.ts (3.8k tokens)
      ├── provider-edit.spec.ts (3.4k tokens)
   ├── system/
      ├── docker-smoke.mjs (2.4k tokens)
├── tsconfig.json (200 tokens)
├── tsconfig.node.json (100 tokens)
```


## /.dockerignore

```dockerignore path="/.dockerignore" 
.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

```

## /.github/workflows/docker-release.yml

```yml path="/.github/workflows/docker-release.yml" 
name: Docker Release

on:
  workflow_dispatch:
    inputs:
      ref:
        description: Git ref to build, usually a release tag.
        required: true
        default: v3.0.21
      version:
        description: Docker version tag without leading v.
        required: true
        default: 3.0.21

permissions:
  contents: read

concurrency:
  group: docker-release-${{ inputs.version }}
  cancel-in-progress: false

env:
  IMAGE_NAME: musistudio/claude-code-router

jobs:
  docker:
    name: Build and Push
    runs-on: ubuntu-latest
    timeout-minutes: 90
    steps:
      - name: Check out release ref
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}

      - name: Prepare metadata
        id: meta
        shell: bash
        run: |
          version="${{ inputs.version }}"
          ref="${{ inputs.ref }}"
          package_version="$(node -p "require('./package.json').version")"

          if [[ "$version" != "$package_version" ]]; then
            echo "Input version $version does not match package.json version $package_version"
            exit 1
          fi

          if [[ "$ref" == v* && "${ref#v}" != "$version" ]]; then
            echo "Input ref $ref does not match version $version"
            exit 1
          fi

          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "major_minor=${version%.*}" >> "$GITHUB_OUTPUT"

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
            ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.major_minor }}
            ${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Inspect pushed image
        run: docker buildx imagetools inspect "${IMAGE_NAME}:${{ steps.meta.outputs.version }}"

```

## /.github/workflows/docs.yml

```yml path="/.github/workflows/docs.yml" 
name: Docs

on:
  push:
    branches:
      - main
    paths:
      - "docs/**"
      - ".github/workflows/docs.yml"
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Set up workspace Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: package-lock.json

      - name: Install workspace dependencies
        run: npm ci --ignore-scripts

      - name: Build and upload docs
        uses: withastro/action@v6
        env:
          ASTRO_SITE: https://ccrdesk.top
          ASTRO_BASE: /
        with:
          path: docs
          node-version: 24

  deploy:
    name: Deploy
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v5

```

## /.github/workflows/release.yml

```yml path="/.github/workflows/release.yml" 
name: Release

on:
  push:
    tags:
      - "v*"

permissions:
  contents: write

concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  model-catalog:
    name: Update model catalog
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Update models.json
        run: npm run models:update

      - name: Upload model catalog
        uses: actions/upload-artifact@v4
        with:
          name: release-model-catalog
          path: packages/core/models.json
          if-no-files-found: error
          retention-days: 1

  macos:
    name: macOS (${{ matrix.display_name }})
    needs: model-catalog
    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

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Download updated model catalog
        uses: actions/download-artifact@v4
        with:
          name: release-model-catalog
          path: packages/core

      - name: Verify tag version
        shell: bash
        run: |
          tag_version="${GITHUB_REF_NAME#v}"
          package_version="$(node -p "require('./package.json').version")"
          if [[ "$tag_version" != "$package_version" ]]; then
            echo "Tag v$tag_version does not match package.json version $package_version"
            exit 1
          fi

      - 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: |
          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: publish-macos
    runs-on: windows-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 dependencies
        run: npm ci

      - name: Download updated model catalog
        uses: actions/download-artifact@v4
        with:
          name: release-model-catalog
          path: packages/core

      - name: Verify tag version
        shell: bash
        run: |
          tag_version="${GITHUB_REF_NAME#v}"
          package_version="$(node -p "require('./package.json').version")"
          if [[ "$tag_version" != "$package_version" ]]; then
            echo "Tag v$tag_version does not match package.json version $package_version"
            exit 1
          fi

      - name: Build and publish Windows artifacts
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: npm run build:app:win:release -- --publish always

  linux:
    name: Linux
    needs: publish-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 dependencies
        run: npm ci

      - name: Download updated model catalog
        uses: actions/download-artifact@v4
        with:
          name: release-model-catalog
          path: packages/core

      - name: Verify tag version
        shell: bash
        run: |
          tag_version="${GITHUB_REF_NAME#v}"
          package_version="$(node -p "require('./package.json').version")"
          if [[ "$tag_version" != "$package_version" ]]; then
            echo "Tag v$tag_version does not match package.json version $package_version"
            exit 1
          fi

      - name: Build and publish Linux artifacts
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          npm run build:assets
          npx electron-builder --linux AppImage --publish always

```

## /.gitignore

```gitignore path="/.gitignore" 
.DS_Store
node_modules
dist
release
*.log
.env
.env.*
!.env.example
.idea
.claude
.bot-gateway-state
.agent-data
.agent-console-remote-mode
tmp
release-local
logs
.opencat
test-results
playwright-report
blob-report
.tmp
.test-dist
docker/local-ai-gateway/*.tgz

```

## /.npmrc

```npmrc path="/.npmrc" 
install-links=true

```

## /Dockerfile

``` path="/Dockerfile" 
ARG NODE_IMAGE=node:22-bookworm-slim
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 if ls docker/local-ai-gateway/*.tgz >/dev/null 2>&1; then \
    npm install --no-save docker/local-ai-gateway/*.tgz; \
  fi
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_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"]

```

## /README.md

<div align="center">

<table width="100%">
  <tr>
    <td align="center">
      <a href="https://www.kimi.com/code?aff=ccr">
        <img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" width="960" alt="Kimi K2.7 Code sponsor banner" />
      </a>
      <br />
      <sub>
        <a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
        &nbsp;·&nbsp;
        <a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
        &nbsp;·&nbsp;
        <a href="https://platform.kimi.com?aff=ccr">API China</a>
      </sub>
    </td>
  </tr>
  <tr>
    <td align="left">
      <p>
        <strong>Thanks to Kimi for sponsoring this project!</strong> Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. Inside CCR, Kimi ships as a built-in provider preset: import the pay-as-you-go API or Kimi Code subscription in one click and route your coding agent's requests to Kimi. The subscription endpoint passes through natively without protocol conversion, API endpoints are adapted automatically, and account balance and subscription usage are visible in the CCR dashboard.
      </p>
      <p align="center">
        CCR already includes Kimi provider presets. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">Kimi Code subscription</a>.
      </p>
    </td>
  </tr>
</table>

</div>

<div align="center">

# Claude Code Router

### Manage every agent and provider from one place.

Connect Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, WorkBuddy, and compatible API clients to the providers you choose—then route, fail over, extend, and observe every request from one app.

<p>
  <a href="https://github.com/musistudio/claude-code-router/releases"><img alt="Download Desktop" src="https://img.shields.io/badge/Download-Desktop_App-2563EB?style=for-the-badge&logo=github&logoColor=white" /></a>
  <a href="#quick-start"><img alt="Quick Start" src="https://img.shields.io/badge/Get_Started-Quick_Start-16A34A?style=for-the-badge&logo=rocket&logoColor=white" /></a>
  <a href="https://ccrdesk.top/"><img alt="Read the Docs" src="https://img.shields.io/badge/Explore-Documentation-0F172A?style=for-the-badge&logo=readthedocs&logoColor=white" /></a>
</p>

<p>
  <a href="README_zh.md"><img alt="Chinese README" src="https://img.shields.io/badge/%F0%9F%87%A8%F0%9F%87%B3-%E4%B8%AD%E6%96%87%E7%89%88-ff0000?style=flat" /></a>
  <a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
  <a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
  <a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
</p>

<br />

<img src="blog/images/claude-code-router.png" width="820" alt="Claude Code Router Desktop dashboard" />

</div>

## Why use Claude Code Router?

Claude Code Router (CCR) is a local model gateway and control plane for coding agents. It gives Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, WorkBuddy, and compatible API clients **one stable local endpoint**, while you manage the providers, models, accounts, routing rules, and tools behind it from one place.

Use CCR to:

- **Manage all agents and providers together** instead of maintaining a separate model configuration for every client.
- **Switch providers or models without changing your workflow** or repeatedly editing agent configuration files.
- **Keep requests running** with retries, credential pools, key rotation, and ordered fallback models.
- **Add capabilities to existing models** with Fusion vision, web search, MCP tools, and ToolHub.
- **See what actually happened** through request logs, resolved routes, latency, token usage, cost estimates, and account status.

CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Content / Interactions, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom compatible providers.

<details open>
<summary><strong>Supported Agents</strong></summary>

<div align="center">

<table width="100%">
  <tr>
    <td align="center" width="20%">
      <a href="https://github.com/anthropics/claude-code">
        <img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Code logo" />
        <br />
        <strong>Claude Code (CLI & APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/openai/codex">
        <img src="/packages/ui/src/assets/agent-logos/codex.png" width="44" height="44" alt="Codex logo" />
        <br />
        <strong>Codex (CLI & APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/xai-org/grok-build">
        <img src="/packages/ui/src/assets/agent-logos/grok.ico" width="44" height="44" alt="Grok CLI logo" />
        <br />
        <strong>Grok CLI (CLI)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/MoonshotAI/kimi-cli">
        <img src="/docs/public/provider-icons/moonshot.ico" width="44" height="44" alt="Kimi CLI logo" />
        <br />
        <strong>Kimi CLI (CLI)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://kilo.ai/">
        <img src="/packages/ui/src/assets/agent-logos/kilo.svg" width="44" height="44" alt="Kilo Code logo" />
        <br />
        <strong>Kilo Code (CLI)</strong>
      </a>
    </td>
  </tr>
  <tr>
    <td align="center" width="20%">
      <a href="https://github.com/anomalyco/opencode">
        <img src="/packages/ui/src/assets/agent-logos/opencode.ico" width="44" height="44" alt="OpenCode logo" />
        <br />
        <strong>OpenCode (CLI & APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/earendil-works/pi">
        <img src="/packages/ui/src/assets/agent-logos/pi.svg" width="44" height="44" alt="Pi logo" />
        <br />
        <strong>Pi (CLI)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://zcode.z.ai/en">
        <img src="/packages/ui/src/assets/agent-logos/zcode.png" width="44" height="44" alt="ZCode logo" />
        <br />
        <strong>ZCode (APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://www.anthropic.com/news/claude-design-anthropic-labs">
        <img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Design logo" />
        <br />
        <strong>Claude Design (APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://www.workbuddy.ai/">
        <img src="/packages/ui/src/assets/agent-logos/workbuddy.png" width="44" height="44" alt="WorkBuddy logo" />
        <br />
        <strong>WorkBuddy (APP)</strong>
      </a>
    </td>
  </tr>
</table>

</div>

</details>

## Quick Start

### Desktop app (recommended)

1. <details open>
   <summary><strong>Download Claude Code Router for macOS, Windows, or Linux, then launch the app.</strong></summary>

   <div align="center">

   <table width="100%">
     <tr>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0.exe">
           <img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows logo" />
           <br />
           <strong>Windows</strong>
         </a>
       </td>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0.AppImage">
           <img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux logo" />
           <br />
           <strong>Linux</strong>
         </a>
       </td>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0-mac-Apple-Silicon-arm64.dmg">
           <img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
           <br />
           <strong>macOS (Apple Silicon)</strong>
         </a>
       </td>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0-mac-Intel-x64.dmg">
           <img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
           <br />
           <strong>macOS (Intel)</strong>
         </a>
       </td>
     </tr>
   </table>

   </div>

   </details>

2. Open **Providers → Add Provider**. Choose a built-in preset or a custom endpoint, enter the API key, select the protocol and models, then save.
3. Open **Server** and click **Start**. The local model gateway listens on `http://127.0.0.1:3456` by default.
4. Open **Agent Config**, choose Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, or WorkBuddy, select a model, and apply the profile.
5. Start using your agent. Open **Logs** to confirm the resolved provider, model, status, tokens, latency, and errors.

Your agent is now connected to CCR. To add conditions, retries, request rewrites, or fallback models, open **Routing**.

### CLI

The npm CLI requires Node.js 22 or newer. It starts the same gateway and a browser-based management UI without Electron:

```sh
npm install -g @musistudio/claude-code-router
ccr ui
```

Open `http://127.0.0.1:3458`, then follow the same **Providers → Server → Agent Profiles** flow above. The model gateway remains at `http://127.0.0.1:3456`. See the [CLI reference](https://ccrdesk.top/en/guides/cli/) for service modes, authentication, and profile commands.

### Docker

```sh
npm run docker:compose:up
```

Docker exposes the management UI and gateway routes through `http://127.0.0.1:3458` by default. The npm script prepares the local plugin-capable ai-gateway runtime before building the image when `../../next-ai/gateway` exists. Read the [Docker deployment guide](https://ccrdesk.top/en/guides/docker/) before exposing CCR remotely.

## Build desktop apps

Install Node.js 22+, then run `npm ci`.

| Target | Command | Output |
| --- | --- | --- |
| macOS local DMG/ZIP | `npm run build:app:mac` | `release-local/` |
| Windows local NSIS installer | `npm run build:app:win` | `release-local/` |

Windows app packaging must run on Windows x64 because `better-sqlite3` ships a native Electron module. The release workflow builds macOS on macOS runners and Windows on `windows-latest` when a `v*` tag is pushed.

## How it works

```text
Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · WorkBuddy · Compatible API clients
                              │
                              ▼
                 Claude Code Router :3456
          Profiles · Routing · Credentials · Tools · Logs
                              │
                              ▼
             Selected provider, model, and account
```

## Core capabilities

| Area | Highlights |
| --- | --- |
| **Agents** | Profiles for Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, and WorkBuddy; model overrides; scopes; environment settings; CLI and app launch entries; multi-instance workflows |
| **Providers** | Presets and custom endpoints; protocol probing; model discovery; connectivity checks; local login import where supported; single keys and credential pools |
| **Models & routing** | Searchable catalog; model descriptions for task selection; conditions on headers and bodies; prefixes; rewrites; retries; ordered fallbacks |
| **Tools & extensions** | Fusion models; ToolHub; built-in browser automation; Chrome login-state import; wrapper and core gateway plugins; local routes and virtual models |
| **Access & quotas** | Separate CCR client keys with expiration and local request, token, and image limits |
| **Observability** | Request and response details; resolved provider, model, and credential; status; latency; tokens; estimated cost; tool calls; agent traces |
| **AgentClaw** | Agent relay through Weixin iLink, WeCom, Slack, Discord, Telegram, LINE, Feishu, and DingTalk |

## Go deeper when you are ready

The complete documentation lives at **[ccrdesk.top](https://ccrdesk.top/)**.

- [Install and launch CCR](https://ccrdesk.top/en/guides/install/)
- [Configure providers](https://ccrdesk.top/en/guides/provider/)
- [Explore routing and configuration](https://ccrdesk.top/en/configuration/)
- [Use the CLI](https://ccrdesk.top/en/guides/cli/)
- [Deploy with Docker](https://ccrdesk.top/en/guides/docker/)
- [Troubleshoot common issues](https://ccrdesk.top/en/troubleshooting/)

## Support & Sponsoring

<div align="center">

<p>If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.</p>

<table>
  <tr>
    <td align="center" width="220">
      <a href="https://ko-fi.com/F1F31GN2GM">
        <img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi" />
      </a>
      <br />
      <sub>One-time support via Ko-fi</sub>
    </td>
    <td align="center" width="220">
      <a href="https://paypal.me/musistudio1999">
        <img src="https://img.shields.io/badge/PayPal-Sponsor-003087?logo=paypal&logoColor=white" alt="Sponsor with PayPal" />
      </a>
      <br />
      <sub>International sponsorship</sub>
    </td>
  </tr>
</table>

<table>
  <tr>
    <td align="center" width="220">
      <strong>Alipay</strong>
      <br />
      <img src="/blog/images/alipay.jpg" width="160" alt="Alipay QR code" />
    </td>
    <td align="center" width="220">
      <strong>WeChat Pay</strong>
      <br />
      <img src="/blog/images/wechat.jpg" width="160" alt="WeChat Pay QR code" />
    </td>
  </tr>
</table>

</div>

### Our Sponsors

<div align="center">

<p>A huge thank you to all our sponsors for their generous support.</p>

<table width="100%">
  <tr>
    <td align="center" width="330">
      <a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ">
        <img src="/docs/public/provider-icons/zhipu-cn-general.png" width="42" height="42" alt="Zhipu icon" />
        <br />
        <strong>Z智谱</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://aihubmix.com/">
        <img src="https://www.google.com/s2/favicons?domain=aihubmix.com&amp;sz=128" width="42" height="42" alt="AIHubmix icon" />
        <br />
        <strong>AIHubmix</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://ai.burncloud.com">
        <img src="https://www.burncloud.com/favicon.png" width="42" height="42" alt="BurnCloud icon" />
        <br />
        <strong>BurnCloud</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://share.302.ai/ZGVF9w">
        <img src="https://www.google.com/s2/favicons?domain=302.ai&amp;sz=128" width="42" height="42" alt="302.AI icon" />
        <br />
        <strong>302.AI</strong>
      </a>
    </td>
  </tr>
  <tr>
    <td align="center" width="330">
      <a href="https://runapi.co/register?aff=IX1t">
        <img src="/docs/public/provider-icons/runapi.jpg" width="42" height="42" alt="RunAPI icon" />
        <br />
        <strong>RunAPI</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://teamorouter.com/">
        <img src="/docs/public/provider-icons/teamorouter.png" width="42" height="42" alt="TeamoRouter icon" />
        <br />
        <strong>TeamoRouter</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
        <img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai icon" />
        <br />
        <strong>code0.ai</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
        <img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi icon" />
        <br />
        <strong>claudeapi</strong>
      </a>
    </td>
  </tr>
  <tr>
    <td align="center" width="330">
      <a href="https://s.qiniu.com/AVjMVf">
        <img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="Qiniu Cloud AI icon" />
        <br />
        <strong>Qiniu Cloud AI</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=9HHHAB5QLAES">
        <img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai icon" />
        <br />
        <strong>Fenno.ai</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://unity2.ai/register?source=claudecoderouter">
        <img src="/docs/public/provider-icons/unity2.jpg" width="42" height="42" alt="Unity2.Ai icon" />
        <br />
        <strong>Unity2.Ai</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://www.infistar.cc/register?aff=CCRCCR&ref_source=link">
        <img src="/docs/public/provider-icons/infistar-ai.jpg" width="42" height="42" alt="无限星河 icon" />
        <br />
        <strong>无限星河</strong>
      </a>
    </td>
  </tr>
</table>

<h4>Community Sponsors</h4>

<table width="100%">
  <tr>
    <td align="center" width="220">@Simon Leischnig</td>
    <td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
    <td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
    <td align="center" width="220">@*o</td>
    <td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
    <td align="center" width="220">@*说</td>
  </tr>
  <tr>
    <td align="center" width="220">@*更</td>
    <td align="center" width="220">@K*g</td>
    <td align="center" width="220">@R*R</td>
    <td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
    <td align="center" width="220">@*苗</td>
    <td align="center" width="220">@*划</td>
  </tr>
  <tr>
    <td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
    <td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
    <td align="center" width="220">@S*r</td>
    <td align="center" width="220">@*晖</td>
    <td align="center" width="220">@*敏</td>
    <td align="center" width="220">@Z*z</td>
  </tr>
  <tr>
    <td align="center" width="220">@*然</td>
    <td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
    <td align="center" width="220">@*苗</td>
    <td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
    <td align="center" width="220">@*应</td>
    <td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
  </tr>
  <tr>
    <td align="center" width="220">@*飞</td>
    <td align="center" width="220">@董*</td>
    <td align="center" width="220">@*汀</td>
    <td align="center" width="220">@*涯</td>
    <td align="center" width="220">@*:-)</td>
    <td align="center" width="220">@**磊</td>
  </tr>
  <tr>
    <td align="center" width="220">@*琢</td>
    <td align="center" width="220">@*成</td>
    <td align="center" width="220">@Z*o</td>
    <td align="center" width="220">@*琨</td>
    <td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
    <td align="center" width="220">@*_</td>
  </tr>
  <tr>
    <td align="center" width="220">@Z*m</td>
    <td align="center" width="220">@*鑫</td>
    <td align="center" width="220">@c*y</td>
    <td align="center" width="220">@*昕</td>
    <td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
    <td align="center" width="220">@b*g</td>
  </tr>
  <tr>
    <td align="center" width="220">@*亿</td>
    <td align="center" width="220">@*辉</td>
    <td align="center" width="220">@JACK</td>
    <td align="center" width="220">@*光</td>
    <td align="center" width="220">@W*l</td>
    <td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
  </tr>
  <tr>
    <td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
    <td align="center" width="220">@二吉吉</td>
    <td align="center" width="220">@a*g</td>
    <td align="center" width="220">@*林</td>
    <td align="center" width="220">@*咸</td>
    <td align="center" width="220">@*明</td>
  </tr>
  <tr>
    <td align="center" width="220">@S*y</td>
    <td align="center" width="220">@f*o</td>
    <td align="center" width="220">@*智</td>
    <td align="center" width="220">@F*t</td>
    <td align="center" width="220">@r*c</td>
    <td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
  </tr>
  <tr>
    <td align="center" width="220">@*军</td>
    <td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
    <td align="center" width="220">@*王</td>
    <td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
    <td align="center" width="220">@*王</td>
    <td align="center" width="220">@zcutlip</td>
  </tr>
  <tr>
    <td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
    <td align="center" width="220">@*更</td>
    <td align="center" width="220">@*.</td>
    <td align="center" width="220">@F*t</td>
    <td align="center" width="220">@*政</td>
    <td align="center" width="220">@*铭</td>
  </tr>
  <tr>
    <td align="center" width="220">@*叶</td>
    <td align="center" width="220">@七*o</td>
    <td align="center" width="220">@*青</td>
    <td align="center" width="220">@**晨</td>
    <td align="center" width="220">@*远</td>
    <td align="center" width="220">@*霄</td>
  </tr>
  <tr>
    <td align="center" width="220">@**吉</td>
    <td align="center" width="220">@**飞</td>
    <td align="center" width="220">@**驰</td>
    <td align="center" width="220">@x*g</td>
    <td align="center" width="220">@**东</td>
    <td align="center" width="220">@*落</td>
  </tr>
  <tr>
    <td align="center" width="220">@哆*k</td>
    <td align="center" width="220">@*涛</td>
    <td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
    <td align="center" width="220">@*呢</td>
    <td align="center" width="220">@d*u</td>
    <td align="center" width="220">@crizcraig</td>
  </tr>
  <tr>
    <td align="center" width="220">s*s</td>
    <td align="center" width="220">*火</td>
    <td align="center" width="220">*勤</td>
    <td align="center" width="220">**锟</td>
    <td align="center" width="220">*涛</td>
    <td align="center" width="220">**明</td>
  </tr>
  <tr>
    <td align="center" width="220">*知</td>
    <td align="center" width="220">*语</td>
    <td align="center" width="220">*瓜</td>
    <td align="center" width="220">**新</td>
    <td align="center" width="220"></td>
    <td align="center" width="220"></td>
  </tr>
</table>

<sub>If your name is masked, please contact me via my homepage email to update it with your GitHub username.</sub>

</div>

## License

This project is licensed under the [MIT License](LICENSE).


## /README_zh.md

<div align="center">

<table width="100%">
  <tr>
    <td align="center">
      <a href="https://www.kimi.com/code?aff=ccr">
        <img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png" width="960" alt="Kimi K2.7 Code 赞助横幅" />
      </a>
      <br />
      <sub>
        <a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
        &nbsp;·&nbsp;
        <a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
        &nbsp;·&nbsp;
        <a href="https://platform.kimi.ai?aff=ccr">API Global</a>
      </sub>
    </td>
  </tr>
  <tr>
    <td align="left">
      <p>
        <strong>感谢 Kimi 赞助本项目!</strong>Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长周期编码、知识工作和推理任务中展现前沿性能。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可将编程 Agent 的请求路由到 Kimi;订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
      </p>
      <p align="center">
        CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API,或了解 <a href="https://www.kimi.com/code?aff=ccr">Kimi Code 订阅</a>。
      </p>
    </td>
  </tr>
</table>

</div>

<div align="center">

# Claude Code Router

### 在一个地方,管理你所有的 Agent 与 Provider

让 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode、WorkBuddy 和兼容 API 客户端连接你选择的供应商,并在一个应用里完成每次请求的路由、降级、增强与观测。

<p>
  <a href="#桌面端推荐"><img alt="下载桌面端" src="https://img.shields.io/badge/%E7%AB%8B%E5%8D%B3%E4%B8%8B%E8%BD%BD-%E6%A1%8C%E9%9D%A2%E5%AE%A2%E6%88%B7%E7%AB%AF-2563EB?style=for-the-badge&logo=github&logoColor=white" /></a>
  <a href="#快速开始"><img alt="快速开始" src="https://img.shields.io/badge/%E7%AB%8B%E5%8D%B3%E4%BD%BF%E7%94%A8-%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B-16A34A?style=for-the-badge&logo=rocket&logoColor=white" /></a>
  <a href="https://ccrdesk.top/"><img alt="查看文档" src="https://img.shields.io/badge/%E6%B7%B1%E5%85%A5%E4%BA%86%E8%A7%A3-%E5%AE%8C%E6%95%B4%E6%96%87%E6%A1%A3-0F172A?style=for-the-badge&logo=readthedocs&logoColor=white" /></a>
</p>

<p>
  <a href="README.md"><img alt="English README" src="https://img.shields.io/badge/%F0%9F%87%AC%F0%9F%87%A7-English-000aff?style=flat" /></a>
  <a href="https://discord.gg/rdftVMaUcS"><img alt="Discord" src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" /></a>
  <a href="https://x.com/musistudio2026"><img alt="X" src="https://img.shields.io/badge/X-@musistudio2026-000000?logo=x&logoColor=white" /></a>
  <a href="https://github.com/musistudio/claude-code-router/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/musistudio/claude-code-router" /></a>
</p>

<br />

<img src="blog/images/claude-code-router.png" width="820" alt="Claude Code Router 桌面端控制台" />

</div>

## 为什么使用 Claude Code Router?

Claude Code Router(CCR)是面向编程 Agent 的本地模型网关与控制平面。它为 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode、WorkBuddy 和兼容 API 客户端提供**一个稳定的本地入口**,让你在一个地方管理入口背后的供应商、模型、账号、路由规则与工具。

你可以使用 CCR:

- **统一管理所有 Agent 与 Provider**,不再为每个客户端维护一套独立模型配置。
- **切换供应商或模型而不改变工作流**,无需反复修改 Agent 配置文件。
- **通过重试、凭据池、Key 轮换和 Fallback 保持请求可用**。
- **通过 Fusion 视觉、联网搜索、MCP 工具和 ToolHub 扩展现有模型**。
- **通过请求日志、最终路由、耗时、Token、成本估算和账号状态了解真实运行情况**。

CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Content / Interactions、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义兼容供应商。

<details open>
<summary><strong>支持的 Agent</strong></summary>

<div align="center">

<table width="100%">
  <tr>
    <td align="center" width="20%">
      <a href="https://github.com/anthropics/claude-code">
        <img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Code 图标" />
        <br />
        <strong>Claude Code (CLI & APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/openai/codex">
        <img src="/packages/ui/src/assets/agent-logos/codex.png" width="44" height="44" alt="Codex 图标" />
        <br />
        <strong>Codex (CLI & APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/xai-org/grok-build">
        <img src="/packages/ui/src/assets/agent-logos/grok.ico" width="44" height="44" alt="Grok CLI 图标" />
        <br />
        <strong>Grok CLI (CLI)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/MoonshotAI/kimi-cli">
        <img src="/docs/public/provider-icons/moonshot.ico" width="44" height="44" alt="Kimi CLI 图标" />
        <br />
        <strong>Kimi CLI (CLI)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://kilo.ai/">
        <img src="/packages/ui/src/assets/agent-logos/kilo.svg" width="44" height="44" alt="Kilo Code 图标" />
        <br />
        <strong>Kilo Code (CLI)</strong>
      </a>
    </td>
  </tr>
  <tr>
    <td align="center" width="20%">
      <a href="https://github.com/anomalyco/opencode">
        <img src="/packages/ui/src/assets/agent-logos/opencode.ico" width="44" height="44" alt="OpenCode 图标" />
        <br />
        <strong>OpenCode (CLI & APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://github.com/earendil-works/pi">
        <img src="/packages/ui/src/assets/agent-logos/pi.svg" width="44" height="44" alt="Pi 图标" />
        <br />
        <strong>Pi (CLI)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://zcode.z.ai/cn">
        <img src="/packages/ui/src/assets/agent-logos/zcode.png" width="44" height="44" alt="ZCode 图标" />
        <br />
        <strong>ZCode (APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://www.anthropic.com/news/claude-design-anthropic-labs">
        <img src="/packages/ui/src/assets/agent-logos/claude-code.png" width="44" height="44" alt="Claude Design 图标" />
        <br />
        <strong>Claude Design (APP)</strong>
      </a>
    </td>
    <td align="center" width="20%">
      <a href="https://www.workbuddy.ai/">
        <img src="/packages/ui/src/assets/agent-logos/workbuddy.png" width="44" height="44" alt="WorkBuddy 图标" />
        <br />
        <strong>WorkBuddy (APP)</strong>
      </a>
    </td>
  </tr>
</table>

</div>

</details>

## 快速开始

### 桌面端(推荐)

1. <details open>
   <summary><strong>下载 Claude Code Router,选择 macOS、Windows 或 Linux 版本并启动应用。</strong></summary>

   <div align="center">

   <table width="100%">
     <tr>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22.exe">
           <img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows 图标" />
           <br />
           <strong>Windows</strong>
         </a>
       </td>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22.AppImage">
           <img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux 图标" />
           <br />
           <strong>Linux</strong>
         </a>
       </td>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22-mac-Apple-Silicon-arm64.dmg">
           <img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
           <br />
           <strong>macOS (Apple Silicon)</strong>
         </a>
       </td>
       <td align="center" width="330">
         <a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22-mac-Intel-x64.dmg">
           <img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
           <br />
           <strong>macOS (Intel)</strong>
         </a>
       </td>
     </tr>
   </table>

   </div>

   </details>

2. 打开 **供应商 → 添加供应商**。选择内置预设或自定义端点,填写 API Key,选择协议与模型,然后保存。
3. 打开 **服务** 并点击 **启动**。本地模型网关默认监听 `http://127.0.0.1:3456`。
4. 打开 **Agent配置**,选择 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 或 WorkBuddy,指定模型并应用配置档案。
5. 开始使用 Agent。在 **日志** 中确认最终供应商、模型、状态、Token、耗时与错误。

现在 Agent 已经连接到 CCR。如需增加条件规则、自动重试、请求改写或 Fallback 模型,请打开 **路由**。

### CLI

npm CLI 要求 Node.js 22 或更高版本。无需 Electron,也能启动相同的模型网关与浏览器管理界面:

```sh
npm install -g @musistudio/claude-code-router
ccr ui
```

打开 `http://127.0.0.1:3458`,然后按照上面的 **供应商 → 服务 → Agent 配置档案** 流程操作。模型网关仍位于 `http://127.0.0.1:3456`。服务模式、鉴权和 Profile 命令见 [CLI 命令参考](https://ccrdesk.top/guides/cli/)。

### Docker

```sh
npm run docker:compose:up
```

Docker 默认通过 `http://127.0.0.1:3458` 提供管理界面与网关路由。这个 npm 脚本会在存在 `../../next-ai/gateway` 时先准备本地支持插件的 ai-gateway runtime,再构建镜像。远程暴露 CCR 前,请先阅读 [Docker 部署指南](https://ccrdesk.top/guides/docker/)。

## 构建桌面应用

先安装 Node.js 22+,然后执行 `npm ci`。

| 目标 | 命令 | 产物目录 |
| --- | --- | --- |
| macOS 本地 DMG/ZIP | `npm run build:app:mac` | `release-local/` |
| Windows 本地 NSIS 安装包 | `npm run build:app:win` | `release-local/` |

Windows App 打包必须在 Windows x64 上运行,因为 `better-sqlite3` 包含 Electron 原生模块,不能从 macOS 或 Linux 交叉编译。推送 `v*` tag 时,release workflow 会分别在 macOS runner 和 `windows-latest` 上构建 macOS 与 Windows 产物。

## 工作方式

```text
Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · WorkBuddy · 兼容 API 客户端
                              │
                              ▼
                 Claude Code Router :3456
              配置档案 · 路由 · 凭据 · 工具 · 日志
                              │
                              ▼
                  命中的供应商、模型与账号
```

## 核心能力

| 能力领域 | 功能亮点 |
| --- | --- |
| **Agent** | Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 和 WorkBuddy 配置档案;模型覆盖;作用范围;环境变量;CLI / App 启动入口;多开工作流 |
| **供应商** | 内置预设和自定义端点;协议探测;模型发现;连通性检测;按支持情况导入本机登录态;单 Key 与凭据池 |
| **模型与路由** | 可搜索模型目录;用于任务选择的模型描述;Header / Body 条件;模型前缀;请求改写;重试;有序 Fallback |
| **工具与扩展** | Fusion 模型;ToolHub;内置浏览器自动化;Chrome 登录态导入;wrapper / core gateway plugin;本地路由与虚拟模型 |
| **访问与额度** | 独立的 CCR 客户端 Key,可设置有效期以及本地请求、Token 和图片限额 |
| **日志与观测** | 请求 / 响应详情;最终供应商、模型与凭据;状态;耗时;Token;成本估算;工具调用;Agent 执行链路 |
| **AgentClaw** | 通过微信 iLink、企业微信、Slack、Discord、Telegram、LINE、飞书和钉钉接力 Agent |

## 准备好后,继续深入

完整文档位于 **[ccrdesk.top](https://ccrdesk.top/)**。

- [安装并启动 CCR](https://ccrdesk.top/guides/install/)
- [配置供应商](https://ccrdesk.top/guides/provider/)
- [了解路由与完整配置](https://ccrdesk.top/configuration/)
- [使用 CLI](https://ccrdesk.top/guides/cli/)
- [通过 Docker 部署](https://ccrdesk.top/guides/docker/)
- [排查常见问题](https://ccrdesk.top/troubleshooting/)

## 支持与赞助

<div align="center">

<p>如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。</p>

<table>
  <tr>
    <td align="center" width="220">
      <a href="https://ko-fi.com/F1F31GN2GM">
        <img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="通过 Ko-fi 赞助" />
      </a>
      <br />
      <sub>通过 Ko-fi 单次赞助</sub>
    </td>
    <td align="center" width="220">
      <a href="https://paypal.me/musistudio1999">
        <img src="https://img.shields.io/badge/PayPal-Sponsor-003087?logo=paypal&logoColor=white" alt="通过 PayPal 赞助" />
      </a>
      <br />
      <sub>国际赞助通道</sub>
    </td>
  </tr>
</table>

<table>
  <tr>
    <td align="center" width="220">
      <strong>支付宝</strong>
      <br />
      <img src="/blog/images/alipay.jpg" width="160" alt="支付宝收款码" />
    </td>
    <td align="center" width="220">
      <strong>微信支付</strong>
      <br />
      <img src="/blog/images/wechat.jpg" width="160" alt="微信支付收款码" />
    </td>
  </tr>
</table>

</div>

### 我们的赞助商

<div align="center">

<p>非常感谢所有赞助商的慷慨支持。</p>

<table width="100%">
  <tr>
    <td align="center" width="330">
      <a href="https://www.bigmodel.cn/claude-code?ic=FPF9IVAGFJ">
        <img src="/docs/public/provider-icons/zhipu-cn-general.png" width="42" height="42" alt="智谱图标" />
        <br />
        <strong>Z智谱</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://aihubmix.com/">
        <img src="https://www.google.com/s2/favicons?domain=aihubmix.com&amp;sz=128" width="42" height="42" alt="AIHubmix 图标" />
        <br />
        <strong>AIHubmix</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://ai.burncloud.com">
        <img src="https://www.burncloud.com/favicon.png" width="42" height="42" alt="BurnCloud 图标" />
        <br />
        <strong>BurnCloud</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://share.302.ai/ZGVF9w">
        <img src="https://www.google.com/s2/favicons?domain=302.ai&amp;sz=128" width="42" height="42" alt="302.AI 图标" />
        <br />
        <strong>302.AI</strong>
      </a>
    </td>
  </tr>
  <tr>
    <td align="center" width="330">
      <a href="https://runapi.co/register?aff=IX1t">
        <img src="/docs/public/provider-icons/runapi.jpg" width="42" height="42" alt="RunAPI 图标" />
        <br />
        <strong>RunAPI</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://teamorouter.com/">
        <img src="/docs/public/provider-icons/teamorouter.png" width="42" height="42" alt="TeamoRouter 图标" />
        <br />
        <strong>TeamoRouter</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://code0.ai/agent/register/9n9jOsSnYQoemIVL?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
        <img src="/docs/public/provider-icons/code0.png" width="42" height="42" alt="code0.ai 图标" />
        <br />
        <strong>code0.ai</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://console.claudeapi.com/agent/register/LbmB7Y9kPloyzhwF?utm_source=claudecoderouter&amp;utm_medium=partner&amp;utm_campaign=claudecoderouter_2026&amp;utm_content=default">
        <img src="/docs/public/provider-icons/claudeapi.png" width="42" height="42" alt="claudeapi 图标" />
        <br />
        <strong>claudeapi</strong>
      </a>
    </td>
  </tr>
  <tr>
    <td align="center" width="330">
      <a href="https://s.qiniu.com/AVjMVf">
        <img src="/docs/public/provider-icons/qiniu-ai.png" width="42" height="42" alt="七牛云 AI 图标" />
        <br />
        <strong>七牛云 AI</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&amp;aff=9HHHAB5QLAES">
        <img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai 图标" />
        <br />
        <strong>Fenno.ai</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://unity2.ai/register?source=claudecoderouter">
        <img src="/docs/public/provider-icons/unity2.jpg" width="42" height="42" alt="Unity2.Ai 图标" />
        <br />
        <strong>Unity2.Ai</strong>
      </a>
    </td>
    <td align="center" width="330">
      <a href="https://www.infistar.cc/register?aff=CCRCCR&ref_source=link">
        <img src="/docs/public/provider-icons/infistar-ai.jpg" width="42" height="42" alt="无限星河图标" />
        <br />
        <strong>无限星河</strong>
      </a>
    </td>
  </tr>
</table>

<h4>社区赞助者</h4>

<table width="100%">
  <tr>
    <td align="center" width="220">@Simon Leischnig</td>
    <td align="center" width="220"><a href="https://github.com/duanshuaimin">@duanshuaimin</a></td>
    <td align="center" width="220"><a href="https://github.com/vrgitadmin">@vrgitadmin</a></td>
    <td align="center" width="220">@*o</td>
    <td align="center" width="220"><a href="https://github.com/ceilwoo">@ceilwoo</a></td>
    <td align="center" width="220">@*说</td>
  </tr>
  <tr>
    <td align="center" width="220">@*更</td>
    <td align="center" width="220">@K*g</td>
    <td align="center" width="220">@R*R</td>
    <td align="center" width="220"><a href="https://github.com/bobleer">@bobleer</a></td>
    <td align="center" width="220">@*苗</td>
    <td align="center" width="220">@*划</td>
  </tr>
  <tr>
    <td align="center" width="220"><a href="https://github.com/Clarence-pan">@Clarence-pan</a></td>
    <td align="center" width="220"><a href="https://github.com/carter003">@carter003</a></td>
    <td align="center" width="220">@S*r</td>
    <td align="center" width="220">@*晖</td>
    <td align="center" width="220">@*敏</td>
    <td align="center" width="220">@Z*z</td>
  </tr>
  <tr>
    <td align="center" width="220">@*然</td>
    <td align="center" width="220"><a href="https://github.com/cluic">@cluic</a></td>
    <td align="center" width="220">@*苗</td>
    <td align="center" width="220"><a href="https://github.com/PromptExpert">@PromptExpert</a></td>
    <td align="center" width="220">@*应</td>
    <td align="center" width="220"><a href="https://github.com/yusnake">@yusnake</a></td>
  </tr>
  <tr>
    <td align="center" width="220">@*飞</td>
    <td align="center" width="220">@董*</td>
    <td align="center" width="220">@*汀</td>
    <td align="center" width="220">@*涯</td>
    <td align="center" width="220">@*:-)</td>
    <td align="center" width="220">@**磊</td>
  </tr>
  <tr>
    <td align="center" width="220">@*琢</td>
    <td align="center" width="220">@*成</td>
    <td align="center" width="220">@Z*o</td>
    <td align="center" width="220">@*琨</td>
    <td align="center" width="220"><a href="https://github.com/congzhangzh">@congzhangzh</a></td>
    <td align="center" width="220">@*_</td>
  </tr>
  <tr>
    <td align="center" width="220">@Z*m</td>
    <td align="center" width="220">@*鑫</td>
    <td align="center" width="220">@c*y</td>
    <td align="center" width="220">@*昕</td>
    <td align="center" width="220"><a href="https://github.com/witsice">@witsice</a></td>
    <td align="center" width="220">@b*g</td>
  </tr>
  <tr>
    <td align="center" width="220">@*亿</td>
    <td align="center" width="220">@*辉</td>
    <td align="center" width="220">@JACK</td>
    <td align="center" width="220">@*光</td>
    <td align="center" width="220">@W*l</td>
    <td align="center" width="220"><a href="https://github.com/kesku">@kesku</a></td>
  </tr>
  <tr>
    <td align="center" width="220"><a href="https://github.com/biguncle">@biguncle</a></td>
    <td align="center" width="220">@二吉吉</td>
    <td align="center" width="220">@a*g</td>
    <td align="center" width="220">@*林</td>
    <td align="center" width="220">@*咸</td>
    <td align="center" width="220">@*明</td>
  </tr>
  <tr>
    <td align="center" width="220">@S*y</td>
    <td align="center" width="220">@f*o</td>
    <td align="center" width="220">@*智</td>
    <td align="center" width="220">@F*t</td>
    <td align="center" width="220">@r*c</td>
    <td align="center" width="220"><a href="https://github.com/qierkang">@qierkang</a></td>
  </tr>
  <tr>
    <td align="center" width="220">@*军</td>
    <td align="center" width="220"><a href="https://github.com/snrise-z">@snrise-z</a></td>
    <td align="center" width="220">@*王</td>
    <td align="center" width="220"><a href="https://github.com/greatheart1000">@greatheart1000</a></td>
    <td align="center" width="220">@*王</td>
    <td align="center" width="220">@zcutlip</td>
  </tr>
  <tr>
    <td align="center" width="220"><a href="https://github.com/Peng-YM">@Peng-YM</a></td>
    <td align="center" width="220">@*更</td>
    <td align="center" width="220">@*.</td>
    <td align="center" width="220">@F*t</td>
    <td align="center" width="220">@*政</td>
    <td align="center" width="220">@*铭</td>
  </tr>
  <tr>
    <td align="center" width="220">@*叶</td>
    <td align="center" width="220">@七*o</td>
    <td align="center" width="220">@*青</td>
    <td align="center" width="220">@**晨</td>
    <td align="center" width="220">@*远</td>
    <td align="center" width="220">@*霄</td>
  </tr>
  <tr>
    <td align="center" width="220">@**吉</td>
    <td align="center" width="220">@**飞</td>
    <td align="center" width="220">@**驰</td>
    <td align="center" width="220">@x*g</td>
    <td align="center" width="220">@**东</td>
    <td align="center" width="220">@*落</td>
  </tr>
  <tr>
    <td align="center" width="220">@哆*k</td>
    <td align="center" width="220">@*涛</td>
    <td align="center" width="220"><a href="https://github.com/WitMiao">@苗大</a></td>
    <td align="center" width="220">@*呢</td>
    <td align="center" width="220">@d*u</td>
    <td align="center" width="220">@crizcraig</td>
  </tr>
  <tr>
    <td align="center" width="220">s*s</td>
    <td align="center" width="220">*火</td>
    <td align="center" width="220">*勤</td>
    <td align="center" width="220">**锟</td>
    <td align="center" width="220">*涛</td>
    <td align="center" width="220">**明</td>
  </tr>
  <tr>
    <td align="center" width="220">*知</td>
    <td align="center" width="220">*语</td>
    <td align="center" width="220">*瓜</td>
    <td align="center" width="220">**新</td>
    <td align="center" width="220"></td>
    <td align="center" width="220"></td>
  </tr>
</table>

<sub>如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。</sub>

</div>

## 许可证

本项目基于 [MIT License](LICENSE) 发布。


## /blog/en/maybe-we-can-do-more-with-the-route.md

# Maybe We Can Do More with the Router

Since the release of `claude-code-router`, I’ve received a lot of user feedback, and quite a few issues are still open. Most of them are related to support for different providers and the lack of tool usage from the deepseek model.

Originally, I created this project for personal use, mainly to access claude code at a lower cost. So, multi-provider support wasn’t part of the initial design. But during troubleshooting, I discovered that even though most providers claim to be compatible with the OpenAI-style `/chat/completions` interface, there are many subtle differences. For example:

1. When Gemini's tool parameter type is string, the `format` field only supports `date` and `date-time`, and there’s no tool call ID.

2. OpenRouter requires `cache_control` for caching.

3. The official DeepSeek API has a `max_output` of 8192, but Volcano Engine’s limit is even higher.

Aside from these, smaller providers often have quirks in their parameter handling. So I decided to create a new project, [musistudio/llms](https://github.com/musistudio/llms), to deal with these compatibility issues. It uses the OpenAI format as a base and introduces a generic Transformer interface for transforming both requests and responses.

Once a `Transformer` is implemented for each provider, it becomes possible to mix-and-match requests between them. For example, I implemented bidirectional conversion between Anthropic and OpenAI formats in `AnthropicTransformer`, which listens to the `/v1/messages` endpoint. Similarly, `GeminiTransformer` handles Gemini <-> OpenAI format conversions and listens to `/v1beta/models/:modelAndAction`.

When both requests and responses are transformed into a common format, they can interoperate seamlessly:

```
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
```

```
GeminiResponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
```

Using a middleware layer to smooth out differences may introduce some performance overhead, but the main goal here is to enable `claude-code-router` to support multiple providers.

As for the issue of DeepSeek’s lackluster tool usage — I found that it stems from poor instruction adherence in long conversations. Initially, the model actively calls tools, but after several rounds, it starts responding with plain text instead. My first workaround was injecting a system prompt to remind the model to use tools proactively. But in long contexts, the model tends to forget this instruction.

After reading the DeepSeek documentation, I noticed it supports the `tool_choice` parameter, which can be set to `"required"` to force the model to use at least one tool. I tested this by enabling the parameter, and it significantly improved the model’s tool usage. We can remove the setting when it's no longer necessary. With the help of the `Transformer` interface in [musistudio/llms](https://github.com/musistudio/llms), we can modify the request before it’s sent and adjust the response after it’s received.

Inspired by the Plan Mode in `claude code`, I implemented a similar Tool Mode for DeepSeek:

```typescript
export class TooluseTransformer implements Transformer {
  name = "tooluse";

  transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
    if (request.tools?.length) {
      request.messages.push({
        role: "system",
        content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task. 
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
      });
      request.tool_choice = "required";
      request.tools.unshift({
        type: "function",
        function: {
          name: "ExitTool",
          description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
Examples:
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
2. Task: "What’s the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
          parameters: {
            type: "object",
            properties: {
              response: {
                type: "string",
                description:
                  "Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
              },
            },
            required: ["response"],
          },
        },
      });
    }
    return request;
  }

  async transformResponseOut(response: Response): Promise<Response> {
    if (response.headers.get("Content-Type")?.includes("application/json")) {
      const jsonResponse = await response.json();
      if (
        jsonResponse?.choices[0]?.message.tool_calls?.length &&
        jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
          "ExitTool"
      ) {
        const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
        jsonResponse.choices[0].message.content = toolArguments.response || "";
        delete jsonResponse.choices[0].message.tool_calls;
      }

      // Handle non-streaming response if needed
      return new Response(JSON.stringify(jsonResponse), {
        status: response.status,
        statusText: response.statusText,
        headers: response.headers,
      });
    } else if (response.headers.get("Content-Type")?.includes("stream")) {
      // ...
    }
    return response;
  }
}
```

This transformer ensures the model calls at least one tool. If no tools are appropriate or the task is finished, it can exit using `ExitTool`. Since this relies on the `tool_choice` parameter, it only works with models that support it.

In practice, this approach noticeably improves tool usage for DeepSeek. The tradeoff is that sometimes the model may invoke irrelevant or unnecessary tools, which could increase latency and token usage.

This update is just a small experiment — adding an `“agent”` to the router. Maybe there are more interesting things we can explore from here.

## /blog/en/progressive-disclosure-of-agent-tools-from-the-perspective-of-cli-tool-style.md

# Progressive Disclosure of Agent Tools from the Perspective of CLI Tool Style

It has been nearly two months since Anthropic released [Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills). In this release, Anthropic mentioned a term: Progressive Disclosure. What exactly is this? What problem does it solve?

Actually, in my Vibe Coding workflow, I rarely use MCP. The reason is that I find the implementation quality of MCP to be inconsistent. At its core, it’s about context injection (the essence of tools is also context injection), and I’m not sure if prompts written by others might affect my workflow, so I simply avoid using it altogether. The current implementation of MCP essentially wraps all functionalities as tools exposed to the Agent (one functionality wrapped as one tool, given a detailed description, telling the agent when to call it and what the parameter format is). This has led to the current explosion of prompts.

It wasn’t until Anthropic released Skills and I looked into it that I realized its essence is still prompt injection. If MCP provides a specification for injecting tools, then what Skills advocates is somewhat "unconventional." Skills provides a Markdown document to describe the purpose and best practices of the skill, along with some attached scripts (different from MCP).
![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2F6f22d8913dbc6228e7f11a41e0b3c124d817b6d2-1650x929.jpg&w=3840&q=75)

Since these scripts run directly on the user’s local machine, there are significant security risks. If users cannot review the script code, it can easily lead to serious security issues such as data leakage or virus infections. Compared to MCP, which provides a standardized interface, Skills offer a series of script files. Different skills may have different types of script files—for example, some scripts are implemented in Node.js, while others use Python. To use these scripts, users also need to install the corresponding runtime and dependencies. This is why I describe it as "unconventional."


Is this really the best practice?

Regarding Progressive Disclosure, here is how Anthropic describes it:
> Progressive disclosure is the core design principle that makes Agent Skills flexible and scalable. Like a well-organized manual that starts with a table of contents, then specific chapters, and finally a detailed appendix, skills let Claude load information only as needed:
> ![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2Fa3bca2763d7892982a59c28aa4df7993aaae55ae-2292x673.jpg&w=3840&q=75)
> Agents with a filesystem and code execution tools don’t need to read the entirety of a skill into their context window when working on a particular task. This means that the amount of context that can be bundled into a skill is effectively unbounded.

The following diagram shows how the context window changes when a skill is triggered by a user’s message.
![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2F441b9f6cc0d2337913c1f41b05357f16f51f702e-1650x929.jpg&w=3840&q=75)

Do we really need to implement it this way?

In our daily use of CLI tools, most CLI tools come with a `--help` parameter for viewing the tool's usage and instructions. Isn't that essentially the tool's user manual? For example:
```shell
> npm --help
npm <command>

Usage:

npm install        install all the dependencies in your project
npm install <foo>  add the <foo> dependency to your project
npm test           run this project's tests
npm run <foo>      run the script named <foo>
npm <command> -h   quick help on <command>
npm -l             display usage info for all commands
npm help <term>    search for help on <term>
npm help npm       more involved overview

All commands:

    access, adduser, audit, bugs, cache, ci, completion,
    config, dedupe, deprecate, diff, dist-tag, docs, doctor,
    edit, exec, explain, explore, find-dupes, fund, get, help,
    help-search, hook, init, install, install-ci-test,
    install-test, link, ll, login, logout, ls, org, outdated,
    owner, pack, ping, pkg, prefix, profile, prune, publish,
    query, rebuild, repo, restart, root, run-script, sbom,
    search, set, shrinkwrap, star, stars, start, stop, team,
    test, token, uninstall, unpublish, unstar, update, version,
    view, whoami

Specify configs in the ini-formatted file:
    /Users/xxx/.npmrc
or on the command line via: npm <command> --key=value

More configuration info: npm help config
Configuration fields: npm help 7 config
```

This manual doesn't return every possible usage of every command either. It only lists which commands are available and what functions they can perform. For the specific usage of a command, you can still obtain it by using the `--help` parameter:
```shell
> npm install --help
Install a package

Usage:
npm install [<package-spec> ...]

Options:
[-S|--save|--no-save|--save-prod|--save-dev|--save-optional|--save-peer|--save-bundle]
[-E|--save-exact] [-g|--global]
[--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]
[--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
[--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
[--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only]
[--foreground-scripts] [--ignore-scripts] [--no-audit] [--no-bin-links]
[--no-fund] [--dry-run] [--cpu <cpu>] [--os <os>] [--libc <libc>]
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
[-ws|--workspaces] [--include-workspace-root] [--install-links]

aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall

Run "npm help install" for more info
```
Doesn't this resemble the definition of progressive disclosure mentioned above?

Can we implement an MCP in this style to achieve progressive disclosure of tools without needing skills? I used Codex to convert the official PDF Skill into an MCP, exposing only a single tool:
```json
{
    "name": "mcp__pdf__pdf",
    "description": "Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.You need to pass in the --help parameter to obtain the usage of this tool first.",
    "input_schema": {
        "type": "object",
        "properties": {
            "params": {
                "$ref": "#/$defs/PdfCommandInput"
            }
        },
        "required": [
            "params"
        ],
        "$defs": {
            "PdfCommandInput": {
                "additionalProperties": false,
                "properties": {
                    "argv": {
                        "description": "Command tokens excluding the leading 'pdf' executable name",
                        "items": {
                            "type": "string"
                        },
                        "title": "Argv",
                        "type": "array"
                    },
                    "command": {
                        "anyOf": [
                            {
                                "type": "string"
                            },
                            {
                                "type": "null"
                            }
                        ],
                        "default": null,
                        "description": "Optional command string; if set, it will be split like a shell",
                        "title": "Command"
                    }
                },
                "title": "PdfCommandInput",
                "type": "object"
            }
        },
        "title": "pdfArguments"
    }
}
```

Below is the change in the context window when using this MCP:
```json
User: Extract all the text from test.pdf and write it into result.txt
Agent: Tool(name="pdf", args=["--help"])
Tool: pdf - Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale在使用之前你需要先传入--help参数以获取该工具的用法\n\nUsage:\n  pdf [--help] <domain> <command> [options]\n\nDomains:\n  forms      Fill, extract, and validate PDF form fields.\n  documents  Create, merge, and split PDFs.\n  text       Extract text content from PDFs.\n  tables     Extract tables (heuristic, whitespace-based).\n  images     Render PDF pages to PNG images.\n\nRun `pdf <domain> --help` for domain help.\nRun `pdf <domain> <command> --help` for command help.
Agent: Tool(name="pdf", args=["text", "--help"])
Tool: text - Extract text content from PDFs.\n\nUsage:\n  pdf text <command> [options]\n\nCommands:\n  extract  Extract text content from a PDF.\n\nRun `pdf text <command> --help` for details.
Agent: Tool(name="pdf", args=["text", "extract", "--help"])
Tool: usage: pdf text extract --pdf PDF_PATH [--pages PAGES] [--out OUTPUT_PATH]\n                        [--format {text,json}] [--overwrite]\n\nExtract text content from a PDF.\n\noptions:\n  --pdf PDF_PATH        Input PDF\n  --pages PAGES         Page selection like 1,3-5 or 'all' (default: all)\n  --out OUTPUT_PATH     Write extracted text to a file\n  --format {text,json}  Output format (default: text)\n  --overwrite           Overwrite output file if it exists\n
Agent: Tool(name="pdf", args=["text", "extract", "--pdf", "test.pdf", "--out", "result.txt", "--overwrite"])
Tool: {"result": {"stdout": "xx","data": {"pdf_path": "test.pdf","page_count": 6,"pages": [xxx],"format": "text","output_path": "result.txt"}}}
Agent: xxx
```

From the perspective of context changes, progressive disclosure has been fully realized. This MCP code is open-source (entirely written by Codex, serving only to validate the idea without any review): https://github.com/musistudio/pdf-skill-mcp 

If you have any thoughts or ideas, I’d also welcome the opportunity to discuss them with you. email: [m@musiiot.top](mailto://m@musiiot.top )


## /blog/en/project-motivation-and-how-it-works.md

# Project Motivation and Principles

As early as the day after Claude Code was released (2025-02-25), I began and completed a reverse engineering attempt of the project. At that time, using Claude Code required registering for an Anthropic account, applying for a waitlist, and waiting for approval. However, due to well-known reasons, Anthropic blocks users from mainland China, making it impossible for me to use the service through normal means. Based on known information, I discovered the following:

1. Claude Code is installed via npm, so it's very likely developed with Node.js.
2. Node.js offers various debugging methods: simple `console.log` usage, launching with `--inspect` to hook into Chrome DevTools, or even debugging obfuscated code using `d8`.

My goal was to use Claude Code without an Anthropic account. I didn’t need the full source code—just a way to intercept and reroute requests made by Claude Code to Anthropic’s models to my own custom endpoint. So I started the reverse engineering process:

1. First, install Claude Code:
```bash
npm install -g @anthropic-ai/claude-code
```

2. After installation, the project is located at `~/.nvm/versions/node/v20.10.0/lib/node_modules/@anthropic-ai/claude-code`(this may vary depending on your Node version manager and version).

3. Open the package.json to analyze the entry point:
```package.json
{
  "name": "@anthropic-ai/claude-code",
  "version": "1.0.24",
  "main": "sdk.mjs",
  "types": "sdk.d.ts",
  "bin": {
    "claude": "cli.js"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "type": "module",
  "author": "Boris Cherny <boris@anthropic.com>",
  "license": "SEE LICENSE IN README.md",
  "description": "Use Claude, Anthropic's AI assistant, right from your terminal. Claude can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
  "homepage": "https://github.com/anthropics/claude-code",
  "bugs": {
    "url": "https://github.com/anthropics/claude-code/issues"
  },
  "scripts": {
    "prepare": "node -e \"if (!process.env.AUTHORIZED) { console.error('ERROR: Direct publishing is not allowed.\\nPlease use the publish-external.sh script to publish this package.'); process.exit(1); }\"",
    "preinstall": "node scripts/preinstall.js"
  },
  "dependencies": {},
  "optionalDependencies": {
    "@img/sharp-darwin-arm64": "^0.33.5",
    "@img/sharp-darwin-x64": "^0.33.5",
    "@img/sharp-linux-arm": "^0.33.5",
    "@img/sharp-linux-arm64": "^0.33.5",
    "@img/sharp-linux-x64": "^0.33.5",
    "@img/sharp-win32-x64": "^0.33.5"
  }
}
```

The key entry is `"claude": "cli.js"`. Opening cli.js, you'll see the code is minified and obfuscated. But using WebStorm’s `Format File` feature, you can reformat it for better readability:
![webstorm-formate-file](../images/webstorm-formate-file.png)

Now you can begin understanding Claude Code’s internal logic and prompt structure by reading the code. To dig deeper, you can insert console.log statements or launch in debug mode with Chrome DevTools using:

```bash
NODE_OPTIONS="--inspect-brk=9229" claude
```

This command starts Claude Code in debug mode and opens port 9229. Visit chrome://inspect/ in Chrome and click inspect to begin debugging:
![chrome-devtools](../images/chrome-inspect.png)
![chrome-devtools](../images/chrome-devtools.png)

By searching for the keyword api.anthropic.com, you can easily locate where Claude Code makes its API calls. From the surrounding code, it's clear that baseURL can be overridden with the `ANTHROPIC_BASE_URL` environment variable, and `apiKey` and `authToken` can be configured similarly:
![search](../images/search.png)

So far, we’ve discovered some key information:

1. Environment variables can override Claude Code's `baseURL` and `apiKey`.

2. Claude Code adheres to the Anthropic API specification.

Therefore, we need:
1. A service to convert OpenAI API–compatible requests into Anthropic API format.

2. Set the environment variables before launching Claude Code to redirect requests to this service.

Thus, `claude-code-router` was born. This project uses `Express.js` to implement the `/v1/messages` endpoint. It leverages middlewares to transform request/response formats and supports request rewriting (useful for prompt tuning per model).

Back in February, the full DeepSeek model series had poor support for Function Calling, so I initially used `qwen-max`. It worked well—but without KV cache support, it consumed a large number of tokens and couldn’t provide the native `Claude Code` experience.

So I experimented with a Router-based mode using a lightweight model to dispatch tasks. The architecture included four roles: `router`, `tool`, `think`, and `coder`. Each request passed through a free lightweight model that would decide whether the task involved reasoning, coding, or tool usage. Reasoning and coding tasks looped until a tool was invoked to apply changes. However, the lightweight model lacked the capability to route tasks accurately, and architectural issues prevented it from effectively driving Claude Code.

Everything changed at the end of May when the official Claude Code was launched, and `DeepSeek-R1` model (released 2025-05-28) added Function Call support. I redesigned the system. With the help of AI pair programming, I fixed earlier request/response transformation issues—especially the handling of models that return JSON instead of Function Call outputs.

This time, I used the `DeepSeek-V3`  model. It performed better than expected: supporting most tool calls, handling task decomposition and stepwise planning, and—most importantly—costing less than one-tenth the price of Claude 3.5 Sonnet.

The official Claude Code organizes agents differently from the beta version, so I restructured my Router mode to include four roles: the default model, `background`, `think`, and `longContext`.

- The default model handles general tasks and acts as a fallback.

- The `background` model manages lightweight background tasks. According to Anthropic, Claude Haiku 3.5 is often used here, so I routed this to a local `ollama` service.

- The `think` model is responsible for reasoning and planning mode tasks. I use `DeepSeek-R1` here, though it doesn’t support cost control, so `Think` and `UltraThink` behave identically.

- The `longContext` model handles long-context scenarios. The router uses `tiktoken` to calculate token lengths in real time, and if the context exceeds 32K, it switches to this model to compensate for DeepSeek's long-context limitations.

This describes the evolution and reasoning behind the project. By cleverly overriding environment variables, we can forward and modify requests without altering Claude Code’s source—allowing us to benefit from official updates while using our own models and custom prompts.

This project offers a practical approach to running Claude Code under Anthropic’s regional restrictions, balancing `cost`, `performance`, and `customizability`. That said, the official `Max Plan` still offers the best experience if available.

## /blog/images/alipay.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/alipay.jpg

## /blog/images/chrome-devtools.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/chrome-devtools.png

## /blog/images/chrome-inspect.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/chrome-inspect.png

## /blog/images/claude-code-router-img.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/claude-code-router-img.png

## /blog/images/claude-code-router.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/claude-code-router.png

## /blog/images/claude-code.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/claude-code.png

## /blog/images/roadmap.svg

```svg path="/blog/images/roadmap.svg" 
<svg viewBox="0 0 1200 420" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <style>
      .road { stroke: #7aa2ff; stroke-width: 6; fill: none; filter: drop-shadow(0 6px 18px rgba(122,162,255,0.25)); }
      .dash { stroke: rgba(122,162,255,0.25); stroke-width: 6; fill: none; stroke-dasharray: 2 18; }
      .node { filter: drop-shadow(0 3px 10px rgba(126,240,193,0.35)); }
      .node-circle { fill: #7ef0c1; }
      .node-core { fill: #181b22; stroke: white; stroke-width: 1.5; }
      .label-bg { fill: rgba(24,27,34,0.8); stroke: rgba(255,255,255,0.12); rx: 12; }
      .label-text { fill: #e8ecf1; font-weight: 700; font-size: 14px; font-family: Arial, sans-serif; }
      .label-sub { fill: #9aa6b2; font-weight: 500; font-size: 12px; font-family: Arial, sans-serif; }
      .spark { fill: none; stroke: #ffd36e; stroke-width: 1.6; stroke-linecap: round; }
    </style>
  </defs>

  <!-- Background road with dash -->
  <path class="dash" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>

  <!-- Main road -->
  <path class="road" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>

  <!-- New Documentation Node -->
  <g class="node" transform="translate(200,280)">
    <circle class="node-circle" r="10"/>
    <circle class="node-core" r="6"/>
  </g>

  <!-- New Documentation Label -->
  <g transform="translate(80,120)">
    <rect class="label-bg" width="260" height="92"/>
    <text class="label-text" x="16" y="34">New Documentation</text>
    <text class="label-sub" x="16" y="58">Clear structure, examples &amp; best practices</text>
  </g>

  <!-- Plugin Marketplace Node -->
  <g class="node" transform="translate(640,150)">
    <circle class="node-circle" r="10"/>
    <circle class="node-core" r="6"/>
  </g>

  <!-- Plugin Marketplace Label -->
  <g transform="translate(560,20)">
    <rect class="label-bg" width="320" height="100"/>
    <text class="label-text" x="16" y="34">Plugin Marketplace</text>
    <text class="label-sub" x="16" y="58">Community submissions, ratings &amp; version constraints</text>
  </g>

  <!-- One More Thing Node -->
  <g class="node" transform="translate(1080,255)">
    <circle class="node-circle" r="10"/>
    <circle class="node-core" r="6"/>
  </g>

  <!-- One More Thing Label -->
  <g transform="translate(940,300)">
    <rect class="label-bg" width="250" height="86"/>
    <text class="label-text" x="16" y="34">One More Thing</text>
    <text class="label-sub" x="16" y="58">🚀 Confidential project · Revealing soon</text>
  </g>

  <!-- Spark decorations -->
  <g transform="translate(1125,290)">
    <path class="spark" d="M0 0 L8 0 M4 -4 L4 4"/>
    <path class="spark" d="M14 -2 L22 -2 M18 -6 L18 2"/>
    <path class="spark" d="M-10 6 L-2 6 M-6 2 L-6 10"/>
  </g>
</svg>

```

## /blog/images/search.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/search.png

## /blog/images/sponsors/glm-en.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/sponsors/glm-en.jpg

## /blog/images/sponsors/glm-zh.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/sponsors/glm-zh.jpg

## /blog/images/statusline-config.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/statusline-config.png

## /blog/images/statusline.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/statusline.png

## /blog/images/ui.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/ui.png

## /blog/images/webstorm-formate-file.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/webstorm-formate-file.png

## /blog/images/wechat.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/wechat.jpg

## /blog/images/wechat_group.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/blog/images/wechat_group.jpg

## /blog/zh/从CLI工具风格看工具渐进式披露.md

# 从CLI工具风格看Agent工具渐进式披露

距离Anthropic发布[Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)也过去将近两个月的时间了,其中Anthropic提到了一个术语渐进式披露(Progressive Disclosure),这到底是什么东西?解决了什么问题?

其实在我的Vibe Coding流程中,我很少使用MCP。因为我觉得MCP实现质量层次不齐,本质是上下文注入(工具的本质也是上下文注入),我不确定别人写的提示词会不会影响到我的工作流,干脆直接不用。现在的MCP实现基本上就是把所有的功能全都包装成工具暴露给Agent(一个功能包装成一个工具,给定详细的描述,告诉agent在什么时候进行调用,参数格式是什么),这就导致了现在的提示词爆炸。    

直到Anthropic发布了Skills,研究了一下发现本质仍然是提示词注入。如果说MCP是提供了一套注入工具的规范,那么Skills所提倡的则是“离经叛道”。Skills给了一个Markdown文档用于描述该skill的用途和最佳用法,附带提供了一些脚本(与MCP不同)。
![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2F6f22d8913dbc6228e7f11a41e0b3c124d817b6d2-1650x929.jpg&w=3840&q=75)
由于这些脚本直接在用户本地运行,存在极大的安全风险。如果用户不能对脚本代码进行review,很容易造成数据泄露、感染病毒等严重安全性问题。相比于MCP提供一个标准化的接口,Skill提供一系列的脚本文件,不同的skill可能拥有不同类型的脚本文件,比如有些脚本使用node.js实现,有些脚本使用Python实现,要使用这些脚本还需要用户安装对应的运行时和脚本所需要的依赖。这也是我说“离经叛道”的原因所在。

这真的是最佳实践吗?

关于渐进式披露,Anthropic是这样描述的:
> 渐进式披露是使代理技能灵活且可扩展的核心设计原则。就像一本组织良好的手册,从目录开始,然后是具体章节,最后是详细的附录一样,技能允许 Claude 仅在需要时加载信息:
> ![image](https://www.ant# 从CLI工具风格看Agent工具渐进式披露

距离Anthropic发布[Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)也过去将近两个月的时间了,其中Anthropic提到了一个术语渐进式披露(Progressive Disclosure),这到底是什么东西?解决了什么问题?

其实在我的Vibe Coding流程中,我很少使用MCP。因为我觉得MCP实现质量层次不齐,本质是上下文注入(工具的本质也是上下文注入),我不确定别人写的提示词会不会影响到我的工作流,干脆直接不用。现在的MCP实现基本上就是把所有的功能全都包装成工具暴露给Agent(一个功能包装成一个工具,给定详细的描述,告诉agent在什么时候进行调用,参数格式是什么),这就导致了现在的提示词爆炸。

直到Anthropic发布了Skills,研究了一下发现本质仍然是提示词注入。如果说MCP是提供了一套注入工具的规范,那么Skills所提倡的则是“离经叛道”。Skills给了一个Markdown文档用于描述该skill的用途和最佳用法,附带提供了一些脚本(与MCP不同)。
![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2F6f22d8913dbc6228e7f11a41e0b3c124d817b6d2-1650x929.jpg&w=3840&q=75)
由于这些脚本直接在用户本地运行,存在极大的安全风险。如果用户不能对脚本代码进行review,很容易造成数据泄露、感染病毒等严重安全性问题。相比于MCP提供一个标准化的接口,Skill提供一系列的脚本文件,不同的skill可能拥有不同类型的脚本文件,比如有些脚本使用node.js实现,有些脚本使用Python实现,要使用这些脚本还需要用户安装对应的运行时和脚本所需要的依赖。这也是我说“离经叛道”的原因所在。

这真的是最佳实践吗?

关于渐进式披露,Anthropic是这样描述的:
> 渐进式披露是使代理技能灵活且可扩展的核心设计原则。就像一本组织良好的手册,从目录开始,然后是具体章节,最后是详细的附录一样,技能允许 Claude 仅在需要时加载信息:
> ![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2Fa3bca2763d7892982a59c28aa4df7993aaae55ae-2292x673.jpg&w=3840&q=75)
> 拥有文件系统和代码执行工具的智能体在执行特定任务时,无需将技能的全部内容读取到上下文窗口中。这意味着技能中可以包含的上下文信息量实际上是无限的。

下图是使用Skill的上下文窗口变化
![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2F441b9f6cc0d2337913c1f41b05357f16f51f702e-1650x929.jpg&w=3840&q=75)

我们真的需要这样去实现吗?

在我们平时使用CLI工具时,一般的CLI工具都会带有一个`--help`参数,用于查看该工具的用法和说明,这不就是该工具的使用手册吗?比如:
```shell
> npm --help
npm <command>

Usage:

npm install        install all the dependencies in your project
npm install <foo>  add the <foo> dependency to your project
npm test           run this project's tests
npm run <foo>      run the script named <foo>
npm <command> -h   quick help on <command>
npm -l             display usage info for all commands
npm help <term>    search for help on <term>
npm help npm       more involved overview

All commands:

    access, adduser, audit, bugs, cache, ci, completion,
    config, dedupe, deprecate, diff, dist-tag, docs, doctor,
    edit, exec, explain, explore, find-dupes, fund, get, help,
    help-search, hook, init, install, install-ci-test,
    install-test, link, ll, login, logout, ls, org, outdated,
    owner, pack, ping, pkg, prefix, profile, prune, publish,
    query, rebuild, repo, restart, root, run-script, sbom,
    search, set, shrinkwrap, star, stars, start, stop, team,
    test, token, uninstall, unpublish, unstar, update, version,
    view, whoami

Specify configs in the ini-formatted file:
    /Users/xxx/.npmrc
or on the command line via: npm <command> --key=value

More configuration info: npm help config
Configuration fields: npm help 7 config
```

这份手册也不会返回所有的命令所有的用法,它只会返回它有哪些命令可以实现什么功能,对于命令的具体用法你仍然可以通过`--help`参数获得:
```shell
> npm install --help
Install a package

Usage:
npm install [<package-spec> ...]

Options:
[-S|--save|--no-save|--save-prod|--save-dev|--save-optional|--save-peer|--save-bundle]
[-E|--save-exact] [-g|--global]
[--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]
[--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
[--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
[--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only]
[--foreground-scripts] [--ignore-scripts] [--no-audit] [--no-bin-links]
[--no-fund] [--dry-run] [--cpu <cpu>] [--os <os>] [--libc <libc>]
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
[-ws|--workspaces] [--include-workspace-root] [--install-links]

aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall

Run "npm help install" for more info
```
这是不是很像上面的渐进式披露的定义?

我们是不是可以按照这种风格去实现一个MCP来实现无需skill的工具渐进式披露?我使用Codex将官方的PDF Skill转换成了一个MCP,只暴露一个工具:
```json
{
    "name": "mcp__pdf__pdf",
    "description": "Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.You need to pass in the --help parameter to obtain the usage of this tool first.",
    "input_schema": {
        "type": "object",
        "properties": {
            "params": {
                "$ref": "#/$defs/PdfCommandInput"
            }
        },
        "required": [
            "params"
        ],
        "$defs": {
            "PdfCommandInput": {
                "additionalProperties": false,
                "properties": {
                    "argv": {
                        "description": "Command tokens excluding the leading 'pdf' executable name",
                        "items": {
                            "type": "string"
                        },
                        "title": "Argv",
                        "type": "array"
                    },
                    "command": {
                        "anyOf": [
                            {
                                "type": "string"
                            },
                            {
                                "type": "null"
                            }
                        ],
                        "default": null,
                        "description": "Optional command string; if set, it will be split like a shell",
                        "title": "Command"
                    }
                },
                "title": "PdfCommandInput",
                "type": "object"
            }
        },
        "title": "pdfArguments"
    }
}
```
下面是使用该MCP的上下文窗口变化
```json
User: Extract all the text from test.pdf and write it into result.txt
Agent: Tool(name="pdf", args=["--help"])
Tool: pdf - Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale在使用之前你需要先传入--help参数以获取该工具的用法\n\nUsage:\n  pdf [--help] <domain> <command> [options]\n\nDomains:\n  forms      Fill, extract, and validate PDF form fields.\n  documents  Create, merge, and split PDFs.\n  text       Extract text content from PDFs.\n  tables     Extract tables (heuristic, whitespace-based).\n  images     Render PDF pages to PNG images.\n\nRun `pdf <domain> --help` for domain help.\nRun `pdf <domain> <command> --help` for command help.
Agent: Tool(name="pdf", args=["text", "--help"])
Tool: text - Extract text content from PDFs.\n\nUsage:\n  pdf text <command> [options]\n\nCommands:\n  extract  Extract text content from a PDF.\n\nRun `pdf text <command> --help` for details.
Agent: Tool(name="pdf", args=["text", "extract", "--help"])
Tool: usage: pdf text extract --pdf PDF_PATH [--pages PAGES] [--out OUTPUT_PATH]\n                        [--format {text,json}] [--overwrite]\n\nExtract text content from a PDF.\n\noptions:\n  --pdf PDF_PATH        Input PDF\n  --pages PAGES         Page selection like 1,3-5 or 'all' (default: all)\n  --out OUTPUT_PATH     Write extracted text to a file\n  --format {text,json}  Output format (default: text)\n  --overwrite           Overwrite output file if it exists\n
Agent: Tool(name="pdf", args=["text", "extract", "--pdf", "test.pdf", "--out", "result.txt", "--overwrite"])
Tool: {"result": {"stdout": "xx","data": {"pdf_path": "test.pdf","page_count": 6,"pages": [xxx],"format": "text","output_path": "result.txt"}}}
Agent: xxx
```
从上下文变化情况来看,完全实现了渐进式披露,该MCP代码开源(代码完全由codex编写,只验证想法,未做任何审查): https://github.com/musistudio/pdf-skill-mcp

如果你有什么想法也欢迎与我进行交流hropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2Fa3bca2763d7892982a59c28aa4df7993aaae55ae-2292x673.jpg&w=3840&q=75)
> 拥有文件系统和代码执行工具的智能体在执行特定任务时,无需将技能的全部内容读取到上下文窗口中。这意味着技能中可以包含的上下文信息量实际上是无限的。    

下图是使用Skill的上下文窗口变化
![image](https://www.anthropic.com/_next/image?url=https%3A%2F%2Fwww-cdn.anthropic.com%2Fimages%2F4zrzovbb%2Fwebsite%2F441b9f6cc0d2337913c1f41b05357f16f51f702e-1650x929.jpg&w=3840&q=75)

我们真的需要这样去实现吗?

在我们平时使用CLI工具时,一般的CLI工具都会带有一个`--help`参数,用于查看该工具的用法和说明,这不就是该工具的使用手册吗?比如:
```shell
> npm --help
npm <command>

Usage:

npm install        install all the dependencies in your project
npm install <foo>  add the <foo> dependency to your project
npm test           run this project's tests
npm run <foo>      run the script named <foo>
npm <command> -h   quick help on <command>
npm -l             display usage info for all commands
npm help <term>    search for help on <term>
npm help npm       more involved overview

All commands:

    access, adduser, audit, bugs, cache, ci, completion,
    config, dedupe, deprecate, diff, dist-tag, docs, doctor,
    edit, exec, explain, explore, find-dupes, fund, get, help,
    help-search, hook, init, install, install-ci-test,
    install-test, link, ll, login, logout, ls, org, outdated,
    owner, pack, ping, pkg, prefix, profile, prune, publish,
    query, rebuild, repo, restart, root, run-script, sbom,
    search, set, shrinkwrap, star, stars, start, stop, team,
    test, token, uninstall, unpublish, unstar, update, version,
    view, whoami

Specify configs in the ini-formatted file:
    /Users/xxx/.npmrc
or on the command line via: npm <command> --key=value

More configuration info: npm help config
Configuration fields: npm help 7 config
```

这份手册也不会返回所有的命令所有的用法,它只会返回它有哪些命令可以实现什么功能,对于命令的具体用法你仍然可以通过`--help`参数获得:
```shell
> npm install --help
Install a package

Usage:
npm install [<package-spec> ...]

Options:
[-S|--save|--no-save|--save-prod|--save-dev|--save-optional|--save-peer|--save-bundle]
[-E|--save-exact] [-g|--global]
[--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]
[--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
[--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
[--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only]
[--foreground-scripts] [--ignore-scripts] [--no-audit] [--no-bin-links]
[--no-fund] [--dry-run] [--cpu <cpu>] [--os <os>] [--libc <libc>]
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
[-ws|--workspaces] [--include-workspace-root] [--install-links]

aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall

Run "npm help install" for more info
```
这是不是很像上面的渐进式披露的定义?

我们是不是可以按照这种风格去实现一个MCP来实现无需skill的工具渐进式披露?我使用Codex将官方的PDF Skill转换成了一个MCP,只暴露一个工具:
```json
{
    "name": "mcp__pdf__pdf",
    "description": "Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.You need to pass in the --help parameter to obtain the usage of this tool first.",
    "input_schema": {
        "type": "object",
        "properties": {
            "params": {
                "$ref": "#/$defs/PdfCommandInput"
            }
        },
        "required": [
            "params"
        ],
        "$defs": {
            "PdfCommandInput": {
                "additionalProperties": false,
                "properties": {
                    "argv": {
                        "description": "Command tokens excluding the leading 'pdf' executable name",
                        "items": {
                            "type": "string"
                        },
                        "title": "Argv",
                        "type": "array"
                    },
                    "command": {
                        "anyOf": [
                            {
                                "type": "string"
                            },
                            {
                                "type": "null"
                            }
                        ],
                        "default": null,
                        "description": "Optional command string; if set, it will be split like a shell",
                        "title": "Command"
                    }
                },
                "title": "PdfCommandInput",
                "type": "object"
            }
        },
        "title": "pdfArguments"
    }
}
```
下面是使用该MCP的上下文窗口变化
```json
User: Extract all the text from test.pdf and write it into result.txt
Agent: Tool(name="pdf", args=["--help"])
Tool: pdf - Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale在使用之前你需要先传入--help参数以获取该工具的用法\n\nUsage:\n  pdf [--help] <domain> <command> [options]\n\nDomains:\n  forms      Fill, extract, and validate PDF form fields.\n  documents  Create, merge, and split PDFs.\n  text       Extract text content from PDFs.\n  tables     Extract tables (heuristic, whitespace-based).\n  images     Render PDF pages to PNG images.\n\nRun `pdf <domain> --help` for domain help.\nRun `pdf <domain> <command> --help` for command help.
Agent: Tool(name="pdf", args=["text", "--help"])
Tool: text - Extract text content from PDFs.\n\nUsage:\n  pdf text <command> [options]\n\nCommands:\n  extract  Extract text content from a PDF.\n\nRun `pdf text <command> --help` for details.
Agent: Tool(name="pdf", args=["text", "extract", "--help"])
Tool: usage: pdf text extract --pdf PDF_PATH [--pages PAGES] [--out OUTPUT_PATH]\n                        [--format {text,json}] [--overwrite]\n\nExtract text content from a PDF.\n\noptions:\n  --pdf PDF_PATH        Input PDF\n  --pages PAGES         Page selection like 1,3-5 or 'all' (default: all)\n  --out OUTPUT_PATH     Write extracted text to a file\n  --format {text,json}  Output format (default: text)\n  --overwrite           Overwrite output file if it exists\n
Agent: Tool(name="pdf", args=["text", "extract", "--pdf", "test.pdf", "--out", "result.txt", "--overwrite"])
Tool: {"result": {"stdout": "xx","data": {"pdf_path": "test.pdf","page_count": 6,"pages": [xxx],"format": "text","output_path": "result.txt"}}}
Agent: xxx
```
从上下文变化情况来看,完全实现了渐进式披露,该MCP代码开源(代码完全由codex编写,只验证想法,未做任何审查): https://github.com/musistudio/pdf-skill-mcp 

如果你有什么想法也欢迎与我进行交流 email: [m@musiiot.top](mailto://m@musiiot.top )


## /blog/zh/或许我们能在Router中做更多事情.md

# 或许我们能在 Router 中做更多事情

自从`claude-code-router`发布以来,我收到了很多用户的反馈,至今还有不少的 issues 未处理。其中大多都是关于不同的供应商的支持和`deepseek`模型调用工具不积极的问题。
之前开发这个项目主要是为了我自己能以较低成本使用上`claude code`,所以一开始的设计并没有考虑到多供应商的情况。在实际的排查问题中,我发现尽管市面上所有的供应商几乎都宣称兼容`OpenAI`格式调用,即通过`/chat/compeletions`接口调用,但是其中的细节差异非常多。例如:

1. Gemini 的工具参数类型是 string 时,`format`参数只支持`date`和`date-time`,并且没有工具调用 ID。

2. OpenRouter 需要使用`cache_control`进行缓存。

3. DeepSeek 官方 API 的 `max_output` 为 8192,而火山引擎的会更大。

除了这些问题之外,还有一些其他的小的供应商,他们或多或少参数都有点问题。于是,我打算开发一个新的项目[musistudio/llms](https://github.com/musistudio/llms)来处理这种不同服务商的兼容问题。该项目使用 OpenAI 格式为基础的通用格式,提供了一个`Transformer`接口,该接口用于处理转换请求和响应。当我们给不同的服务商都实现了`Transformer`后,我们可以实现不同服务商的混合调用。比如我在`AnthropicTransformer`中实现了`Anthropic`<->`OpenAI`格式的互相转换,并监听了`/v1/messages`端点,在`GeminiTransformer`中实现了`Gemini`<->`OpenAI`格式的互相转换,并监听了`/v1beta/models/:modelAndAction`端点,当他们的请求和响应都被转换成一个通用格式的时候,就可以实现他们的互相调用。

```
AnthropicRequest -> AnthropicTransformer -> OpenAIRequest -> GeminiTransformer -> GeminiRequest -> GeminiServer
```

```
GeminiReseponse -> GeminiTransformer -> OpenAIResponse -> AnthropicTransformer -> AnthropicResponse
```

虽然使用中间层抹平差异可能会带来一些性能问题,但是该项目最初的目的是为了让`claude-code-router`支持不同的供应商。

至于`deepseek`模型调用工具不积极的问题,我发现这是由于`deepseek`在长上下文中的指令遵循不佳导致的。现象就是刚开始模型会主动调用工具,但是在经过几轮对话后模型只会返回文本。一开始的解决方案是通过注入一个系统提示词告知模型需要积极去使用工具以解决用户的问题,但是后面测试发现在长上下文中模型会遗忘该指令。
查看`deepseek`文档后发现模型支持`tool_choice`参数,可以强制让模型最少调用 1 个工具,我尝试将该值设置为`required`,发现模型调用工具的积极性大大增加,现在我们只需要在合适的时候取消这个参数即可。借助[musistudio/llms](https://github.com/musistudio/llms)的`Transformer`可以让我们在发送请求前和收到响应后做点什么,所以我参考`claude code`的`Plan Mode`,实现了一个使用与`deepseek`的`Tool Mode`

```typescript
export class TooluseTransformer implements Transformer {
  name = "tooluse";

  transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
    if (request.tools?.length) {
      request.messages.push({
        role: "system",
        content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task. 
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
      });
      request.tool_choice = "required";
      request.tools.unshift({
        type: "function",
        function: {
          name: "ExitTool",
          description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
Examples:
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
2. Task: "What’s the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
          parameters: {
            type: "object",
            properties: {
              response: {
                type: "string",
                description:
                  "Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
              },
            },
            required: ["response"],
          },
        },
      });
    }
    return request;
  }

  async transformResponseOut(response: Response): Promise<Response> {
    if (response.headers.get("Content-Type")?.includes("application/json")) {
      const jsonResponse = await response.json();
      if (
        jsonResponse?.choices[0]?.message.tool_calls?.length &&
        jsonResponse?.choices[0]?.message.tool_calls[0]?.function?.name ===
          "ExitTool"
      ) {
        const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
        jsonResponse.choices[0].message.content = toolArguments.response || "";
        delete jsonResponse.choices[0].message.tool_calls;
      }

      // Handle non-streaming response if needed
      return new Response(JSON.stringify(jsonResponse), {
        status: response.status,
        statusText: response.statusText,
        headers: response.headers,
      });
    } else if (response.headers.get("Content-Type")?.includes("stream")) {
      // ...
    }
    return response;
  }
}
```

该工具将始终让模型至少调用一个工具,如果没有合适的工具或者任务已完成可以调用`ExitTool`来退出工具模式,因为是依靠`tool_choice`参数实现的,所以仅适用于支持该参数的模型。经过测试,该工具能显著增加`deepseek`的工具调用次数,弊端是可能会有跟任务无关或者没有必要的工具调用导致增加任务执行事件和消耗的 `token` 数。

这次更新仅仅是在 Router 中实现一个`agent`的一次小探索,或许还能做更多其他有趣的事也说不定...


## /blog/zh/项目初衷及原理.md

# 项目初衷及原理

早在 Claude Code 发布的第二天(2025-02-25),我就尝试并完成了对该项目的逆向。当时要使用 Claude Code 你需要注册一个 Anthropic 账号,然后申请 waitlist,等待通过后才能使用。但是因为众所周知的原因,Anthropic 屏蔽了中国区的用户,所以通过正常手段我无法使用,通过已知的信息,我发现:

1. Claude Code 使用 npm 进行安装,所以很大可能其使用 Node.js 进行开发。
2. Node.js 调试手段众多,可以简单使用`console.log`获取想要的信息,也可以使用`--inspect`将其接入`Chrome Devtools`,甚至你可以使用`d8`去调试某些加密混淆的代码。

由于我的目标是让我在没有 Anthropic 账号的情况下使用`Claude Code`,我并不需要获得完整的源代码,只需要将`Claude Code`请求 Anthropic 模型时将其转发到我自定义的接口即可。接下来我就开启了我的逆向过程:

1. 首先安装`Claude Code`

```bash
npm install -g @anthropic-ai/claude-code
```

2. 安装后该项目被放在了`~/.nvm/versions/node/v20.10.0/lib/node_modules/@anthropic-ai/claude-code`中,因为我使用了`nvm`作为我的 node 版本控制器,当前使用`node-v20.10.0`,所以该路径会因人而异。
3. 找到项目路径之后可通过 package.json 分析包入口,内容如下:

```package.json
{
  "name": "@anthropic-ai/claude-code",
  "version": "1.0.24",
  "main": "sdk.mjs",
  "types": "sdk.d.ts",
  "bin": {
    "claude": "cli.js"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "type": "module",
  "author": "Boris Cherny <boris@anthropic.com>",
  "license": "SEE LICENSE IN README.md",
  "description": "Use Claude, Anthropic's AI assistant, right from your terminal. Claude can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
  "homepage": "https://github.com/anthropics/claude-code",
  "bugs": {
    "url": "https://github.com/anthropics/claude-code/issues"
  },
  "scripts": {
    "prepare": "node -e \"if (!process.env.AUTHORIZED) { console.error('ERROR: Direct publishing is not allowed.\\nPlease use the publish-external.sh script to publish this package.'); process.exit(1); }\"",
    "preinstall": "node scripts/preinstall.js"
  },
  "dependencies": {},
  "optionalDependencies": {
    "@img/sharp-darwin-arm64": "^0.33.5",
    "@img/sharp-darwin-x64": "^0.33.5",
    "@img/sharp-linux-arm": "^0.33.5",
    "@img/sharp-linux-arm64": "^0.33.5",
    "@img/sharp-linux-x64": "^0.33.5",
    "@img/sharp-win32-x64": "^0.33.5"
  }
}
```

其中`"claude": "cli.js"`就是我们要找的入口,打开 cli.js,发现代码被压缩混淆过了。没关系,借助`webstorm`的`Formate File`功能可以重新格式化,让代码变得稍微好看一点。就像这样:
![webstorm-formate-file](../images/webstorm-formate-file.png)

现在,你可以通过阅读部分代码来了解`Claude Code`的内容工具原理与提示词。你也可以在关键地方使用`console.log`来获得更多信息,当然,也可以使用`Chrome Devtools`来进行断点调试,使用以下命令启动`Claude Code`:

```bash
NODE_OPTIONS="--inspect-brk=9229" claude
```

该命令会以调试模式启动`Claude Code`,并将调试的端口设置为`9229`。这时候通过 Chrome 访问`chrome://inspect/`即可看到当前的`Claude Code`进程,点击`inspect`即可进行调试。
![chrome-devtools](../images/chrome-inspect.png)
![chrome-devtools](../images/chrome-devtools.png)

通过搜索关键字符`api.anthropic.com`很容易能找到`Claude Code`用来发请求的地方,根据上下文的查看,很容易发现这里的`baseURL`可以通过环境变量`ANTHROPIC_BASE_URL`进行覆盖,`apiKey`和`authToken`也同理。
![search](../images/search.png)

到目前为止,我们获得关键信息:

1. 可以使用环境变量覆盖`Claude Code`的`BaseURL`和`apiKey`的配置

2. `Claude Code`使用[Anthropic API](https://docs.anthropic.com/en/api/overview)的规范

所以我们需要:

1. 实现一个服务用来将`OpenAI API`的规范转换成`Anthropic API`格式。

2. 启动`Claude Code`之前写入环境变量将`baseURL`指向到该服务。

于是,`claude-code-router`就诞生了,该项目使用`Express.js`作为 HTTP 服务,实现`/v1/messages`端点,使用`middlewares`处理请求/响应的格式转换以及请求重写功能(可以用来重写 Claude Code 的提示词以针对单个模型进行调优)。
在 2 月份由于`DeepSeek`全系列模型对`Function Call`的支持不佳导致无法直接使用`DeepSeek`模型,所以在当时我选择了`qwen-max`模型,一切表现的都很好,但是`qwen-max`不支持`KV Cache`,意味着我要消耗大量的 token,但是却无法获取`Claude Code`原生的体验。
所以我又尝试了`Router`模式,即使用一个小模型对任务进行分发,一共分为四个模型:`router`、`tool`、`think`和`coder`,所有的请求先经过一个免费的小模型,由小模型去判断应该是进行思考还是编码还是调用工具,再进行任务的分发,如果是思考和编码任务将会进行循环调用,直到最终使用工具写入或修改文件。但是实践下来发现免费的小模型不足以很好的完成任务的分发,再加上整个 Agnet 的设计存在缺陷,导致并不能很好的驱动`Claude Code`。
直到 5 月底,`Claude Code`被正式推出,这时`DeepSeek`全系列模型(R1 于 05-28)均支持`Function Call`,我开始重新设计该项目。在与 AI 的结对编程中我修复了之前的请求和响应转换问题,在某些场景下模型输出 JSON 响应而不是`Function Call`。这次直接使用`DeepSeek-v3`模型,它工作的比我想象中要好:能完成绝大多数工具调用,还支持用步骤规划解决任务,最关键的是`DeepSeek`的价格不到`claude Sonnet 3.5`的十分之一。正式发布的`Claude Code`对 Agent 的组织也不同于测试版,于是在分析了`Claude Code`的请求调用之后,我重新组织了`Router`模式:现在它还是四个模型:默认模型、`background`、`think`和`longContext`。

- 默认模型作为最终的兜底和日常处理

- `background`是用来处理一些后台任务,据 Anthropic 官方说主要用`Claude Haiku 3.5`模型去处理一些小任务,如俳句生成和对话摘要,于是我将其路由到了本地的`ollama`服务。

- `think`模型用于让`Claude Code`进行思考或者在`Plan Mode`下使用,这里我使用的是`DeepSeek-R1`,由于其不支持推理成本控制,所以`Think`和`UltraThink`是一样的逻辑。

- `longContext`是用于处理长下上文的场景,该项目会对每次请求使用tiktoken实时计算上下文长度,如果上下文大于32K则使用该模型,旨在弥补`DeepSeek`在长上下文处理不佳的情况。

以上就是该项目的发展历程以及我的一些思考,通过巧妙的使用环境变量覆盖的手段在不修改`Claude Code`源码的情况下完成请求的转发和修改,这就使得在可以得到 Anthropic 更新的同时使用自己的模型,自定义自己的提示词。该项目只是在 Anthropic 封禁中国区用户的情况下使用`Claude Code`并且达到成本和性能平衡的一种手段。如果可以的话,还是官方的Max Plan体验最好。


## /build/benchmark-request-logs.mjs

```mjs path="/build/benchmark-request-logs.mjs" 
import esbuild from "esbuild";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const buildDir = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(buildDir, "..");
const outputDir = path.join(projectRoot, ".benchmark-dist", "request-logs");
const outputFile = path.join(outputDir, "request-log-runtime.bench.js");
const args = parseArgs(process.argv.slice(2));

rmSync(outputDir, { force: true, recursive: true });
mkdirSync(outputDir, { recursive: true });

try {
  await esbuild.build({
    absWorkingDir: projectRoot,
    bundle: true,
    entryPoints: {
      "request-log-runtime.bench": path.join(projectRoot, "packages/core/benchmark/request-log-runtime.bench.mjs"),
      "request-log-web-load": path.join(projectRoot, "packages/core/benchmark/request-log-web-load.mjs"),
      "request-log-worker": path.join(projectRoot, "packages/core/src/observability/request-log-worker.ts")
    },
    external: ["better-sqlite3", "electron", "undici"],
    format: "cjs",
    legalComments: "none",
    logLevel: "warning",
    outdir: outputDir,
    platform: "node",
    plugins: [packageAliasPlugin()],
    target: "node22"
  });

  const runtime = resolveRuntime();
  const executable = runtime === "electron"
    ? (await import("electron")).default
    : process.execPath;
  const child = spawnSync(executable, [outputFile], {
    cwd: projectRoot,
    encoding: "utf8",
    env: {
      ...process.env,
      CCR_REQUEST_LOG_BENCHMARK_LABEL: args.label,
      CCR_REQUEST_LOG_BENCHMARK_SKIP_STORAGE: args.skipStorage ? "1" : "0",
      CCR_REQUEST_LOG_BENCHMARK_SKIP_WEB: args.skipWeb ? "1" : "0",
      CCR_REQUEST_LOG_BENCHMARK_WEB_BODY_BYTES: args.webBodyBytes,
      CCR_REQUEST_LOG_BENCHMARK_WEB_CONCURRENCY: args.webConcurrency,
      CCR_REQUEST_LOG_BENCHMARK_WEB_REQUESTS: args.webRequests,
      ...(runtime === "electron" ? { ELECTRON_RUN_AS_NODE: "1" } : {})
    },
    maxBuffer: 16 * 1024 * 1024,
    stdio: ["ignore", "pipe", "inherit"]
  });
  if (child.status !== 0) {
    process.stderr.write(child.stdout || `benchmark exited with ${child.status}\n`);
    process.exitCode = child.status ?? 1;
  } else {
    const result = JSON.parse(child.stdout);
    result.runtime = runtime;
    const json = `${JSON.stringify(result, null, 2)}\n`;
    process.stdout.write(json);
    if (args.output) {
      const destination = path.resolve(projectRoot, args.output);
      mkdirSync(path.dirname(destination), { recursive: true });
      writeFileSync(destination, json, "utf8");
    }
  }
} finally {
  rmSync(outputDir, { force: true, recursive: true });
}

function parseArgs(values) {
  const parsed = {
    label: "benchmark",
    output: "",
    skipStorage: false,
    skipWeb: false,
    webBodyBytes: "1024",
    webConcurrency: "64",
    webRequests: "10000"
  };
  for (let index = 0; index < values.length; index += 1) {
    if (values[index] === "--label") parsed.label = values[index + 1] || parsed.label;
    if (values[index] === "--output") parsed.output = values[index + 1] || "";
    if (values[index] === "--skip-web") parsed.skipWeb = true;
    if (values[index] === "--skip-storage") parsed.skipStorage = true;
    if (values[index] === "--web-body-bytes") parsed.webBodyBytes = values[index + 1] || parsed.webBodyBytes;
    if (values[index] === "--web-concurrency") parsed.webConcurrency = values[index + 1] || parsed.webConcurrency;
    if (values[index] === "--web-requests") parsed.webRequests = values[index + 1] || parsed.webRequests;
  }
  return parsed;
}

function resolveRuntime() {
  const probe = spawnSync(process.execPath, [
    "-e",
    "const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.close();"
  ], { stdio: "ignore" });
  return probe.status === 0 ? "node" : "electron";
}

function packageAliasPlugin() {
  const roots = {
    cli: path.join(projectRoot, "packages/cli/src"),
    core: path.join(projectRoot, "packages/core/src"),
    electron: path.join(projectRoot, "packages/electron/src"),
    ui: path.join(projectRoot, "packages/ui/src")
  };
  return {
    name: "request-log-benchmark-alias",
    setup(build) {
      build.onResolve({ filter: /^@ccr\/(cli|core|electron|ui)\// }, (resolveArgs) => {
        const match = resolveArgs.path.match(/^@ccr\/(cli|core|electron|ui)\/(.+)$/);
        if (!match) return undefined;
        return { path: resolvePackageImport(roots[match[1]], match[2]) };
      });
    }
  };
}

function resolvePackageImport(root, importPath) {
  const base = path.resolve(root, importPath);
  for (const candidate of [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.mjs`, path.join(base, "index.ts")]) {
    if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
  }
  return base;
}

```

## /build/build.mjs

```mjs path="/build/build.mjs" 
import "./../scripts/generate-claude-code-config-options.mjs";
import { buildBrowserRenderer, buildMain, buildRenderer, buildRequestLogBodyWorker, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";

const mode = process.argv.includes("--dev") ? "development" : "production";

cleanDist();
copyAppAssets();
copyBundledClaudeRuntimePlugins();
copyModelCatalog();
copyBrowserRendererHtml();
copyRendererHtml();
copyTrayRendererHtml();

await Promise.all([
  buildMain({ mode }),
  buildBrowserRenderer({ mode }),
  buildRenderer({ mode }),
  buildRequestLogBodyWorker({ mode }),
  buildTrayRenderer({ mode }),
  buildWebClientBridge({ mode }),
  buildStyles({ minify: mode === "production" })
]);

syncUiRendererToRuntimeDists();

console.log(`Built monorepo package assets in ${mode} mode.`);

```

## /build/dev.mjs

```mjs path="/build/dev.mjs" 
import electron from "electron";
import esbuild from "esbuild";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import {
  buildStyles,
  cleanDist,
  browserRendererHtmlInput,
  cliSourceRoot,
  coreSourceRoot,
  copyAppAssets,
  copyBrowserRendererHtml,
  copyBundledClaudeRuntimePlugins,
  copyCliRuntimeToElectronDist,
  copyModelCatalog,
  copyRendererHtml,
  copyTrayRendererHtml,
  createRequestLogBodyWorkerBuildOptions,
  createBrowserRendererBuildOptions,
  createCliBuildOptions,
  createMainBuildOptions,
  createRendererBuildOptions,
  createTrayRendererBuildOptions,
  createWebClientBridgeBuildOptions,
  appAssetsInput,
  bundledClaudeRuntimePluginsInputDir,
  modelCatalogInput,
  projectRoot,
  rendererRoot,
  rendererHtmlInput,
  syncUiRendererToRuntimeDists,
  trayRendererHtmlInput,
  watchPlugin
} from "./esbuild.config.mjs";

let electronProcess = null;
let restartTimer = null;
let restartInFlight = false;
let restartQueued = false;
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,
  logWorker: false,
  main: false,
  renderer: 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.ui ? ["logWorker"] : []),
  ...(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}`);
}

function relativePath(file) {
  return path.relative(projectRoot, file) || ".";
}

function readyState() {
  return Object.entries(ready)
    .filter(([name]) => activeReadyNames.has(name))
    .map(([name, value]) => `${name}:${value ? "ready" : "pending"}`)
    .join(" ");
}

function contentSignature(targetPath) {
  try {
    return readContentSignature(targetPath);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return {
      key: `error:${message}`,
      summary: `signature-error=${message}`
    };
  }
}

function readContentSignature(targetPath) {
  if (!existsSync(targetPath)) {
    return {
      key: "missing",
      summary: "missing"
    };
  }

  const stats = statSync(targetPath);
  if (stats.isDirectory()) {
    return directorySignature(targetPath);
  }

  const content = readFileSync(targetPath);
  const hash = createHash("sha1").update(content).digest("hex").slice(0, 12);
  return {
    key: `file:${hash}`,
    summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()} sha1=${hash}`
  };
}

function directorySignature(targetPath) {
  const files = listDirectoryFiles(targetPath);
  const hash = createHash("sha1");
  let newestMtimeMs = 0;

  for (const file of files) {
    const absolutePath = path.join(targetPath, file);
    const stats = statSync(absolutePath);
    newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
    hash.update(file);
    hash.update("\0");
    hash.update(readFileSync(absolutePath));
    hash.update("\0");
  }

  const digest = hash.digest("hex").slice(0, 12);
  const newestMtime = newestMtimeMs > 0 ? new Date(newestMtimeMs).toISOString() : "none";
  return {
    key: `dir:${digest}`,
    summary: `files=${files.length} newestMtime=${newestMtime} sha1=${digest}`
  };
}

function listDirectoryFiles(targetPath, basePath = targetPath) {
  const entries = readdirSync(targetPath, { withFileTypes: true })
    .filter((entry) => !ignoredSignatureEntries.has(entry.name))
    .sort((a, b) => a.name.localeCompare(b.name));
  const files = [];

  for (const entry of entries) {
    const absolutePath = path.join(targetPath, entry.name);
    const relative = path.relative(basePath, absolutePath);
    if (entry.isDirectory()) {
      files.push(...listDirectoryFiles(absolutePath, basePath));
    } else if (entry.isFile()) {
      files.push(relative);
    }
  }

  return files;
}

function rememberWatchSignature(label, targetPath, options = {}) {
  const signature = options.metadataOnly
    ? metadataSignature(targetPath)
    : contentSignature(targetPath);
  watchSignatures.set(label, signature.key);
  logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`);
}

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 pollWatchedInput(label, targetPath, onChange, options = {}) {
  const signature = options.metadataOnly
    ? metadataSignature(targetPath)
    : contentSignature(targetPath);
  const previousSignature = watchSignatures.get(label);
  if (previousSignature === signature.key) {
    return;
  }

  watchSignatures.set(label, signature.key);
  logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`);
  try {
    onChange();
    if (enabled.electron && options.restart !== false) {
      scheduleRestart(label);
    }
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    logDev(`watch action failed: ${label}; ${message}`);
  }
}

function metadataSignature(targetPath) {
  if (!existsSync(targetPath)) {
    return {
      key: "missing",
      summary: "missing"
    };
  }

  const stats = statSync(targetPath);
  return {
    key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`,
    summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}`
  };
}

function pollSourceWatchTargets() {
  pollWatchedInput("home html", rendererHtmlInput, () => {
    copyRendererHtml();
    syncUiRendererToRuntimeDists();
  });
  pollWatchedInput("browser html", browserRendererHtmlInput, () => {
    copyBrowserRendererHtml();
    syncUiRendererToRuntimeDists();
  });
  pollWatchedInput("tray html", trayRendererHtmlInput, () => {
    copyTrayRendererHtml();
    syncUiRendererToRuntimeDists();
  });
  if (enabled.electron) {
    pollWatchedInput("app assets", appAssetsInput, copyAppAssets);
    pollWatchedInput("bundled Claude runtime plugins", bundledClaudeRuntimePluginsInputDir, copyBundledClaudeRuntimePlugins);
  }
  if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
    pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true });
  }
}

function markReady(name, reason = `${name} esbuild completed`) {
  if (name === "browser" || name === "cli" || name === "logWorker" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
    ready[name] = true;
  }
  logDev(`build ready: ${reason}; ${readyState()}`);
  if (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) {
    scheduleRestart(reason);
  }
}

function scheduleRestart(reason = "unknown trigger") {
  if (shuttingDown) {
    logDev(`restart ignored during shutdown: ${reason}`);
    return;
  }
  pendingRestartReasons.push(reason);
  if (restartTimer) {
    clearTimeout(restartTimer);
    logDev(`restart rescheduled in ${restartDelayMs}ms: ${reason}`);
  } else {
    logDev(`restart scheduled in ${restartDelayMs}ms: ${reason}`);
  }
  restartTimer = setTimeout(restartElectron, restartDelayMs);
}

async function restartElectron() {
  if (restartInFlight) {
    restartQueued = true;
    return;
  }
  restartInFlight = true;
  const reasons = Array.from(new Set(pendingRestartReasons));
  pendingRestartReasons = [];
  restartTimer = null;

  try {
    if (electronProcess) {
      await stopElectron(electronProcess);
    }

    if (shuttingDown) {
      return;
    }
    logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`);
    const child = spawn(electron, ["."], {
      cwd: projectRoot,
      env: {
        ...process.env,
        NODE_ENV: "development"
      },
      stdio: "inherit"
    });
    electronProcess = child;
    logDev(`Electron started pid=${child.pid ?? "unknown"}`);
    child.on("exit", (code, signal) => {
      logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`);
      if (electronProcess === child) {
        electronProcess = null;
      }
    });
  } finally {
    restartInFlight = false;
    if (restartQueued || pendingRestartReasons.length > 0) {
      restartQueued = false;
      scheduleRestart("changes queued during Electron restart");
    }
  }
}

async function stopElectron(child) {
  logDev(`stopping Electron pid=${child.pid ?? "unknown"}`);
  if (child.exitCode !== null || child.signalCode !== null) {
    if (electronProcess === child) {
      electronProcess = null;
    }
    return;
  }

  await new Promise((resolve) => {
    let forceTimer = null;
    let giveUpTimer = null;
    const finish = () => {
      if (forceTimer) clearTimeout(forceTimer);
      if (giveUpTimer) clearTimeout(giveUpTimer);
      child.off("exit", finish);
      resolve();
    };
    child.once("exit", finish);
    child.kill();
    forceTimer = setTimeout(() => {
      if (child.exitCode === null && child.signalCode === null) {
        logDev(`force stopping Electron pid=${child.pid ?? "unknown"}`);
        child.kill("SIGKILL");
      }
    }, 2_000);
    giveUpTimer = setTimeout(finish, 5_000);
  });
  if (electronProcess === child) {
    electronProcess = null;
  }
}

logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`);
cleanDist();
if (enabled.electron) {
  copyAppAssets();
  copyBundledClaudeRuntimePlugins();
}
if (enabled.cli || enabled.electron) {
  copyModelCatalog();
}
copyBrowserRendererHtml();
copyRendererHtml();
copyTrayRendererHtml();
await buildStyles({ minify: false });
syncUiRendererToRuntimeDists();

rememberWatchSignature("home html", rendererHtmlInput);
rememberWatchSignature("browser html", browserRendererHtmlInput);
rememberWatchSignature("tray html", trayRendererHtmlInput);
for (const styleWatchRoot of styleWatchRoots) {
  rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot);
}
if (enabled.electron) {
  rememberWatchSignature("app assets", appAssetsInput);
  rememberWatchSignature("bundled Claude runtime plugins", bundledClaudeRuntimePluginsInputDir);
}
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
  rememberWatchSignature("model catalog", modelCatalogInput, { metadataOnly: true });
}

const sourcePoller = setInterval(() => {
  pollStyleWatchRoots();
  pollSourceWatchTargets();
}, stylePollIntervalMs);

const contexts = [];

if (enabled.electron) {
  contexts.push(
    await esbuild.context(
      createMainBuildOptions({
        mode: "development",
        plugins: [watchPlugin("main", (name) => markReady(name))]
      })
    )
  );
}

if (enabled.cli) {
  contexts.push(
    await esbuild.context(
      createCliBuildOptions({
        mode: "development",
        plugins: [
          watchPlugin("cli", (name) => {
            if (enabled.electron) {
              copyCliRuntimeToElectronDist();
            }
            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 esbuild.context(
      createRequestLogBodyWorkerBuildOptions({
        mode: "development",
        plugins: [
          watchPlugin("logWorker", (name) => {
            syncUiRendererToRuntimeDists();
            markReady(name);
          })
        ]
      })
    )
  );
}

await Promise.all(contexts.map((context) => context.watch()));
logDev("watchers are active");

async function shutdown() {
  logDev("shutting down dev build");
  shuttingDown = true;
  if (restartTimer) {
    clearTimeout(restartTimer);
  }
  if (electronProcess) {
    await stopElectron(electronProcess);
  }
  if (styleBuildTimer) {
    clearTimeout(styleBuildTimer);
  }
  clearInterval(sourcePoller);
  await Promise.all(contexts.map((context) => context.dispose()));
  process.exit(0);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

```

## /build/docker-build.mjs

```mjs path="/build/docker-build.mjs" 
import {
  buildBrowserRenderer,
  buildCoreServer,
  buildRenderer,
  buildStyles,
  buildTrayRenderer,
  buildWebClientBridge,
  cleanDist,
  copyBrowserRendererHtml,
  copyModelCatalog,
  copyRendererHtml,
  copyTrayRendererHtml,
  syncUiRendererToRuntimeDists
} from "./esbuild.config.mjs";

const mode = process.argv.includes("--dev") ? "development" : "production";

cleanDist();
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.`);

```

## /build/docker-compose.mjs

```mjs path="/build/docker-compose.mjs" 
import { spawnSync } from "node:child_process";
import { prepareLocalGatewayPackage, projectRoot } from "./docker-local-gateway.mjs";

const args = process.argv.slice(2);
const composeArgs = args.length > 0 ? args : ["up", "-d", "--build"];

prepareLocalGatewayPackage();

const result = spawnSync("docker", ["compose", ...composeArgs], {
  cwd: projectRoot,
  stdio: "inherit"
});

process.exit(result.status ?? 1);

```

## /build/docker-image.mjs

```mjs path="/build/docker-image.mjs" 
import { spawnSync } from "node:child_process";
import { prepareLocalGatewayPackage, projectRoot } from "./docker-local-gateway.mjs";

const imageName = process.env.CCR_DOCKER_IMAGE || "claude-code-router:local";
const args = process.argv.slice(2);

prepareLocalGatewayPackage();

const dockerArgs = ["build", "-t", imageName, ".", ...args];
const result = spawnSync("docker", dockerArgs, {
  cwd: projectRoot,
  stdio: "inherit"
});

process.exit(result.status ?? 1);

```

## /build/docker-local-gateway.mjs

```mjs path="/build/docker-local-gateway.mjs" 
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const projectRoot = path.resolve(__dirname, "..");
export const dockerLocalGatewayDir = path.join(projectRoot, "docker", "local-ai-gateway");

const routerPluginSupportMarkers = [
  "requestTransforms",
  "routeResolvers",
  "httpRoutes"
];

export function resolveLocalGatewayRoot() {
  for (const envName of ["CCR_DOCKER_GATEWAY_SOURCE_DIR", "CCR_GATEWAY_SOURCE_DIR", "CCR_LOCAL_AI_GATEWAY_DIR"]) {
    const value = process.env[envName]?.trim();
    if (!value) continue;
    return path.resolve(projectRoot, value);
  }
  return path.resolve(projectRoot, "..", "..", "next-ai", "gateway");
}

export function hasLocalGatewaySource(gatewayRoot = resolveLocalGatewayRoot()) {
  return existsSync(path.join(gatewayRoot, "package.json")) &&
    existsSync(path.join(gatewayRoot, "bin", "next-ai-gateway.js"));
}

export function prepareLocalGatewayPackage(options = {}) {
  const gatewayRoot = options.gatewayRoot ?? resolveLocalGatewayRoot();
  if (!hasLocalGatewaySource(gatewayRoot)) {
    cleanLocalGatewayPackage();
    if (options.required) {
      throw new Error(`Local ai-gateway source was not found: ${gatewayRoot}`);
    }
    console.log(`[docker] Local ai-gateway source not found at ${gatewayRoot}; using package-lock dependency.`);
    return undefined;
  }

  run("npm", ["run", "build"], { cwd: gatewayRoot });
  assertGatewaySupportsRouterPlugin(gatewayRoot);

  cleanLocalGatewayPackage();
  mkdirSync(dockerLocalGatewayDir, { recursive: true });
  const result = run("npm", ["pack", "--ignore-scripts", "--pack-destination", dockerLocalGatewayDir], {
    cwd: gatewayRoot,
    capture: true
  });
  const fileName = result.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop();
  if (!fileName) {
    throw new Error("npm pack did not report a package file.");
  }
  const tarball = path.join(dockerLocalGatewayDir, fileName);
  if (!existsSync(tarball)) {
    throw new Error(`Packed ai-gateway tarball was not created: ${tarball}`);
  }
  console.log(`[docker] Packed local ai-gateway for Docker: ${path.relative(projectRoot, tarball)}`);
  return tarball;
}

export function cleanLocalGatewayPackage() {
  mkdirSync(dockerLocalGatewayDir, { recursive: true });
  for (const entry of readdirSync(dockerLocalGatewayDir, { withFileTypes: true })) {
    if (entry.isFile() && entry.name.endsWith(".tgz")) {
      unlinkSync(path.join(dockerLocalGatewayDir, entry.name));
    }
  }
}

function assertGatewaySupportsRouterPlugin(gatewayRoot) {
  const gatewayEntry = path.join(gatewayRoot, "dist", "index.js");
  if (!existsSync(gatewayEntry)) {
    throw new Error(`Local ai-gateway build output was not found: ${gatewayEntry}`);
  }
  const source = readFileSync(gatewayEntry, "utf8");
  const missing = routerPluginSupportMarkers.filter((marker) => !source.includes(marker));
  if (missing.length > 0) {
    throw new Error(`Local ai-gateway is missing router plugin support markers: ${missing.join(", ")}`);
  }
}

function run(command, args, options = {}) {
  const result = spawnSync(command, args, {
    cwd: options.cwd ?? projectRoot,
    encoding: "utf8",
    stdio: options.capture ? "pipe" : "inherit"
  });
  if (result.status !== 0) {
    throw new Error([
      `${command} ${args.join(" ")} failed with code ${result.status ?? "unknown"}`,
      result.stdout,
      result.stderr
    ].filter(Boolean).join("\n"));
  }
  return result.stdout?.trim() ?? "";
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const command = process.argv[2] ?? "prepare";
  if (command === "prepare") {
    prepareLocalGatewayPackage({ required: process.argv.includes("--required") });
  } else if (command === "clean") {
    cleanLocalGatewayPackage();
  } else {
    throw new Error(`Unknown docker local gateway command: ${command}`);
  }
}

```

## /build/electron-builder.local.cjs

```cjs path="/build/electron-builder.local.cjs" 
const baseConfig = require("../electron-builder.json");

const config = {
  ...baseConfig,
  directories: {
    ...baseConfig.directories,
    output: "release-local"
  },
  mac: {
    ...baseConfig.mac,
    identity: "-",
    notarize: false,
    forceCodeSigning: false
  }
};

delete config.afterSign;

module.exports = config;

```

## /build/entitlements.mac.inherit.plist

```plist path="/build/entitlements.mac.inherit.plist" 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
  </dict>
</plist>

```

## /build/entitlements.mac.plist

```plist path="/build/entitlements.mac.plist" 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
  </dict>
</plist>

```

## /build/esbuild.config.mjs

```mjs path="/build/esbuild.config.mjs" 
import esbuild from "esbuild";
import { spawn } from "node:child_process";
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");
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 = resolveGatewayPackageRoot();
export const gatewayRuntimeInput = resolveGatewayRuntimeInput(gatewayPackageRoot);
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 bundledClaudeRuntimePluginIds = ["claude-design", "claude-ship", "new-api-account"];
export const bundledClaudeRuntimePluginsInputDir = path.join(electronRoot, "bundled-plugins");
export const electronBundledRuntimePluginsDir = path.join(electronDistDir, "bundled-plugins");
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");
export const browserRendererHtmlOutput = path.join(rendererOutDir, "pages", "browser", "index.html");
export const trayRendererHtmlInput = path.join(rendererRoot, "pages", "tray", "index.html");
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 requestLogBodyWorkerOutput = path.join(rendererAssetsDir, "log-body.worker.js");
export const requestLogBodyWorkerInput = path.join(rendererRoot, "pages", "home", "shared", "log-body.worker.ts");
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
export const localAgentAuthProviderHookInput = path.join(coreSourceRoot, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts");
export const routerPluginInput = path.join(coreSourceRoot, "gateway", "core-runtime", "router-plugin.ts");
export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.ts");
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js", "media-tools-proxy-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",
  "better-sqlite3",
  ...builtinModules,
  ...builtinModules.map((moduleName) => `node:${moduleName}`)
];

function resolveGatewayPackageRoot() {
  for (const envName of ["CCR_GATEWAY_SOURCE_DIR", "CCR_GATEWAY_PACKAGE_DIR", "CCR_LOCAL_AI_GATEWAY_DIR"]) {
    const value = process.env[envName]?.trim();
    if (!value) continue;
    const root = path.resolve(projectRoot, value);
    if (!isGatewayPackageRoot(root)) {
      throw new Error(`${envName} must point to an ai-gateway package root: ${root}`);
    }
    return root;
  }

  const gatewayEntry = process.env.CCR_GATEWAY_ENTRY?.trim();
  if (gatewayEntry) {
    const entry = path.resolve(projectRoot, gatewayEntry);
    const root = nearestPackageRoot(entry);
    if (!root || !isGatewayPackageRoot(root)) {
      throw new Error(`CCR_GATEWAY_ENTRY must point inside an ai-gateway package: ${entry}`);
    }
    return root;
  }

  const siblingGatewayRoot = path.resolve(projectRoot, "..", "..", "next-ai", "gateway");
  if (isGatewayPackageRoot(siblingGatewayRoot)) {
    return siblingGatewayRoot;
  }

  for (const packageName of ["@the-next-ai/ai-gateway", "gateway"]) {
    try {
      const root = nearestPackageRoot(requireFromHere.resolve(packageName));
      if (root && isGatewayPackageRoot(root)) {
        return root;
      }
    } catch {
      // Try the next known package name.
    }
  }
  throw new Error("Unable to resolve an installed ai-gateway package.");
}

function resolveGatewayRuntimeInput(packageRoot) {
  const manifestFile = path.join(packageRoot, "package.json");
  const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
  const binEntry = typeof manifest.bin === "string"
    ? manifest.bin
    : manifest.bin?.["next-ai-gateway"];
  const candidate = binEntry
    ? path.join(packageRoot, binEntry)
    : path.join(packageRoot, "bin", "next-ai-gateway.js");
  if (!existsSync(candidate)) {
    throw new Error(`ai-gateway runtime bin entry was not found: ${candidate}`);
  }
  return candidate;
}

function nearestPackageRoot(fileOrDirectory) {
  let current = fileOrDirectory;
  if (!existsSync(path.join(current, "package.json"))) {
    current = path.dirname(current);
  }
  for (let depth = 0; depth < 8; depth += 1) {
    if (existsSync(path.join(current, "package.json"))) {
      return current;
    }
    const parent = path.dirname(current);
    if (parent === current) {
      return undefined;
    }
    current = parent;
  }
  return undefined;
}

function isGatewayPackageRoot(candidate) {
  const manifestFile = path.join(candidate, "package.json");
  if (!existsSync(manifestFile)) {
    return false;
  }
  try {
    const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
    return manifest.name === "@the-next-ai/ai-gateway" || manifest.name === "gateway";
  } catch {
    return false;
  }
}

export function cleanDist() {
  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(cliMainOutDir, { recursive: true });
  mkdirSync(coreMainOutDir, { recursive: true });
  mkdirSync(electronMainOutDir, { recursive: true });
  mkdirSync(electronBotGatewaySdkDistDir, { recursive: true });
  mkdirSync(electronBotGatewaySdkBinDir, { recursive: true });
  mkdirSync(appAssetsDir, { recursive: true });
  mkdirSync(electronBundledRuntimePluginsDir, { 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 });
}

export function copyAppAssets() {
  ensureDist();
  if (existsSync(appAssetsInput)) {
    cpSync(appAssetsInput, appAssetsDir, { recursive: true });
  }
}

export function copyModelCatalog() {
  ensureDist();
  if (existsSync(modelCatalogInput)) {
    cpSync(modelCatalogInput, cliModelCatalogOutput);
    cpSync(modelCatalogInput, coreModelCatalogOutput);
    cpSync(modelCatalogInput, electronModelCatalogOutput);
  }
}

export function copyRendererHtml() {
  copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", {
    beforeModuleScriptTags: ['    <script src="../../assets/web-client-bridge.js"></script>']
  });
}

export function copyTrayRendererHtml() {
  copyRendererPageHtml(trayRendererHtmlInput, trayRendererHtmlOutput, "tray.js");
}

export function copyBrowserRendererHtml() {
  copyRendererPageHtml(browserRendererHtmlInput, browserRendererHtmlOutput, "browser.js");
}

export function copyBundledClaudeRuntimePlugins() {
  ensureDist();
  for (const pluginId of bundledClaudeRuntimePluginIds) {
    copyBundledClaudeRuntimePlugin(pluginId);
  }
}

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 = '    <link rel="stylesheet" href="../../assets/main.css" />';
  const scriptTag = `    <script type="module" src="../../assets/${scriptName}"></script>`;
  let html = source.includes('<script type="module" src="./main.tsx"></script>')
    ? source.replace('    <script type="module" src="./main.tsx"></script>', scriptTag)
    : source.replace("</body>", `${scriptTag}\n  </body>`);

  for (const extraScriptTag of options.beforeModuleScriptTags ?? []) {
    if (!hasScriptTag(html, extraScriptTag)) {
      html = html.replace(scriptTag, `${extraScriptTag}\n${scriptTag}`);
    }
  }

  if (!html.includes('href="../../assets/main.css"')) {
    html = html.replace("</head>", `${styleTag}\n  </head>`);
  }

  writeFileSync(output, html, "utf8");
}

function copyBundledClaudeRuntimePlugin(pluginId) {
  const inputDir = path.join(bundledClaudeRuntimePluginsInputDir, pluginId);
  const outputDir = path.join(electronBundledRuntimePluginsDir, pluginId);
  const moduleInput = path.join(inputDir, "index.cjs");
  if (!existsSync(moduleInput)) {
    throw new Error(`Bundled Claude runtime plugin ${pluginId} is missing: ${moduleInput}`);
  }

  rmSync(outputDir, { force: true, recursive: true });
  mkdirSync(outputDir, { recursive: true });
  for (const fileName of ["index.cjs", "plugin.json", "README.md"]) {
    const input = path.join(inputDir, fileName);
    if (existsSync(input)) {
      cpSync(input, path.join(outputDir, fileName));
    }
  }
}

function hasScriptTag(html, scriptTag) {
  const sourceMatch = scriptTag.match(/\bsrc="([^"]+)"/);
  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,
    bundle: true,
    entryNames: "[name]",
    entryPoints: [
      path.join(electronSourceRoot, "main", "main.ts"),
      path.join(electronSourceRoot, "main", "browser-preload.ts"),
      gatewayRuntimeInput,
      path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"),
      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"),
      path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"),
      path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
      path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
      path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
      localAgentAuthProviderHookInput,
      routerPluginInput,
      upstreamHeaderSanitizerInput,
      electronUndiciProxyAgentInput,
      path.join(electronSourceRoot, "main", "preload.ts")
    ],
    external: nodeExternals,
    format: "cjs",
    legalComments: "none",
    logLevel: "info",
    metafile: true,
    minify: mode === "production",
    outdir: electronMainOutDir,
    platform: "node",
    plugins: [packageAliasPlugin(), ...plugins],
    sourcemap: mode !== "production",
    target: "node22"
  };
}

export function createCliBuildOptions({ mode = "production", plugins = [] } = {}) {
  return {
    absWorkingDir: projectRoot,
    bundle: true,
    entryNames: "[name]",
    entryPoints: [
      path.join(cliSourceRoot, "cli.ts"),
      gatewayRuntimeInput,
      path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"),
      path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
      path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
      path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"),
      path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
      path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
      path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
      localAgentAuthProviderHookInput,
      routerPluginInput,
      upstreamHeaderSanitizerInput
    ],
    external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
    format: "cjs",
    legalComments: "none",
    logLevel: "info",
    minify: mode === "production",
    outdir: cliMainOutDir,
    platform: "node",
    plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
    sourcemap: mode !== "production",
    target: "node22"
  };
}

export function createCoreServerBuildOptions({ mode = "production", plugins = [] } = {}) {
  return {
    absWorkingDir: projectRoot,
    bundle: true,
    entryNames: "[name]",
    entryPoints: [
      path.join(coreSourceRoot, "entrypoints", "server.ts"),
      gatewayRuntimeInput,
      path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"),
      path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
      path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
      path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"),
      path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
      path.join(coreSourceRoot, "observability", "request-log-worker.ts"),
      path.join(coreSourceRoot, "routing", "route-script-worker.ts"),
      localAgentAuthProviderHookInput,
      routerPluginInput,
      upstreamHeaderSanitizerInput
    ],
    external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
    format: "cjs",
    legalComments: "none",
    logLevel: "info",
    minify: mode === "production",
    outdir: coreMainOutDir,
    platform: "node",
    plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
    sourcemap: mode !== "production",
    target: "node22"
  };
}

export function createRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
  return {
    absWorkingDir: projectRoot,
    assetNames: "assets/[name]-[hash]",
    bundle: true,
    define: {
      "process.env.NODE_ENV": JSON.stringify(mode)
    },
    entryPoints: [path.join(rendererRoot, "pages", "home", "main.tsx")],
    format: "esm",
    jsx: "automatic",
    legalComments: "none",
    loader: {
      ".gif": "file",
      ".ico": "file",
      ".jpg": "file",
      ".jpeg": "file",
      ".png": "file",
      ".svg": "file",
      ".webp": "file"
    },
    logLevel: "info",
    minify: mode === "production",
    outfile: path.join(rendererAssetsDir, "main.js"),
    platform: "browser",
    plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
    publicPath: "../../assets",
    sourcemap: mode !== "production",
    target: "chrome120"
  };
}

export function createTrayRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
  return {
    ...createRendererBuildOptions({ mode, plugins }),
    entryPoints: [path.join(rendererRoot, "pages", "tray", "main.tsx")],
    outfile: path.join(rendererAssetsDir, "tray.js")
  };
}

export function createBrowserRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
  return {
    ...createRendererBuildOptions({ mode, plugins }),
    entryPoints: [path.join(rendererRoot, "pages", "browser", "main.tsx")],
    outfile: path.join(rendererAssetsDir, "browser.js")
  };
}

export function createWebClientBridgeBuildOptions({ mode = "production", plugins = [] } = {}) {
  return {
    absWorkingDir: projectRoot,
    bundle: true,
    entryPoints: [path.join(uiSourceRoot, "web-client-bridge.ts")],
    format: "iife",
    legalComments: "none",
    logLevel: "info",
    minify: mode === "production",
    outfile: webClientBridgeOutput,
    platform: "browser",
    plugins: [packageAliasPlugin(), ...plugins],
    sourcemap: mode !== "production",
    target: "chrome120"
  };
}

export function createRequestLogBodyWorkerBuildOptions({ mode = "production", plugins = [] } = {}) {
  return {
    absWorkingDir: projectRoot,
    bundle: true,
    define: {
      "process.env.NODE_ENV": JSON.stringify(mode)
    },
    entryPoints: [requestLogBodyWorkerInput],
    format: "esm",
    legalComments: "none",
    logLevel: "info",
    minify: mode === "production",
    outfile: requestLogBodyWorkerOutput,
    platform: "browser",
    plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
    sourcemap: mode !== "production",
    target: "chrome120"
  };
}

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`,
    setup(build) {
      build.onEnd((result) => {
        if (result.errors.length === 0) {
          onEnd(name);
        }
      });
    }
  };
}

export async function buildMain(options = {}) {
  const [mainBuildResult] = await Promise.all([
    esbuild.build(createMainBuildOptions(options)),
    buildBotGatewaySdkRuntime(options),
    buildCoreServer(options),
    buildCli(options)
  ]);
  copyCliRuntimeToElectronDist();
  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));
}

export async function buildCoreServer(options = {}) {
  await esbuild.build(createCoreServerBuildOptions(options));
}

export async function buildRenderer(options = {}) {
  await esbuild.build(createRendererBuildOptions(options));
}

export async function buildTrayRenderer(options = {}) {
  await esbuild.build(createTrayRendererBuildOptions(options));
}

export async function buildBrowserRenderer(options = {}) {
  await esbuild.build(createBrowserRendererBuildOptions(options));
}

export async function buildWebClientBridge(options = {}) {
  await esbuild.build(createWebClientBridgeBuildOptions(options));
}

export async function buildRequestLogBodyWorker(options = {}) {
  await esbuild.build(createRequestLogBodyWorkerBuildOptions(options));
}

export function copyCliRuntimeToElectronDist() {
  ensureDist();
  const cliRuntime = path.join(cliMainOutDir, "cli.js");
  if (existsSync(cliRuntime)) {
    cpSync(cliRuntime, path.join(electronMainOutDir, "cli.js"));
  }
}

export async function buildStyles({ minify = false } = {}) {
  ensureDist();
  const args = ["-i", cssInput, "-o", cssOutput];
  if (minify) {
    args.push("--minify");
  }
  await runCommand(binPath("tailwindcss"), args);
}

export function binPath(name) {
  const extension = process.platform === "win32" ? ".cmd" : "";
  return path.join(projectRoot, "node_modules", ".bin", `${name}${extension}`);
}

export function runCommand(command, args, options = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, {
      cwd: projectRoot,
      stdio: "inherit",
      shell: process.platform === "win32",
      ...options
    });

    child.on("error", reject);
    child.on("exit", (code) => {
      if (code === 0) {
        resolve();
        return;
      }
      reject(new Error(`${path.basename(command)} exited with code ${code}`));
    });
  });
}

function rendererAliasPlugin() {
  return {
    name: "renderer-alias",
    setup(build) {
      build.onResolve({ filter: /^@\// }, (args) => {
        return { path: resolveRendererImport(args.path.slice(2)) };
      });
    }
  };
}

function packageAliasPlugin() {
  return {
    name: "ccr-package-alias",
    setup(build) {
      build.onResolve({ filter: /^@ccr\/cli\// }, (args) => {
        return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) };
      });
      build.onResolve({ filter: /^@ccr\/core\// }, (args) => {
        return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) };
      });
      build.onResolve({ filter: /^@ccr\/electron\// }, (args) => {
        return { path: resolvePackageImport(electronSourceRoot, args.path.slice("@ccr/electron/".length)) };
      });
      build.onResolve({ filter: /^@ccr\/ui\// }, (args) => {
        return { path: resolvePackageImport(uiSourceRoot, args.path.slice("@ccr/ui/".length)) };
      });
    }
  };
}

function forbidCliElectronPlugin() {
  return {
    name: "forbid-cli-electron",
    setup(build) {
      build.onResolve({ filter: /^electron$/ }, () => {
        return {
          errors: [
            {
              text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary."
            }
          ]
        };
      });
    }
  };
}

function validateLightweightMcpBundles(metafile) {
  if (!metafile) {
    return;
  }

  const outputsByName = new Map(
    Object.entries(metafile.outputs).map(([outputPath, output]) => [path.basename(outputPath), { output, outputPath }])
  );

  for (const bundleName of lightweightMcpBundleNames) {
    const entry = outputsByName.get(bundleName);
    if (!entry) {
      continue;
    }

    const violations = [];
    if (entry.output.bytes > lightweightMcpBundleMaxBytes) {
      violations.push(`bundle size ${entry.output.bytes} bytes exceeds ${lightweightMcpBundleMaxBytes} bytes`);
    }

    for (const inputPath of Object.keys(entry.output.inputs ?? {})) {
      const normalizedInput = normalizeBuildPath(inputPath);
      for (const rule of forbiddenLightweightMcpInputs) {
        if (normalizedInput.startsWith(rule.prefix)) {
          violations.push(`${normalizedInput} (${rule.reason})`);
        }
      }
    }

    for (const imported of entry.output.imports ?? []) {
      if (imported.external && forbiddenLightweightMcpExternalImports.has(imported.path)) {
        violations.push(`${imported.path} (external native/runtime dependency is not allowed)`);
      }
    }

    if (violations.length > 0) {
      throw new Error([
        `Lightweight MCP bundle ${bundleName} crossed its dependency boundary.`,
        ...violations.map((violation) => `- ${violation}`)
      ].join("\n"));
    }
  }
}

function normalizeBuildPath(value) {
  return value.split(path.sep).join("/");
}

function resolveRendererImport(importPath) {
  return resolvePackageImport(rendererRoot, importPath);
}

function resolvePackageImport(rootDir, importPath) {
  const packageBasePath = path.resolve(rootDir, importPath);
  const candidates = [
    packageBasePath,
    `${packageBasePath}.tsx`,
    `${packageBasePath}.ts`,
    `${packageBasePath}.jsx`,
    `${packageBasePath}.js`,
    `${packageBasePath}.json`,
    `${packageBasePath}.css`,
    path.join(packageBasePath, "index.tsx"),
    path.join(packageBasePath, "index.ts"),
    path.join(packageBasePath, "index.jsx"),
    path.join(packageBasePath, "index.js")
  ];

  for (const candidate of candidates) {
    if (existsSync(candidate) && statSync(candidate).isFile()) {
      return candidate;
    }
  }

  return packageBasePath;
}

```

## /build/icon.icns

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/build/icon.icns

## /build/icon.ico

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/build/icon.ico

## /build/icon.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/build/icon.png

## /build/macos-release-preflight.mjs

```mjs path="/build/macos-release-preflight.mjs" 
import { execFileSync } from "node:child_process";

const errors = [];

function reportAndExit() {
  if (errors.length === 0) {
    return;
  }

  console.error("macOS release preflight failed:");
  for (const error of errors) {
    console.error(`- ${error}`);
  }
  process.exit(1);
}

function run(command, args) {
  try {
    return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
  } catch (error) {
    const stderr = error.stderr?.toString().trim();
    const stdout = error.stdout?.toString().trim();
    const details = stderr || stdout || error.message;
    errors.push(`cannot run ${command} ${args.join(" ")}: ${details}`);
    return "";
  }
}

function hasCompleteNotaryCredentials() {
  const hasAppPassword =
    Boolean(process.env.APPLE_ID) &&
    Boolean(process.env.APPLE_APP_SPECIFIC_PASSWORD) &&
    Boolean(process.env.APPLE_TEAM_ID);

  const hasApiKey =
    Boolean(process.env.APPLE_API_KEY) &&
    Boolean(process.env.APPLE_API_KEY_ID) &&
    Boolean(process.env.APPLE_API_ISSUER);

  const hasKeychainProfile = Boolean(process.env.APPLE_KEYCHAIN_PROFILE);

  return hasAppPassword || hasApiKey || hasKeychainProfile;
}

if (process.platform !== "darwin") {
  errors.push("macOS release builds must run on macOS.");
  reportAndExit();
}

const developerDir = run("xcode-select", ["-p"]).trim();
if (developerDir.endsWith("/CommandLineTools")) {
  errors.push("notarization stapling requires full Xcode. Install Xcode and run `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`.");
}

run("xcrun", ["notarytool", "--version"]);
run("xcrun", ["-f", "stapler"]);

if (!process.env.CSC_LINK) {
  const identities = run("security", ["find-identity", "-v", "-p", "codesigning"]);
  if (!/"Developer ID Application: .+ \([A-Z0-9]+\)"/.test(identities)) {
    errors.push("no Developer ID Application signing identity was found in the keychain. Install a Developer ID Application certificate or provide CSC_LINK/CSC_KEY_PASSWORD.");
  }
}

if (!hasCompleteNotaryCredentials()) {
  errors.push(
    "missing notarization credentials. Set APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER, or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID, or APPLE_KEYCHAIN_PROFILE."
  );
}

reportAndExit();
console.log("macOS release preflight passed.");

```

## /build/merge-macos-update-metadata.mjs

```mjs path="/build/merge-macos-update-metadata.mjs" 
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import yaml from "js-yaml";

const [outputFile, ...inputFiles] = process.argv.slice(2);

if (!outputFile || inputFiles.length < 2) {
  console.error("Usage: node build/merge-macos-update-metadata.mjs <output> <latest-mac.yml> <latest-mac.yml> [...]");
  process.exit(1);
}

const updateInfos = inputFiles.map((file) => {
  const info = yaml.load(readFileSync(file, "utf8"));
  if (!info || typeof info !== "object") {
    throw new Error(`${file} is not a valid update metadata object`);
  }
  if (!Array.isArray(info.files) || info.files.length === 0) {
    throw new Error(`${file} does not contain any update files`);
  }
  return { file, info };
});

const version = updateInfos[0].info.version;
if (!version || updateInfos.some(({ info }) => info.version !== version)) {
  throw new Error("All macOS update metadata files must have the same version");
}

const files = uniqueByUrl(
  updateInfos
    .flatMap(({ info }) => info.files)
    .filter((file) => file && typeof file.url === "string")
    .sort(compareMacUpdateFile)
);

const defaultZip = files.find((file) => file.url.endsWith(".zip") && file.url.includes("arm64")) ?? files.find((file) => file.url.endsWith(".zip"));
if (!defaultZip?.sha512) {
  throw new Error("Merged macOS update metadata must include at least one ZIP with sha512");
}

const releaseDate = updateInfos
  .map(({ info }) => info.releaseDate)
  .filter(Boolean)
  .sort()
  .at(-1);

const { files: _files, path: _path, sha512: _sha512, releaseDate: _releaseDate, ...baseInfo } = updateInfos[0].info;
const mergedInfo = {
  ...baseInfo,
  version,
  files,
  path: defaultZip.url,
  sha512: defaultZip.sha512,
  ...(releaseDate ? { releaseDate } : {})
};

writeFileSync(outputFile, yaml.dump(mergedInfo, { lineWidth: 120, noRefs: true }), "utf8");
console.log(`Wrote ${path.relative(process.cwd(), outputFile)} with ${files.length} macOS artifacts.`);

function uniqueByUrl(files) {
  const seen = new Set();
  return files.filter((file) => {
    if (seen.has(file.url)) {
      return false;
    }
    seen.add(file.url);
    return true;
  });
}

function compareMacUpdateFile(left, right) {
  return fileRank(left) - fileRank(right) || left.url.localeCompare(right.url);
}

function fileRank(file) {
  const isZip = file.url.endsWith(".zip") ? 0 : 10;
  const isAppleSilicon = file.url.includes("arm64") ? 0 : 1;
  return isZip + isAppleSilicon;
}

```

## /build/run-tests.mjs

```mjs path="/build/run-tests.mjs" 
import electron from "electron";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const testsOutDir = path.join(projectRoot, ".test-dist");
const testProjects = {
  architecture: { runtime: "node" },
  cli: { runtime: "node" },
  core: { runtime: "node-with-electron-fallback" },
  electron: { runtime: "electron" },
  ui: { runtime: "node" }
};
const requestedProjects = process.argv.slice(2);
const projects = requestedProjects.length === 0 ? Object.keys(testProjects) : requestedProjects;

for (const project of projects) {
  if (!testProjects[project]) {
    throw new Error(`Unknown test project: ${project}`);
  }
}

for (const project of projects) {
  await runProject(project);
}

async function runProject(project) {
  const testFiles = findCompiledTests(path.join(testsOutDir, project, "test"));
  if (testFiles.length === 0) {
    console.log(`No ${project} tests found.`);
    return;
  }

  const testHome = mkdtempSync(path.join(os.tmpdir(), `ccr-${project}-test-home-`));
  const runtime = resolveRuntime(testProjects[project].runtime);
  const executable = runtime === "electron" ? electron : process.execPath;
  console.log(`\nRunning ${project} tests with ${runtime}...`);

  try {
    await new Promise((resolve, reject) => {
      const child = spawn(executable, ["--test", ...testFiles], {
        cwd: projectRoot,
        env: {
          ...process.env,
          CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"),
          CCR_INTERNAL_HOME_DIR: testHome,
          CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"),
          HOME: testHome,
          ...(runtime === "electron" ? { ELECTRON_RUN_AS_NODE: "1" } : {})
        },
        stdio: "inherit"
      });

      child.on("error", reject);
      child.on("exit", (code, signal) => {
        if (signal) {
          reject(new Error(`${project} tests exited from signal ${signal}`));
          return;
        }
        if (code === 0) {
          resolve();
          return;
        }
        reject(new Error(`${project} tests exited with code ${code ?? 1}`));
      });
    });
  } finally {
    rmSync(testHome, { force: true, recursive: true });
  }
}

function resolveRuntime(runtime) {
  if (runtime !== "node-with-electron-fallback") {
    return runtime;
  }
  const probe = spawnSync(process.execPath, [
    "-e",
    "const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.close();"
  ], { stdio: "ignore" });
  return probe.status === 0 ? "node" : "electron";
}

function findCompiledTests(dir) {
  if (!existsSync(dir)) {
    return [];
  }
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const file = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      return findCompiledTests(file);
    }
    return entry.isFile() && entry.name.endsWith(".test.js") ? [file] : [];
  }).sort();
}

```

## /build/test.mjs

```mjs path="/build/test.mjs" 
import esbuild from "esbuild";
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const ccrExtensionsRoot = path.resolve(process.env.CCR_EXTENSIONS_DIR || path.join(projectRoot, "..", "ccr-extensions"));
const testsOutDir = path.join(projectRoot, ".test-dist");
const packageRoots = {
  cli: path.join(projectRoot, "packages", "cli", "src"),
  core: path.join(projectRoot, "packages", "core", "src"),
  electron: path.join(projectRoot, "packages", "electron", "src"),
  ui: path.join(projectRoot, "packages", "ui", "src")
};
const testProjects = {
  architecture: {
    testDir: path.join(projectRoot, "tests", "architecture")
  },
  cli: {
    runtimeEntryPoints: {
      "runtime/cli": path.join(packageRoots.cli, "cli.ts")
    },
    testDir: path.join(projectRoot, "packages", "cli", "test")
  },
  core: {
    runtimeEntryPoints: {
      "runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"),
      "runtime/gateway-bootstrap": path.join(packageRoots.core, "gateway", "core-runtime", "gateway-bootstrap.ts"),
      "runtime/local-agent-auth-provider-hook": path.join(packageRoots.core, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts"),
      "runtime/media-tools-proxy-mcp": path.join(packageRoots.core, "mcp", "media-tools-proxy-mcp.ts"),
      "runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"),
      "runtime/router-plugin": path.join(packageRoots.core, "gateway", "core-runtime", "router-plugin.ts"),
      "runtime/route-script-worker": path.join(packageRoots.core, "routing", "route-script-worker.ts"),
      "runtime/upstream-header-sanitizer": path.join(packageRoots.core, "gateway", "core-runtime", "upstream-header-sanitizer.ts"),
      "runtime/toolhub-mcp": path.join(packageRoots.core, "mcp", "toolhub-mcp.ts")
    },
    testDirs: [
      {
        dir: path.join(projectRoot, "packages", "core", "test")
      },
      {
        dir: path.join(ccrExtensionsRoot, "plugins", "claude-design", "test"),
        outputPrefix: "plugins/claude-design"
      }
    ]
  },
  electron: {
    testDir: path.join(projectRoot, "packages", "electron", "test")
  },
  ui: {
    testDir: path.join(projectRoot, "packages", "ui", "test")
  }
};

const args = process.argv.slice(2);
const scopeIndex = args.indexOf("--scope");
const scope = scopeIndex >= 0 ? args[scopeIndex + 1] : undefined;
if (scopeIndex >= 0 && !scope) {
  throw new Error("--scope requires a test directory name such as unit or integration");
}
const requestedProjects = new Set(args.filter((arg, index) =>
  arg !== "--scope" && (scopeIndex < 0 || index !== scopeIndex + 1)
));
const projectNames = new Set(Object.keys(testProjects));
const unknownProjects = [...requestedProjects].filter((project) => !projectNames.has(project));
const selectedProjects = requestedProjects.size === 0
  ? Object.entries(testProjects)
  : Object.entries(testProjects).filter(([name]) => requestedProjects.has(name));

if (unknownProjects.length > 0) {
  throw new Error(`Unknown test project: ${unknownProjects.join(", ")}`);
}

for (const [name, project] of selectedProjects) {
  const projectOutDir = path.join(testsOutDir, name);
  const entryPoints = {};
  for (const testRoot of projectTestRoots(project)) {
    const scopedTestDir = scope ? path.join(testRoot.dir, scope) : testRoot.dir;
    Object.assign(entryPoints, testEntryPoints(scopedTestDir, testRoot.outputPrefix));
  }
  if (!scope || scope === "integration" || scope === "unit") {
    Object.assign(entryPoints, project.runtimeEntryPoints ?? {});
  }

  rmSync(projectOutDir, { force: true, recursive: true });
  if (Object.keys(entryPoints).length === 0) {
    continue;
  }

  await esbuild.build({
    absWorkingDir: projectRoot,
    bundle: true,
    entryPoints,
    external: [
      "better-sqlite3",
      "electron"
    ],
    format: "cjs",
    jsx: "automatic",
    legalComments: "none",
    loader: {
      ".gif": "file",
      ".ico": "file",
      ".jpg": "file",
      ".jpeg": "file",
      ".png": "file",
      ".svg": "file",
      ".webp": "file"
    },
    logLevel: "info",
    outdir: projectOutDir,
    platform: "node",
    plugins: [rendererAliasPlugin(), packageAliasPlugin()],
    target: "node22"
  });
}

function projectTestRoots(project) {
  if (Array.isArray(project.testDirs)) {
    return project.testDirs.map((entry) => ({
      dir: entry.dir,
      outputPrefix: entry.outputPrefix || ""
    }));
  }
  return [{
    dir: project.testDir,
    outputPrefix: ""
  }];
}

function testEntryPoints(testDir, outputPrefix = "") {
  const prefix = outputPrefix ? `${outputPrefix.replace(/^\/+|\/+$/g, "")}/` : "";
  return Object.fromEntries(findTestFiles(testDir).map((file) => {
    const relative = path.relative(testDir, file).replace(/\\/g, "/");
    const outputName = `test/${prefix}${relative.replace(/\.(mjs|ts|tsx)$/, "")}`;
    return [outputName, file];
  }));
}

function findTestFiles(dir) {
  if (!existsSync(dir)) {
    return [];
  }

  const files = [];
  for (const name of readdirSync(dir)) {
    const file = path.join(dir, name);
    const stat = statSync(file);
    if (stat.isDirectory()) {
      files.push(...findTestFiles(file));
    } else if (/\.(test|spec)\.(mjs|ts|tsx)$/.test(name)) {
      files.push(file);
    }
  }
  return files.sort();
}

function rendererAliasPlugin() {
  return {
    name: "renderer-test-alias",
    setup(build) {
      build.onResolve({ filter: /^@\// }, (args) => {
        return { path: resolvePackageImport(packageRoots.ui, args.path.slice(2)) };
      });
    }
  };
}

function packageAliasPlugin() {
  return {
    name: "test-package-alias",
    setup(build) {
      build.onResolve({ filter: /^@ccr\/(cli|core|electron|ui)\// }, (args) => {
        const match = args.path.match(/^@ccr\/(cli|core|electron|ui)\/(.+)$/);
        if (!match) {
          return undefined;
        }
        return { path: resolvePackageImport(packageRoots[match[1]], match[2]) };
      });
    }
  };
}

function resolvePackageImport(rootDir, importPath) {
  const basePath = path.resolve(rootDir, importPath);
  const candidates = [
    basePath,
    `${basePath}.tsx`,
    `${basePath}.ts`,
    `${basePath}.jsx`,
    `${basePath}.js`,
    `${basePath}.json`,
    path.join(basePath, "index.tsx"),
    path.join(basePath, "index.ts"),
    path.join(basePath, "index.jsx"),
    path.join(basePath, "index.js")
  ];

  for (const candidate of candidates) {
    if (existsSync(candidate) && statSync(candidate).isFile()) {
      return candidate;
    }
  }

  return basePath;
}

```

## /build/verify-macos-notarization.cjs

```cjs path="/build/verify-macos-notarization.cjs" 
const { execFileSync } = require("node:child_process");
const path = require("node:path");

function run(command, args) {
  execFileSync(command, args, { stdio: "inherit" });
}

module.exports = async function verifyMacosNotarization(context) {
  if (context.electronPlatformName !== "darwin") {
    return;
  }

  if (process.env.CCR_SKIP_MAC_NOTARIZATION_VERIFY === "1") {
    console.warn("Skipping macOS notarization verification because CCR_SKIP_MAC_NOTARIZATION_VERIFY=1.");
    return;
  }

  const productFilename = context.packager.appInfo.productFilename;
  const appPath = path.join(context.appOutDir, `${productFilename}.app`);

  run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath]);
  run("xcrun", ["stapler", "validate", appPath]);
  run("spctl", ["--assess", "--type", "execute", "--verbose=4", appPath]);
};

```

## /build/verify-packaged-app.cjs

```cjs path="/build/verify-packaged-app.cjs" 
const fs = require("node:fs");
const path = require("node:path");

const betterSqliteNativeRelativePath = path.join(
  "app.asar.unpacked",
  "node_modules",
  "better-sqlite3",
  "build",
  "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;
  const arch = normalizeArch(context?.arch);
  const appOutDir = context?.appOutDir;

  if (!platform || !appOutDir) {
    throw new Error("Packaged app verification did not receive electron-builder platform 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");

  const nativeInfo = inspectNativeModule(nativeModule);
  if (nativeInfo.platform !== platform) {
    throw new Error(
      `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` +
        `but electron-builder is packaging ${platform}/${arch}. Rebuild native dependencies for the target platform before packaging.`
    );
  }

  if (arch && !nativeArchMatches(nativeInfo, arch)) {
    throw new Error(
      `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` +
        `but electron-builder is packaging ${platform}/${arch}. Run npm run rebuild:sqlite3 on the target platform before packaging.`
    );
  }
};

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");
  }

  const appBundle = fs.readdirSync(appOutDir)
    .find((entry) => entry.endsWith(".app") && fs.statSync(path.join(appOutDir, entry)).isDirectory());
  if (!appBundle) {
    throw new Error(`Could not find a .app bundle in ${appOutDir}`);
  }
  return path.join(appOutDir, appBundle, "Contents", "Resources");
}

function assertFile(file, label) {
  let stat;
  try {
    stat = fs.statSync(file);
  } catch {
    throw new Error(`${label} is missing: ${file}`);
  }

  if (!stat.isFile() || stat.size === 0) {
    throw new Error(`${label} is empty or not a file: ${file}`);
  }
}

function inspectNativeModule(file) {
  const buffer = fs.readFileSync(file);
  if (buffer.length < 32) {
    throw new Error(`Native module is too small to identify: ${file}`);
  }

  if (buffer[0] === 0x4d && buffer[1] === 0x5a) {
    return inspectPe(buffer, file);
  }
  if (buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46) {
    return inspectElf(buffer);
  }

  const magicLe = buffer.readUInt32LE(0);
  const magicBe = buffer.readUInt32BE(0);
  if (magicLe === 0xfeedface || magicLe === 0xfeedfacf || magicLe === 0xcefaedfe || magicLe === 0xcffaedfe) {
    return inspectMachO(buffer, magicLe);
  }
  if (magicBe === 0xcafebabe || magicBe === 0xcafebabf) {
    return inspectFatMachO(buffer, magicBe);
  }

  throw new Error(`Native module has an unknown binary format: ${file}`);
}

function inspectPe(buffer, file) {
  const peOffset = buffer.readUInt32LE(0x3c);
  if (peOffset + 6 >= buffer.length || buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") {
    throw new Error(`Native module has an invalid PE header: ${file}`);
  }
  const machine = buffer.readUInt16LE(peOffset + 4);
  return {
    arch: peMachineArch(machine),
    platform: "win32"
  };
}

function inspectElf(buffer) {
  const machine = buffer.readUInt16LE(18);
  return {
    arch: elfMachineArch(machine),
    platform: "linux"
  };
}

function inspectMachO(buffer, magicLe) {
  const bigEndian = magicLe === 0xcefaedfe || magicLe === 0xcffaedfe;
  const cpuType = bigEndian ? buffer.readUInt32BE(4) : buffer.readUInt32LE(4);
  return {
    arch: machCpuArch(cpuType),
    platform: "darwin"
  };
}

function inspectFatMachO(buffer, magicBe) {
  const archs = [];
  const count = buffer.readUInt32BE(4);
  const entrySize = magicBe === 0xcafebabf ? 32 : 20;
  for (let index = 0; index < count; index += 1) {
    const offset = 8 + index * entrySize;
    if (offset + 8 > buffer.length) {
      break;
    }
    archs.push(machCpuArch(buffer.readUInt32BE(offset)));
  }
  return {
    arch: "universal",
    archs,
    platform: "darwin"
  };
}

function peMachineArch(machine) {
  if (machine === 0x8664) {
    return "x64";
  }
  if (machine === 0xaa64) {
    return "arm64";
  }
  if (machine === 0x014c) {
    return "ia32";
  }
  return `unknown-pe-${machine.toString(16)}`;
}

function elfMachineArch(machine) {
  if (machine === 0x3e) {
    return "x64";
  }
  if (machine === 0xb7) {
    return "arm64";
  }
  if (machine === 0x03) {
    return "ia32";
  }
  if (machine === 0x28) {
    return "armv7l";
  }
  return `unknown-elf-${machine.toString(16)}`;
}

function machCpuArch(cpuType) {
  if (cpuType === 0x01000007) {
    return "x64";
  }
  if (cpuType === 0x0100000c) {
    return "arm64";
  }
  if (cpuType === 0x00000007) {
    return "ia32";
  }
  return `unknown-macho-${cpuType.toString(16)}`;
}

function normalizeArch(arch) {
  if (typeof arch === "string") {
    return arch;
  }
  const electronBuilderArchNames = new Map([
    [0, "ia32"],
    [1, "x64"],
    [2, "armv7l"],
    [3, "arm64"],
    [4, "universal"]
  ]);
  return electronBuilderArchNames.get(arch);
}

function nativeArchMatches(info, arch) {
  if (info.arch === arch) {
    return true;
  }
  if (info.arch === "universal") {
    return arch === "universal" || Boolean(info.archs?.includes(arch));
  }
  return false;
}

function formatNativeInfo(info) {
  return info.arch === "universal" && info.archs?.length
    ? `${info.platform}/${info.arch} (${info.archs.join(", ")})`
    : `${info.platform}/${info.arch}`;
}

```

## /build/verify-update-metadata.cjs

```cjs path="/build/verify-update-metadata.cjs" 
const fs = require("node:fs");
const path = require("node:path");

module.exports = async function verifyUpdateMetadata(buildResult) {
  const artifactPaths = Array.isArray(buildResult?.artifactPaths) ? buildResult.artifactPaths : [];
  const outputDirs = new Set(artifactPaths.map((artifactPath) => path.dirname(artifactPath)));
  const metadataFiles = artifactPaths.filter((artifactPath) => /^latest(?:-[a-z]+)?\.ya?ml$/i.test(path.basename(artifactPath)));

  for (const outputDir of outputDirs) {
    for (const filename of fs.readdirSync(outputDir)) {
      const candidate = path.join(outputDir, filename);
      if (/^latest(?:-[a-z]+)?\.ya?ml$/i.test(filename) && !metadataFiles.includes(candidate)) {
        metadataFiles.push(candidate);
      }
    }
  }

  const errors = [];
  for (const metadataFile of metadataFiles) {
    const metadataDir = path.dirname(metadataFile);
    const metadata = fs.readFileSync(metadataFile, "utf8");
    for (const artifact of readReferencedArtifacts(metadata)) {
      if (isRemoteUrl(artifact)) {
        continue;
      }
      const artifactPath = path.resolve(metadataDir, artifact);
      if (!fs.existsSync(artifactPath)) {
        errors.push(`${path.relative(process.cwd(), metadataFile)} references missing artifact ${artifact}`);
      }
    }
  }

  if (errors.length) {
    throw new Error(`Update metadata validation failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
  }
};

function readReferencedArtifacts(metadata) {
  const values = new Set();
  for (const line of metadata.split(/\r?\n/)) {
    const match = line.match(/^\s*(?:path|url):\s*(.+?)\s*$/);
    if (!match) {
      continue;
    }
    const value = match[1].replace(/^['"]|['"]$/g, "").trim();
    if (value) {
      values.add(value);
    }
  }
  return [...values];
}

function isRemoteUrl(value) {
  return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
}

```

## /build/windows-package-preflight.mjs

```mjs path="/build/windows-package-preflight.mjs" 
const errors = [];

if (process.platform !== "win32") {
  errors.push(
    [
      "Windows app builds must run on Windows.",
      "This package includes better-sqlite3, and node-gyp cannot cross-compile its native Electron binary from macOS or Linux.",
      "Use a Windows x64 machine or the GitHub Actions Windows release job."
    ].join(" ")
  );
}

if (process.arch !== "x64") {
  errors.push(
    `Windows packaging is configured for x64 NSIS artifacts, but this Node.js process is ${process.arch}. Run the build with x64 Node.js on Windows.`
  );
}

if (errors.length > 0) {
  console.error("Windows packaging preflight failed:");
  for (const error of errors) {
    console.error(`- ${error}`);
  }
  process.exit(1);
}

console.log("Windows packaging preflight passed.");

```

## /cdn/README.md

# CCR CDN

This directory contains the static assets for the embeddable CCR provider import button.

Default Cloudflare Pages project:

```text
claude-code-router-cdn
```

Default CDN URLs:

```text
https://cdn.ccrdesk.top/ccr-provider-buttons.js
https://cdn.ccrdesk.top/ccr-icon.png
```

Cloudflare Pages custom domain:

```text
cdn.ccrdesk.top
```

The Pages custom domain must have a CNAME record pointing to the Pages project:

```text
cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev
```

The documentation site uses `ccrdesk.top` through GitHub Pages. Configure DNS like this:

```text
ccrdesk.top A 185.199.108.153
ccrdesk.top A 185.199.109.153
ccrdesk.top A 185.199.110.153
ccrdesk.top A 185.199.111.153
ccrdesk.top AAAA 2606:50c0:8000::153
ccrdesk.top AAAA 2606:50c0:8001::153
ccrdesk.top AAAA 2606:50c0:8002::153
ccrdesk.top AAAA 2606:50c0:8003::153
cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev
```

If `ccrdesk.top` is delegated to Cloudflare, use these Cloudflare nameservers at the registrar:

```text
benedict.ns.cloudflare.com
evangeline.ns.cloudflare.com
```

Deploy manually:

```sh
cd cdn
npx wrangler pages deploy public --project-name=claude-code-router-cdn
```

The GitHub Actions workflow requires these repository secrets:

```text
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_ID
```


## /cdn/package.json

```json path="/cdn/package.json" 
{
  "name": "claude-code-router-cdn",
  "private": true,
  "version": "0.1.0",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "deploy": "wrangler pages deploy public --project-name=claude-code-router-cdn",
    "deploy:preview": "wrangler pages deploy public --project-name=claude-code-router-cdn --branch=preview"
  }
}

```

## /cdn/public/_headers

``` path="/cdn/public/_headers" 
/*
  Access-Control-Allow-Origin: *
  X-Content-Type-Options: nosniff

/ccr-provider-buttons.js
  Cache-Control: public, max-age=300, s-maxage=86400
  Content-Type: application/javascript; charset=utf-8

/ccr-icon.png
  Cache-Control: public, max-age=31536000, immutable

```

## /cdn/public/ccr-icon.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/cdn/public/ccr-icon.png

## /cdn/public/ccr-provider-buttons.js

```js path="/cdn/public/ccr-provider-buttons.js" 
(function () {
  "use strict";

  var VERSION = "0.2.0";
  var STYLE_ID = "ccr-provider-buttons-style";
  var OFFICIAL_CCR_ICON_URL = "https://cdn.ccrdesk.top/ccr-icon.png";

  var LINK_PARAMS = [
    "name",
    "base_url",
    "api_key",
    "protocol",
    "icon",
    "source",
    "payload",
    "usage_url",
    "usage_method",
    "usage_headers",
    "usage_body",
    "balance",
    "balance_unit",
    "subscription",
    "subscription_limit",
    "subscription_reset",
    "subscription_unit",
    "subscription_window"
  ];
  var ELEMENT_ATTRIBUTES = LINK_PARAMS.concat(["manifest", "models", "fetch_usage", "description", "color", "color_2", "color_3"]);

  var CSS = [
    ".ccr-provider-button{--ccrpb-brand:#17201c;--ccrpb-brand-2:#36624d;--ccrpb-brand-3:#dff8eb;position:relative;isolation:isolate;display:grid;grid-template-columns:132px minmax(0,1fr);align-items:center;min-height:86px;width:100%;max-width:420px;padding:13px 16px 13px 12px;overflow:hidden;border:1px solid color-mix(in srgb,var(--ccrpb-brand-2) 42%,transparent);border-radius:12px;background:radial-gradient(circle at 14% 22%,color-mix(in srgb,var(--ccrpb-brand-3) 34%,transparent) 0,transparent 30%),radial-gradient(circle at 96% 96%,color-mix(in srgb,var(--ccrpb-brand-2) 44%,transparent) 0,transparent 46%),linear-gradient(135deg,var(--ccrpb-brand),color-mix(in srgb,var(--ccrpb-brand-2) 88%,#111 12%));box-shadow:0 10px 26px color-mix(in srgb,var(--ccrpb-brand) 22%,transparent),inset 0 1px 0 rgba(255,255,255,.16);box-sizing:border-box;color:#fff;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;text-decoration:none;transform:translateY(0);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}",
    ".ccr-provider-button::before{content:\"\";position:absolute;inset:-40% auto -40% -70%;z-index:-1;width:58%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.26),transparent);transform:skewX(-18deg);transition:transform 620ms ease;}",
    ".ccr-provider-button::after{content:\"\";position:absolute;right:-38px;bottom:-52px;z-index:-2;width:132px;height:132px;border-radius:999px;background:radial-gradient(circle,color-mix(in srgb,var(--ccrpb-brand-3) 38%,transparent),transparent 68%);opacity:.82;transform:scale(.96);transition:opacity 220ms ease,transform 220ms ease;}",
    ".ccr-provider-button:hover,.ccr-provider-button:focus-visible{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 68%,rgba(255,255,255,.34));box-shadow:0 16px 34px color-mix(in srgb,var(--ccrpb-brand) 30%,transparent),0 0 0 1px color-mix(in srgb,var(--ccrpb-brand-3) 26%,transparent),inset 0 1px 0 rgba(255,255,255,.22);color:#fff;text-decoration:none;transform:translateY(-3px);}",
    ".ccr-provider-button:hover::before,.ccr-provider-button:focus-visible::before{transform:translateX(320%) skewX(-18deg);}",
    ".ccr-provider-button:hover::after,.ccr-provider-button:focus-visible::after{opacity:1;transform:scale(1.08);}",
    ".ccr-provider-button:active{transform:translateY(-1px) scale(.992);}",
    ".ccrpb-provider-icon{position:relative;z-index:1;grid-column:1;grid-row:1;justify-self:start;display:grid;place-items:center;width:48px;height:48px;overflow:hidden;border:1px solid rgba(255,255,255,.46);border-radius:12px;background:linear-gradient(135deg,color-mix(in srgb,var(--ccrpb-brand-3) 34%,rgba(255,255,255,.9)),color-mix(in srgb,var(--ccrpb-brand-2) 64%,#111 36%));box-shadow:0 8px 18px rgba(0,0,0,.14),inset 0 1px 0 rgba(255,255,255,.42);color:#fff;font-size:15px;font-weight:820;letter-spacing:0;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.34);transform:rotate(0) scale(1);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}",
    ".ccrpb-provider-icon img{box-sizing:border-box;display:block;width:100%;height:100%;background:color-mix(in srgb,#fff 84%,var(--ccrpb-brand-3));object-fit:contain;filter:drop-shadow(0 1px 1px rgba(0,0,0,.12));}",
    ".ccrpb-provider-mark{display:block;max-width:42px;overflow:hidden;text-align:center;text-overflow:ellipsis;white-space:nowrap;}",
    ".ccrpb-flow{display:inline-flex;grid-column:1;grid-row:1;align-items:center;justify-self:end;justify-content:flex-end;gap:8px;width:72px;height:48px;padding:0;border:0;background:transparent;color:rgba(255,255,255,.86);font-size:14px;font-weight:760;line-height:1;box-shadow:none;transform:translateX(0);transition:transform 220ms ease;}",
    ".ccrpb-ccr-icon{box-sizing:border-box;display:grid;flex:0 0 auto;place-items:center;width:48px;height:48px;overflow:hidden;border:0;border-radius:12px;background:transparent;box-shadow:none;transition:transform 220ms ease;}",
    ".ccrpb-ccr-icon img{box-sizing:border-box;display:block;width:48px;height:48px;border-radius:12px;}",
    ".ccrpb-arrow{display:inline-block;order:-1;line-height:1;transform:translateX(0);transition:transform 220ms ease;}",
    ".ccrpb-copy{display:grid;min-width:0;gap:2px;padding:0 0 0 6px;}",
    ".ccrpb-name{display:block;overflow:hidden;color:#fff;font-size:15px;font-weight:780;line-height:1.25;text-overflow:ellipsis;white-space:nowrap;}",
    ".ccrpb-description{display:block;overflow:hidden;color:rgba(255,255,255,.74);font-size:11px;font-weight:650;letter-spacing:0;line-height:1.35;text-overflow:ellipsis;white-space:nowrap;}",
    ".ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 74%,rgba(255,255,255,.54));box-shadow:0 12px 24px rgba(0,0,0,.18),0 0 0 4px color-mix(in srgb,var(--ccrpb-brand-3) 16%,transparent),inset 0 1px 0 rgba(255,255,255,.52);transform:rotate(-3deg) scale(1.06);}",
    ".ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow{transform:translateX(3px);}",
    ".ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon{transform:scale(1.04);}",
    ".ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:translateX(2px);}",
    "@media (max-width:520px){.ccr-provider-button{grid-template-columns:118px minmax(0,1fr);min-height:92px;padding:12px}.ccrpb-provider-icon,.ccrpb-provider-icon img,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{width:44px;height:44px}.ccrpb-provider-icon img,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{border-radius:11px}.ccrpb-flow{width:66px;justify-self:end}}",
    "@media (prefers-reduced-motion:reduce){.ccr-provider-button,.ccr-provider-button::before,.ccr-provider-button::after,.ccrpb-provider-icon,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-arrow{transition:none}.ccr-provider-button:hover,.ccr-provider-button:focus-visible,.ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon,.ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow,.ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon,.ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:none}.ccr-provider-button::before{display:none}}"
  ].join("");

  function injectStyles() {
    if (document.getElementById(STYLE_ID)) {
      return;
    }
    var style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = CSS;
    document.head.appendChild(style);
  }

  function isPresent(value) {
    return value !== undefined && value !== null && String(value).trim() !== "";
  }

  function stringifyValue(value) {
    if (value === true) {
      return "1";
    }
    if (value === false) {
      return "0";
    }
    if (typeof value === "object") {
      return JSON.stringify(value);
    }
    return String(value);
  }

  function appendParam(params, key, value) {
    if (!isPresent(value)) {
      return;
    }
    if (Array.isArray(value)) {
      value.forEach(function (item) {
        if (isPresent(item)) {
          params.append(key, stringifyValue(item));
        }
      });
      return;
    }
    params.append(key, stringifyValue(value));
  }

  function providerUrl(options) {
    options = options || {};
    var params = new URLSearchParams();
    if (isPresent(options.manifest)) {
      params.set("manifest", stringifyValue(options.manifest));
      return "ccr://provider?" + params.toString();
    }
    LINK_PARAMS.forEach(function (key) {
      appendParam(params, key, options[key]);
    });
    appendParam(params, "models", options.models);
    appendParam(params, "fetch_usage", options.fetch_usage);
    return "ccr://provider?" + params.toString();
  }

  function createImage(src, alt) {
    var img = document.createElement("img");
    img.src = src;
    img.alt = alt || "";
    img.loading = "lazy";
    img.decoding = "async";
    return img;
  }

  function providerInitials(name) {
    var words = String(name || "")
      .replace(/[^A-Za-z0-9\u4e00-\u9fff]+/g, " ")
      .trim()
      .split(/\s+/)
      .filter(Boolean);
    if (words.length === 0) {
      return "AI";
    }
    if (words.length === 1) {
      return words[0].slice(0, 2).toUpperCase();
    }
    return (words[0].slice(0, 1) + words[1].slice(0, 1)).toUpperCase();
  }

  function protocolDescription(options) {
    if (options.description) {
      return options.description;
    }
    if (options.manifest) {
      return "Remote provider manifest";
    }
    if (options.protocol) {
      return options.protocol.replace(/_/g, " ");
    }
    return "Import provider to CCR";
  }

  function providerName(options) {
    return options.name || (options.manifest ? "Provider manifest" : "Provider");
  }

  function providerId(options) {
    return providerName(options).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "provider";
  }

  function applyColors(element, options) {
    element.style.setProperty("--ccrpb-brand", options.color || "#17201c");
    element.style.setProperty("--ccrpb-brand-2", options.color_2 || "#36624d");
    element.style.setProperty("--ccrpb-brand-3", options.color_3 || "#dff8eb");
  }

  function createProviderIcon(options) {
    var shell = document.createElement("span");
    shell.className = "ccrpb-provider-icon";
    if (options.icon) {
      shell.appendChild(createImage(options.icon, providerName(options)));
      return shell;
    }
    var mark = document.createElement("span");
    mark.className = "ccrpb-provider-mark";
    mark.textContent = providerInitials(providerName(options));
    shell.appendChild(mark);
    return shell;
  }

  function createButton(options) {
    options = options || {};
    var button = document.createElement("a");
    button.className = "ccr-provider-button ccr-provider-" + providerId(options);
    button.href = providerUrl(options);
    button.dataset.ccrProviderName = providerName(options);
    button.setAttribute("aria-label", "Import " + providerName(options) + " provider to CCR");
    applyColors(button, options);

    var providerIcon = createProviderIcon(options);

    var flow = document.createElement("span");
    flow.className = "ccrpb-flow";

    var ccrIconShell = document.createElement("span");
    ccrIconShell.className = "ccrpb-ccr-icon";
    ccrIconShell.appendChild(createImage(OFFICIAL_CCR_ICON_URL, "CCR"));

    var arrow = document.createElement("span");
    arrow.className = "ccrpb-arrow";
    arrow.setAttribute("aria-hidden", "true");
    arrow.textContent = "→";

    flow.appendChild(ccrIconShell);
    flow.appendChild(arrow);

    var copy = document.createElement("span");
    copy.className = "ccrpb-copy";

    var name = document.createElement("span");
    name.className = "ccrpb-name";
    name.textContent = providerName(options);

    var description = document.createElement("span");
    description.className = "ccrpb-description";
    description.textContent = protocolDescription(options);

    copy.appendChild(name);
    copy.appendChild(description);

    button.appendChild(providerIcon);
    button.appendChild(flow);
    button.appendChild(copy);
    return button;
  }

  function render(target, options) {
    options = options || {};
    var root = typeof target === "string" ? document.querySelector(target) : target;
    if (!root) {
      throw new Error("CCRProviderButtons.render target not found");
    }
    injectStyles();
    var button = createButton(options);
    if (options.clear !== false) {
      root.textContent = "";
    }
    root.appendChild(button);
    return {
      element: button,
      href: button.href,
      destroy: function () {
        if (button.parentNode) {
          button.parentNode.removeChild(button);
        }
      }
    };
  }

  function attributeOptions(element) {
    var options = {};
    ELEMENT_ATTRIBUTES.forEach(function (name) {
      if (!element.hasAttribute(name)) {
        return;
      }
      var value = element.getAttribute(name);
      options[name] = name === "fetch_usage" && value === "" ? true : value;
    });
    return options;
  }

  function defineElement(name) {
    if (!("customElements" in window) || customElements.get(name)) {
      return;
    }
    customElements.define(name, class extends HTMLElement {
      static get observedAttributes() {
        return ELEMENT_ATTRIBUTES;
      }
      connectedCallback() {
        this.render();
      }
      attributeChangedCallback() {
        if (this.isConnected) {
          this.render();
        }
      }
      render() {
        render(this, attributeOptions(this));
      }
    });
  }

  function defineElements() {
    defineElement("ccr-provider-button");
    defineElement("ccr-provider-buttons");
  }

  window.CCRProviderButtons = {
    version: VERSION,
    render: render,
    renderButton: render,
    createButton: function (options) {
      injectStyles();
      return createButton(options || {});
    },
    createProviderUrl: providerUrl
  };

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", defineElements, { once: true });
  } else {
    defineElements();
  }
}());

```

## /cdn/wrangler.toml

```toml path="/cdn/wrangler.toml" 
name = "claude-code-router-cdn"
compatibility_date = "2026-06-28"
pages_build_output_dir = "./public"

```

## /components.json

```json path="/components.json" 
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "packages/ui/src/styles/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib"
  },
  "iconLibrary": "lucide"
}

```

## /docker-compose.yml

```yml path="/docker-compose.yml" 
services:
  ccr:
    build:
      context: .
      dockerfile: Dockerfile
    image: claude-code-router:local
    ports:
      # Publish only Nginx. Internal web/gateway listeners stay inside the container.
      - "3458:8080"
    volumes:
      - ccr-data:/data
    restart: unless-stopped

volumes:
  ccr-data:

```

## /docker/README.md

# Docker Deployment

[中文说明](#中文说明) · [Project documentation](https://ccrdesk.top/en/) · [GitHub](https://github.com/musistudio/claude-code-router)

The Docker image runs the CCR core server under PM2 and serves the built management UI through Nginx. Nginx is the only public container entrypoint: the browser UI, management RPC, gateway API, and health route all share one published port.

The image is intended for a persistent gateway and browser-based administration. It does not include Electron, the npm `ccr` command, system tray features, desktop Agent/App launching, automatic desktop updates, or desktop-only browser integrations.

## Architecture And Ports

```text
host:3458 -> container Nginx:8080
                         |-> static management UI
                         |-> management RPC: 127.0.0.1:3459
                         `-> gateway:       127.0.0.1:3456
```

Only Nginx port `8080` should be published. The internal management and gateway ports are container implementation details and should not be exposed individually.

Nginx routes:

| Public route | Purpose |
| --- | --- |
| `/` and `/pages/home/index.html` | Browser management UI. `/` redirects to a URL containing the management token. |
| `/api/ccr/rpc` | Authenticated management RPC. |
| `/health` | Gateway health, not container/UI health. It can return `502` until a provider and model are configured and the gateway starts. |
| `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Supported model and MCP gateway requests. |

## Quick Start With Compose

From the repository root:

```sh
npm run docker:compose:up
docker compose logs -f ccr
```

Open <http://127.0.0.1:3458>. On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**.

The npm Compose script prepares a local `../../next-ai/gateway` checkout before building so the image uses that plugin-capable ai-gateway runtime. If you run `docker compose up -d --build` directly, run `npm run docker:prepare-gateway` first. `npm run docker:build` performs the same preparation automatically.

The repository Compose file publishes `3458:8080`, stores data in the `ccr-data` named volume, and restarts the service unless explicitly stopped. A mapping without a host IP binds on every host interface. For local-only access, change it to:

```yaml
ports:
  - "127.0.0.1:3458:8080"
```

Stop or remove the container without deleting its named volume:

```sh
docker compose stop
docker compose down
```

Do not add `--volumes` to `docker compose down` unless you intentionally want to delete all persisted CCR data.

## `docker run`

Build and run without Compose:

```sh
npm run docker:build
docker run -d \
  --name claude-code-router \
  --restart unless-stopped \
  -p 127.0.0.1:3458:8080 \
  -e CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458 \
  -v ccr-data:/data \
  claude-code-router:local
```

The temporary run script is also available:

```sh
npm run docker:run
```

`npm run docker:run` uses port `3458` and the `ccr-data` volume, but runs an ephemeral container without a fixed name or restart policy.

## Authentication And Network Security

There are two independent authentication layers:

1. `CCR_WEB_AUTH_TOKEN` protects management RPC. Nginx puts it into the management-page URL, and the browser sends it to RPC as `x-ccr-web-auth`.
2. CCR client API keys created in the **API Keys** page protect model gateway requests. These are separate from upstream provider credentials.

If `CCR_WEB_AUTH_TOKEN` is unset, the entrypoint generates a new random token on each container start. Opening `/` still works because Nginx redirects to a tokenized URL, but a stable token is recommended for persistent or remote deployments.

Avoid putting the token directly in shell history. Create a protected environment file instead:

```dotenv
CCR_WEB_AUTH_TOKEN=replace-with-a-long-random-value
CCR_PUBLIC_BASE_URL=http://127.0.0.1:3458
```

Then use it with `docker run --env-file` or map the same variables under the Compose service's `environment` section. Keep this file out of version control.

Security guidance:

- Bind the published port to `127.0.0.1` unless LAN or remote access is intentional.
- Never expose the management UI over untrusted networks without TLS, a firewall/private network, and a fixed strong management token.
- Treat tokenized management URLs as secrets; URLs may be recorded in browser history, proxy logs, screenshots, and support tickets.
- Create scoped CCR client API keys before exposing gateway routes. Do not reuse upstream provider credentials as client keys.
- Protect `/data` and its backups because they contain configuration, provider credentials, CCR client keys, request data, and generated certificates.

## Changing The Public Address

The host-facing URL is separate from the container's internal ports. Whenever the host port, hostname, or scheme changes, set `CCR_PUBLIC_BASE_URL` to the exact URL clients should use:

```yaml
services:
  ccr:
    ports:
      - "127.0.0.1:8088:8080"
    environment:
      CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
      CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
```

`CCR_PUBLIC_BASE_URL` is written to CCR's public router endpoint. It does not publish a Docker port by itself.

For a reverse proxy or ingress that terminates HTTPS:

```yaml
services:
  ccr:
    ports:
      - "127.0.0.1:3458:8080"
    environment:
      CCR_PUBLIC_BASE_URL: https://ccr.example.com
      CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
```

Proxy all paths to Nginx and preserve streaming. The external proxy should allow long-lived responses and should not buffer SSE/model streams. Keep the host port private when the reverse proxy is the public entrypoint.

## Persistent Data

The entrypoint sets `HOME=/data`, so CCR stores files under:

```text
/data/.claude-code-router/
├── config.sqlite
├── gateway.config.json
├── app-data/
│   ├── api-keys.sqlite
│   ├── request-logs.sqlite
│   ├── usage.sqlite
│   └── certs/
├── profiles/
└── bin/
```

Use a named volume unless a bind mount is operationally required. Bind mounts must be writable by the container and should not be shared by two running CCR containers.

The first-run bootstrap writes a minimal legacy `config.json` only when neither `config.json` nor `config.sqlite` exists. When the UI saves current settings, SQLite becomes authoritative. By default, every container start also synchronizes the stored gateway listener and `routerEndpoint` to the Docker public endpoint.

## Backup And Restore

The safest application-level backup is **Settings → Export data**. For a full volume backup, stop writes before copying the data directory:

```sh
docker compose stop ccr
docker compose cp ccr:/data/. ./ccr-data-backup/
docker compose start ccr
```

Keep the backup private. It contains secrets and may include request/response data.

For a full restore, use a new empty volume or empty `/data` directory, copy the backup contents into it while the CCR container is stopped, then start the container. Do not overlay an old backup onto a populated live volume: stale SQLite WAL/SHM files and newer runtime files can produce an inconsistent result. Make a second backup before replacing existing data.

## Upgrade And Rollback

Back up `/data`, update the source revision, rebuild with fresh base layers, and recreate the service:

```sh
git pull
docker compose build --pull
docker compose up -d
docker compose ps
docker compose logs --tail=200 ccr
```

Configuration migrations run against the persistent data. To roll back, use the previous image/source revision together with a backup created before the upgrade; do not assume a newer database can always be read by an older build.

## Environment Variables

Most deployments should set only `CCR_WEB_AUTH_TOKEN`, `CCR_PUBLIC_BASE_URL`, and the Docker port mapping. Internal listener values normally should remain unchanged.

| Variable | Default | Description |
| --- | --- | --- |
| `CCR_WEB_AUTH_TOKEN` | Random per container start | Management UI/RPC token. Set a stable strong value for persistent or remote use. |
| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Exact public gateway/UI base URL written into CCR configuration. Overrides `CCR_PUBLIC_HOST` and `CCR_PUBLIC_PORT`. |
| `CCR_PUBLIC_HOST` | `127.0.0.1` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset; it does not change Docker port publishing. |
| `CCR_PUBLIC_PORT` | `3458` | Used only to derive `CCR_PUBLIC_BASE_URL` when the full URL is unset. |
| `CCR_DATA_DIR` | `/data` | Container data root and process `HOME`. Mount persistent storage here. |
| `CCR_NGINX_PORT` | `8080` | Container-private Nginx listen port. Match the container side of the published mapping if changed. |
| `CCR_WEB_HOST` | `127.0.0.1` | Container-private management server host. |
| `CCR_WEB_PORT` | `3459` | Container-private management server port. |
| `CCR_GATEWAY_HOST` | `127.0.0.1` | Container-private gateway listener host. |
| `CCR_GATEWAY_PORT` | `3456` | Container-private gateway listener port used by Nginx. |
| `CCR_NO_GATEWAY` | `0` | Set to `1`, `true`, or `yes` to run the management UI without starting the gateway at boot. |
| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable minimal first-run `config.json` bootstrap. |
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Set to `0` to stop startup from syncing existing JSON/SQLite listener and public endpoint fields to Docker values. |

Changing internal ports requires corresponding Nginx/PM2 variables and offers no benefit in normal deployments. Publish only `CCR_NGINX_PORT`.

## Build Options And Smoke Test

The Dockerfile builds native dependencies with `node:22-bookworm`, then copies production dependencies and built assets into `node:22-bookworm-slim`. Override the base images when required:

```sh
docker build \
  --build-arg NODE_IMAGE=node:22-bookworm \
  --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
  -t claude-code-router:local .
```

Run the isolated Docker smoke test:

```sh
npm run test:docker
```

The test builds the image, starts a temporary container and volume, verifies that only Nginx is published, checks UI/RPC authentication, tests public-endpoint migration, starts a configured gateway, checks `/health`, and removes its resources. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an existing image or `CCR_DOCKER_TEST_IMAGE` to test a different local tag.

## Operations And Troubleshooting

Useful commands:

```sh
docker compose ps
docker compose logs -f ccr
docker compose restart ccr
docker compose config
```

### `/` returns `302`

This is expected. Nginx redirects the root URL to the management page and URL-encodes the management token.

### `/health` returns `502`

`/health` checks the model gateway, not Nginx or the management UI. On a fresh volume it returns `502` until a provider/model exists and the gateway has started; after startup, the plugin-capable ai-gateway runtime returns `200` with status `ok`. Use `docker compose ps` for container health and open the UI to configure/start the gateway.

### The UI returns `401` after a token change

Open the bare root URL again so Nginx creates a URL with the current token. Close stale tabs and avoid bookmarks that contain an old `ccr_web_token`.

### Clients still use the old port or hostname

Update `CCR_PUBLIC_BASE_URL` and recreate the container. Leave `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT=1` so existing SQLite configuration is synchronized at startup.

### Configuration disappears after recreation

Confirm that `/data` is mounted and that the same named volume or bind-mount path is being reused. `docker compose down` keeps named volumes; `docker compose down --volumes` deletes them.

### A bind mount fails with permission errors

Verify that the host directory exists, is writable by the container, and is not mounted read-only. Named volumes avoid most host ownership and labeling issues.

### The container is healthy but model requests fail

Container health only verifies Nginx/UI reachability. Check **Server** status, provider connectivity, CCR client-key authentication, routing, and request logs. Then inspect `docker compose logs --tail=200 ccr` for startup or runtime errors.

---

## 中文说明

Docker 镜像通过 PM2 运行 CCR Core,并由 Nginx 同时提供管理 UI、管理 RPC、模型网关和健康检查。对外只应发布 Nginx 的容器端口 `8080`;`3459` 和 `3456` 是容器内部实现端口,不应单独暴露。

这个镜像面向常驻网关和浏览器管理,不包含 Electron、npm 的 `ccr` 命令、系统托盘、桌面 Agent/App 启动、桌面自动更新和桌面专属浏览器集成。

### 快速启动

```sh
npm run docker:compose:up
docker compose logs -f ccr
```

打开 <http://127.0.0.1:3458>。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。

这个 npm Compose 脚本会在构建前准备本机的 `../../next-ai/gateway` checkout,让镜像使用支持插件的 ai-gateway runtime。如果直接运行 `docker compose up -d --build`,请先执行 `npm run docker:prepare-gateway`。`npm run docker:build` 也会自动打包这份本地 ai-gateway。

仓库默认映射是 `3458:8080`,会监听宿主机所有网卡。如果只允许本机访问,请改为:

```yaml
ports:
  - "127.0.0.1:3458:8080"
```

### 鉴权与远程访问

- `CCR_WEB_AUTH_TOKEN` 用于管理 UI / RPC;不设置时,每次容器启动都会生成新的随机 Token。
- **API 密钥** 页面创建的 CCR 客户端 Key 用于模型网关请求。
- 上游供应商凭据是第三类凭据,不应拿来代替 CCR 客户端 Key。

根路径会重定向到包含 `ccr_web_token` 的管理 URL。请把该 URL 当作密码。远程部署至少应使用固定强 Token、TLS、主机防火墙或私网,并让反向代理把全部路径转发到 Nginx。流式响应和 SSE 不应被代理缓冲。

外部端口、域名或协议变化时,必须同步设置公开地址:

```yaml
environment:
  CCR_PUBLIC_BASE_URL: https://ccr.example.com
  CCR_WEB_AUTH_TOKEN: ${CCR_WEB_AUTH_TOKEN:?set CCR_WEB_AUTH_TOKEN}
```

`CCR_PUBLIC_BASE_URL` 只负责写入客户端应使用的公开地址,不会自动发布 Docker 端口。

### 数据、备份与升级

数据实际位于 `/data/.claude-code-router/`,其中包括 `config.sqlite`、`app-data/`、Agent 配置和生成文件。优先使用命名卷,不要让两个运行中的 CCR 容器共享同一个数据目录。

完整文件备份前先停止写入:

```sh
docker compose stop ccr
docker compose cp ccr:/data/. ./ccr-data-backup/
docker compose start ccr
```

备份包含密钥和请求数据,必须按敏感数据保存。恢复时应复制到新的空卷或空 `/data`,不要把旧备份覆盖到仍有新数据的目录。升级前先备份,然后执行:

```sh
git pull
docker compose build --pull
docker compose up -d
docker compose ps
docker compose logs --tail=200 ccr
```

### 常见排查

- `/` 返回 `302`:正常,Nginx 正在跳转到带管理 Token 的页面。
- `/health` 返回 `502`:它检查的是模型网关;首次启动尚未配置模型时属于预期行为。
- 修改 Token 后 UI 返回 `401`:重新打开不带参数的根地址,关闭仍使用旧 Token 的标签页。
- 重建后配置消失:检查是否仍挂载同一个 `/data` 卷;`docker compose down --volumes` 会删除数据卷。
- 容器健康但模型请求失败:继续检查服务状态、供应商连通性、CCR 客户端 Key、路由和请求日志;容器健康只表示 Nginx / UI 可访问。

完整的环境变量、端口拓扑、远程部署、构建参数和烟雾测试说明见本页英文主体,对应变量名和命令在中英文环境中完全相同。


## /docker/entrypoint.sh

```sh path="/docker/entrypoint.sh" 
#!/bin/sh
set -eu

CCR_DATA_DIR="${CCR_DATA_DIR:-/data}"
CCR_WEB_HOST="${CCR_WEB_HOST:-127.0.0.1}"
CCR_WEB_PORT="${CCR_WEB_PORT:-3459}"
CCR_NGINX_PORT="${CCR_NGINX_PORT:-8080}"
CCR_GATEWAY_HOST="${CCR_GATEWAY_HOST:-127.0.0.1}"
CCR_GATEWAY_PORT="${CCR_GATEWAY_PORT:-3456}"
CCR_PUBLIC_HOST="${CCR_PUBLIC_HOST:-127.0.0.1}"
CCR_PUBLIC_PORT="${CCR_PUBLIC_PORT:-3458}"
CCR_PUBLIC_BASE_URL="${CCR_PUBLIC_BASE_URL:-http://${CCR_PUBLIC_HOST}:${CCR_PUBLIC_PORT}}"
CCR_NO_GATEWAY="${CCR_NO_GATEWAY:-0}"

if [ -z "${CCR_WEB_AUTH_TOKEN:-}" ]; then
  CCR_WEB_AUTH_TOKEN="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")"
fi
CCR_WEB_AUTH_TOKEN_QUERY="$(node -e "process.stdout.write(encodeURIComponent(process.argv[1] || ''))" "${CCR_WEB_AUTH_TOKEN}")"

export HOME="${CCR_DATA_DIR}"
export CCR_DATA_DIR
export CCR_GATEWAY_HOST
export CCR_GATEWAY_PORT
export CCR_NGINX_PORT
export CCR_NO_GATEWAY
export CCR_PUBLIC_BASE_URL
export CCR_PUBLIC_HOST
export CCR_PUBLIC_PORT
export CCR_WEB_AUTH_TOKEN
export CCR_WEB_AUTH_TOKEN_QUERY
export CCR_WEB_HOST
export CCR_WEB_PORT

CONFIG_DIR="${HOME}/.claude-code-router"
CONFIG_FILE="${CONFIG_DIR}/config.json"
APP_CONFIG_DB_FILE="${CONFIG_DIR}/config.sqlite"

mkdir -p "${CONFIG_DIR}" "${CONFIG_DIR}/app-data" /run/nginx /var/lib/nginx /var/log/nginx

if [ "${CCR_DOCKER_INIT_CONFIG:-1}" != "0" ] && [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${APP_CONFIG_DB_FILE}" ]; then
  node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");

const configDir = path.join(process.env.HOME, ".claude-code-router");
const configFile = path.join(configDir, "config.json");
const gatewayHost = process.env.CCR_GATEWAY_HOST || "0.0.0.0";
const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456");
const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, "");

fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(configFile, `${JSON.stringify({
  HOST: gatewayHost,
  PORT: gatewayPort,
  gateway: {
    enabled: true,
    host: gatewayHost,
    port: gatewayPort
  },
  routerEndpoint: publicBaseUrl
}, null, 2)}\n`, { mode: 0o600 });
NODE
fi

if [ "${CCR_DOCKER_SYNC_PUBLIC_ENDPOINT:-1}" != "0" ]; then
  node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");

const configDir = path.join(process.env.HOME, ".claude-code-router");
const configFile = path.join(configDir, "config.json");
const appConfigDbFile = path.join(configDir, "config.sqlite");
const gatewayHost = process.env.CCR_GATEWAY_HOST || "127.0.0.1";
const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456");
const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, "");

function syncConfig(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return value;
  }
  value.HOST = gatewayHost;
  value.PORT = gatewayPort;
  const existingGateway = value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway)
    ? value.gateway
    : {};
  const { coreHost: _coreHost, corePort: _corePort, ...publicGateway } = existingGateway;
  value.gateway = {
    ...publicGateway,
    enabled: true,
    host: gatewayHost,
    port: gatewayPort
  };
  value.routerEndpoint = publicBaseUrl;
  return value;
}

function syncJsonFile() {
  if (!fs.existsSync(configFile)) {
    return;
  }
  const parsed = JSON.parse(fs.readFileSync(configFile, "utf8"));
  fs.writeFileSync(configFile, `${JSON.stringify(syncConfig(parsed), null, 2)}\n`, { mode: 0o600 });
}

function syncSqliteConfig() {
  if (!fs.existsSync(appConfigDbFile)) {
    return;
  }
  let Database;
  try {
    Database = require("better-sqlite3");
  } catch {
    return;
  }
  const db = new Database(appConfigDbFile);
  try {
    const row = db.prepare("select value_json from app_config where key = ?").get("default");
    if (!row?.value_json) {
      return;
    }
    const parsed = JSON.parse(row.value_json);
    db.prepare("update app_config set value_json = ?, updated_at = ? where key = ?")
      .run(JSON.stringify(syncConfig(parsed)), new Date().toISOString(), "default");
  } finally {
    db.close();
  }
}

syncJsonFile();
syncSqliteConfig();
NODE
fi

cat > /etc/nginx/conf.d/default.conf <<EOF
server {
  listen ${CCR_NGINX_PORT};
  server_name _;
  root /usr/share/nginx/html;
  index pages/home/index.html;
  absolute_redirect off;

  client_max_body_size 8m;

  location = / {
    return 302 /pages/home/index.html?ccr_web_token=${CCR_WEB_AUTH_TOKEN_QUERY};
  }

  location = /pages/home/index.html {
    if (\$arg_ccr_web_token = "") {
      return 302 /pages/home/index.html?ccr_web_token=${CCR_WEB_AUTH_TOKEN_QUERY};
    }
    try_files /pages/home/index.html =404;
  }

  location = /api/ccr/rpc {
    proxy_http_version 1.1;
    proxy_set_header Host ${CCR_WEB_HOST}:${CCR_WEB_PORT};
    proxy_set_header Origin http://${CCR_WEB_HOST}:${CCR_WEB_PORT};
    proxy_set_header Referer http://${CCR_WEB_HOST}:${CCR_WEB_PORT}/;
    proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host \$host;
    proxy_set_header X-Forwarded-Proto \$scheme;
    proxy_pass http://${CCR_WEB_HOST}:${CCR_WEB_PORT};
  }

  location = /health {
    proxy_http_version 1.1;
    proxy_set_header Host ${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
    proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host \$host;
    proxy_set_header X-Forwarded-Proto \$scheme;
    proxy_pass http://${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
  }

  location ~ ^/(v1|v1beta|mcp|messages|chat/completions|responses|interactions)(/|$) {
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_request_buffering off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_set_header Connection "";
    proxy_set_header Host ${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
    proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host \$host;
    proxy_set_header X-Forwarded-Proto \$scheme;
    proxy_pass http://${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
  }

  location / {
    try_files \$uri \$uri/ /pages/home/index.html;
  }
}
EOF

if [ "$#" -gt 0 ]; then
  exec "$@"
fi

if [ -x /app/node_modules/.bin/pm2-runtime ]; then
  exec /app/node_modules/.bin/pm2-runtime docker/pm2.config.cjs
fi

exec /app/packages/core/node_modules/.bin/pm2-runtime docker/pm2.config.cjs

```

## /docker/local-ai-gateway/README.md

This directory is used by `npm run docker:prepare-gateway`,
`npm run docker:build`, `npm run docker:compose:build`, and
`npm run docker:compose:up`.

Generated `.tgz` files are ignored by Git. They are copied into the Docker
build context so the image can bundle a local plugin-capable ai-gateway runtime
instead of the package-lock dependency.


## /docker/pm2.config.cjs

```cjs path="/docker/pm2.config.cjs" 
const noGateway = /^(1|true|yes)$/i.test(process.env.CCR_NO_GATEWAY || "");
const serverArgs = [
  "--host",
  process.env.CCR_WEB_HOST || "127.0.0.1",
  "--port",
  process.env.CCR_WEB_PORT || "3459",
  "--no-open"
];

if (noGateway) {
  serverArgs.push("--no-gateway");
}

module.exports = {
  apps: [
    {
      name: "ccr-server",
      script: "/app/packages/core/dist/main/server.js",
      args: serverArgs,
      cwd: "/app",
      interpreter: "node",
      env: {
        ...process.env,
        NODE_ENV: "production"
      }
    },
    {
      name: "ccr-nginx",
      script: "/usr/sbin/nginx",
      args: ["-g", "daemon off;"],
      cwd: "/app",
      interpreter: "none"
    }
  ]
};

```

## /docs/.gitignore

```gitignore path="/docs/.gitignore" 
node_modules/
dist/
.astro/
.env
.env.*

```

## /docs/README.md

# Claude Code Router Docs

Astro-powered documentation site for Claude Code Router.

## Commands

```sh
npm install
npm run dev
npm run build
npm run preview
```

The local development server runs from this `docs` directory.

## GitHub Pages

Docs are deployed from `.github/workflows/docs.yml` on pushes to `main` that change `docs/**` or the workflow itself. The default public URL is:

```text
https://ccrdesk.top/
```

The Astro build reads `ASTRO_SITE` and `ASTRO_BASE`, defaulting to `https://ccrdesk.top` and `/`.

## Content

Docs pages are authored in Markdown:

- Chinese: `src/content/docs/zh/index.md`
- English: `src/content/docs/en/index.md`

Frontmatter provides the page title, eyebrow, and lead text. Markdown headings generate the right-side table of contents, and fenced code blocks are compiled with Shiki highlighting.


## /docs/astro.config.mjs

```mjs path="/docs/astro.config.mjs" 
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";

/**
 * The reused UI package imports images as `import url from "@/assets/x.png"`
 * and expects a URL STRING (as esbuild produces). Under Astro, `astro:assets`
 * intercepts those and returns an `{ src, ... }` metadata object, which then
 * renders as `<img src="[object Object]">`. This plugin forces every image
 * imported from the UI package's assets dir to Vite's plain `?url` string,
 * WITHOUT modifying the UI source.
 */
function uiAssetsAsUrlPlugin() {
  const uiAssetsPath = new URL("./../packages/ui/src/assets", import.meta.url).pathname;
  const imageExt = /\.(png|jpe?g|svg|webp|gif|ico|avif)$/i;
  return {
    name: "ui-assets-as-url",
    enforce: "pre",
    async resolveId(source, importer, options) {
      const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
      if (!resolved) return null;
      const id = resolved.id.split("?")[0];
      if (id.startsWith(uiAssetsPath) && imageExt.test(id)) {
        return `${id}?url`;
      }
      return null;
    },
  };
}

const site = process.env.ASTRO_SITE ?? "https://ccrdesk.top";
const base = process.env.ASTRO_BASE ?? "/";

const agentClawPlatforms = [
  "slack",
  "discord",
  "telegram",
  "line",
  "weixin-ilink",
  "wecom",
  "feishu",
  "dingtalk",
];

/** Legacy URLs served as redirect stubs; excluded from the sitemap. */
const redirectPaths = new Set([
  "/configuration/",
  "/configuration/bot-relay/",
  "/configuration/bot-setup/",
  "/configuration/extensions/",
  "/configuration/fusion/",
  "/configuration/fusion-mcp-tool/",
  "/configuration/fusion-vision/",
  "/configuration/fusion-web-search/",
  "/configuration/provider-deeplink/",
  "/configuration/routing/",
  "/configuration/toolhub/",
  "/configuration/provider/",
  "/configuration/profile/",
  "/configuration/config-file/",
  "/en/configuration/",
  "/en/configuration/bot-setup/",
  "/en/configuration/bots/",
  "/en/configuration/extensions/",
  "/en/configuration/fusion-models/",
  "/en/configuration/fusion-mcp-tool/",
  "/en/configuration/fusion-vision/",
  "/en/configuration/fusion-web-search/",
  "/en/configuration/provider-deeplink/",
  "/en/configuration/routing/",
  "/en/configuration/toolhub/",
  ...agentClawPlatforms.map((platform) => `/bot-与-im-接力-agent/${platform}/`),
  ...agentClawPlatforms.map((platform) => `/en/relay-agents-in-im-with-bots/${platform}/`),
]);

const basePath = base.endsWith("/") ? base : `${base}/`;
const isRedirectPage = (page) => {
  let pathname = new URL(page).pathname;
  if (basePath !== "/" && pathname.startsWith(basePath)) {
    pathname = `/${pathname.slice(basePath.length)}`;
  }
  if (!pathname.endsWith("/")) pathname = `${pathname}/`;
  return redirectPaths.has(pathname);
};

export default defineConfig({
  site,
  base,
  output: "static",
  integrations: [
    react(),
    sitemap({ filter: (page) => !isRedirectPage(page) }),
  ],
  vite: {
    plugins: [tailwindcss(), uiAssetsAsUrlPlugin()],
    resolve: {
      alias: {
        "@": new URL("./../packages/ui/src", import.meta.url).pathname,
        "@ccr/core": new URL("./../packages/core/src", import.meta.url).pathname,
      },
    },
  },
  markdown: {
    shikiConfig: {
      themes: {
        light: "github-light",
        dark: "github-dark",
      },
      defaultColor: false,
    },
  },
});

```

## /docs/package.json

```json path="/docs/package.json" 
{
  "name": "claude-code-router-docs",
  "private": true,
  "version": "0.1.0",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "devDependencies": {
    "@astrojs/sitemap": "^3.7.3",
    "@tailwindcss/vite": "^4.3.3",
    "astro": "7.0.0",
    "lucide-astro": "^0.556.0",
    "tailwindcss": "^4.3.3"
  },
  "dependencies": {
    "@astrojs/react": "^6.0.2",
    "@types/react": "^18.3.31",
    "@types/react-dom": "^18.3.7",
    "baseui": "^16.1.1",
    "clsx": "^2.1.1",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "styletron-engine-atomic": "^1.6.2",
    "styletron-react": "^6.1.1",
    "tailwind-merge": "^3.6.0"
  }
}

```

## /docs/public/CNAME

``` path="/docs/public/CNAME" 
ccrdesk.top

```

## /docs/public/ccr-icon.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/ccr-icon.png

## /docs/public/favicon.svg

```svg path="/docs/public/favicon.svg" 
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
  <rect width="64" height="64" rx="16" fill="#0a7f48"/>
  <path d="M18 36c0-10 8-18 18-18h10v10c0 10-8 18-18 18H18V36Z" fill="#fff"/>
  <circle cx="24" cy="40" r="6" fill="#b7e4cf"/>
</svg>

```

## /docs/public/logo.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/logo.png

## /docs/public/platform-icons/linux.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/platform-icons/linux.png

## /docs/public/platform-icons/macos.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/platform-icons/macos.png

## /docs/public/platform-icons/windows.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/platform-icons/windows.png

## /docs/public/provider-icons/anthropic.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/anthropic.png

## /docs/public/provider-icons/bailian.ico

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/bailian.ico

## /docs/public/provider-icons/claudeapi.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/claudeapi.png

## /docs/public/provider-icons/code0.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/code0.png

## /docs/public/provider-icons/deepseek.ico

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/deepseek.ico

## /docs/public/provider-icons/fenno.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/fenno.jpg

## /docs/public/provider-icons/infistar-ai.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/infistar-ai.jpg

## /docs/public/provider-icons/minimax.ico

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/minimax.ico

## /docs/public/provider-icons/mistral.webp

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/mistral.webp

## /docs/public/provider-icons/moonshot.ico

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/moonshot.ico

## /docs/public/provider-icons/nvidia.svg

```svg path="/docs/public/provider-icons/nvidia.svg" 
<svg fill="#76B900" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>NVIDIA</title><path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"/></svg>

```

## /docs/public/provider-icons/openai.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/openai.png

## /docs/public/provider-icons/openrouter.ico

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/openrouter.ico

## /docs/public/provider-icons/qiniu-ai.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/qiniu-ai.png

## /docs/public/provider-icons/runapi.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/runapi.jpg

## /docs/public/provider-icons/siliconflow.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/siliconflow.png

## /docs/public/provider-icons/teamorouter.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/teamorouter.png

## /docs/public/provider-icons/unity2.jpg

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/unity2.jpg

## /docs/public/provider-icons/zhipu-cn-coding.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/zhipu-cn-coding.png

## /docs/public/provider-icons/zhipu-cn-general.png

Binary file available at https://raw.githubusercontent.com/musistudio/claude-code-router/refs/heads/main/docs/public/provider-icons/zhipu-cn-general.png

## /docs/src/agentclaw-docs.ts

```ts path="/docs/src/agentclaw-docs.ts" 
export type AgentClawDocModule = {
  Content: any;
  frontmatter: {
    title?: string;
    pageTitle?: string;
    eyebrow?: string;
    lead?: string;
    [key: string]: unknown;
  };
  getHeadings: () => { depth: number; slug: string; text: string }[];
  rawContent: () => string;
};

export const zhAgentClawDocs = import.meta.glob<AgentClawDocModule>(
  "./content/docs/zh/agentclaw/*.md",
  { eager: true }
);

export const enAgentClawDocs = import.meta.glob<AgentClawDocModule>(
  "./content/docs/en/agentclaw/*.md",
  { eager: true }
);

export const AGENTCLAW_DOC_ORDER = [
  "setup",
  "slack",
  "discord",
  "telegram",
  "line",
  "weixin-ilink",
  "wecom",
  "feishu",
  "dingtalk",
] as const;

export type AgentClawDocSlug = (typeof AGENTCLAW_DOC_ORDER)[number];

export function agentClawSlugFromPath(filePath: string): string {
  const file = filePath.split("/").pop() ?? filePath;
  return file.replace(/\.md$/, "");
}

```

## /docs/src/pages/agentclaw.astro

```astro path="/docs/src/pages/agentclaw.astro" 
---
import DocPage from "../components/DocPage.astro";
---

<DocPage locale="zh" pageKey="agentclaw" />

```


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.
Copied!