chore: import upstream snapshot with attribution
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
node_modules
|
||||
dist
|
||||
packages/*/dist
|
||||
release
|
||||
release-local
|
||||
logs
|
||||
tmp
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
docs/node_modules
|
||||
docs/dist
|
||||
docs/.astro
|
||||
@@ -0,0 +1,48 @@
|
||||
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: 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
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
name: macOS (${{ matrix.display_name }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
arch_flag: --arm64
|
||||
runner: macos-14
|
||||
display_name: Apple Silicon
|
||||
release_label: Apple-Silicon
|
||||
artifact_name: macos-apple-silicon-arm64
|
||||
- arch: x64
|
||||
arch_flag: --x64
|
||||
runner: macos-15-intel
|
||||
display_name: Intel
|
||||
release_label: Intel
|
||||
artifact_name: macos-intel-x64
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- 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: 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:assets
|
||||
npx electron-builder --win --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: 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
|
||||
@@ -0,0 +1,20 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.idea
|
||||
.claude
|
||||
.bot-gateway-state
|
||||
.agent-data
|
||||
tmp
|
||||
release-local
|
||||
logs
|
||||
.opencat
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
.tmp
|
||||
@@ -0,0 +1,75 @@
|
||||
ARG NODE_IMAGE=node:22-bookworm
|
||||
ARG RUNTIME_NODE_IMAGE=node:22-bookworm-slim
|
||||
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/cli/package.json packages/cli/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/electron/package.json packages/electron/package.json
|
||||
COPY packages/ui/package.json packages/ui/package.json
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM ${NODE_IMAGE} AS production-deps
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
RUN npm ci --omit=dev --workspace=@claude-code-router/core --include-workspace-root=false \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM ${RUNTIME_NODE_IMAGE} AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
CCR_DATA_DIR=/data \
|
||||
CCR_WEB_HOST=127.0.0.1 \
|
||||
CCR_WEB_PORT=3459 \
|
||||
CCR_NGINX_PORT=8080 \
|
||||
CCR_GATEWAY_HOST=127.0.0.1 \
|
||||
CCR_GATEWAY_PORT=3456 \
|
||||
CCR_GATEWAY_CORE_PORT=3457 \
|
||||
CCR_PUBLIC_HOST=127.0.0.1 \
|
||||
CCR_PUBLIC_PORT=3458
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 nginx \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \
|
||||
&& rm -rf \
|
||||
/opt/yarn-* \
|
||||
/usr/local/bin/corepack \
|
||||
/usr/local/bin/npm \
|
||||
/usr/local/bin/npx \
|
||||
/usr/local/bin/yarn \
|
||||
/usr/local/bin/yarnpkg \
|
||||
/usr/local/include/node \
|
||||
/usr/local/lib/node_modules/corepack \
|
||||
/usr/local/lib/node_modules/npm \
|
||||
/usr/local/share/doc \
|
||||
/usr/local/share/man
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY --from=production-deps /app/node_modules node_modules
|
||||
|
||||
COPY --from=build /app/packages/core/dist packages/core/dist
|
||||
COPY --from=build /app/packages/ui/dist/renderer /usr/share/nginx/html
|
||||
COPY docker/entrypoint.sh /usr/local/bin/ccr-docker-entrypoint
|
||||
COPY docker/pm2.config.cjs docker/pm2.config.cjs
|
||||
|
||||
RUN chmod +x /usr/local/bin/ccr-docker-entrypoint \
|
||||
&& mkdir -p /data /run/nginx /var/lib/nginx /var/log/nginx
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.CCR_NGINX_PORT || '8080') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["ccr-docker-entrypoint"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 musistudio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,392 @@
|
||||
<h1 align="center">Claude Code Router</h1>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases"><img alt="Desktop downloads" src="https://img.shields.io/github/downloads/musistudio/claude-code-router/total?label=Desktop%20downloads&logo=github" /></a>
|
||||
<a href="https://ccrdesk.top/"><img alt="Documentation" src="https://img.shields.io/badge/Docs-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
|
||||
·
|
||||
<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 K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard.
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR already supports Kimi. 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">cost-effective Coding Plan</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use.
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop screenshot" />
|
||||
</p>
|
||||
|
||||
## Why Use CCR
|
||||
|
||||
- Use one local endpoint for multiple agent tools instead of configuring every client separately.
|
||||
- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand.
|
||||
- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers.
|
||||
- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs.
|
||||
|
||||
## Features
|
||||
|
||||
- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards.
|
||||
- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available.
|
||||
- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites.
|
||||
- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles.
|
||||
- **Gateway compatibility**: translate supported client requests through the local CCR model gateway.
|
||||
- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture.
|
||||
- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model.
|
||||
|
||||
## Documentation
|
||||
|
||||
Read the full documentation at [ccrdesk.top](https://ccrdesk.top/).
|
||||
|
||||
## Download And Install
|
||||
|
||||
1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases).
|
||||
2. Download the package for your platform:
|
||||
- macOS Apple Silicon: `Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` or `.zip`
|
||||
- macOS Intel: `Claude-Code-Router_<version>-mac-Intel-x64.dmg` or `.zip`
|
||||
- Windows: `Claude Code Router_<version>.exe`
|
||||
- Linux: `Claude Code Router_<version>.AppImage`
|
||||
3. Install and launch **Claude Code Router**.
|
||||
4. On first launch, CCR creates its local configuration database:
|
||||
- macOS/Linux: `~/.claude-code-router/config.sqlite`
|
||||
- Windows: `%APPDATA%\Claude Code Router\config.sqlite`
|
||||
|
||||
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists.
|
||||
|
||||
After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status.
|
||||
|
||||
## Quick Start
|
||||
|
||||
CCR can be configured entirely from the desktop UI. Use this setup order for a clean first run.
|
||||
|
||||
### 1. Add a provider
|
||||
|
||||
Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider.
|
||||
|
||||
### 2. Configure routing
|
||||
|
||||
Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior.
|
||||
|
||||
Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets.
|
||||
|
||||
### 3. Start the gateway
|
||||
|
||||
Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens.
|
||||
|
||||
### 4. Connect your agent tool
|
||||
|
||||
Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR.
|
||||
|
||||
### 5. Monitor and adjust
|
||||
|
||||
Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl).
|
||||
|
||||
## 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&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&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&utm_medium=partner&utm_campaign=claudecoderouter_2026&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&utm_medium=partner&utm_campaign=claudecoderouter_2026&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&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>
|
||||
</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).
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`musistudio/claude-code-router`
|
||||
- 原始仓库:https://github.com/musistudio/claude-code-router
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,391 @@
|
||||
<h1 align="center">Claude Code Router</h1>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases"><img alt="桌面端下载次数" src="https://img.shields.io/github/downloads/musistudio/claude-code-router/total?label=%E6%A1%8C%E9%9D%A2%E7%AB%AF%E4%B8%8B%E8%BD%BD&logo=github" /></a>
|
||||
<a href="https://ccrdesk.top/"><img alt="文档" src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-ccrdesk.top-0ea5e9?style=flat" /></a>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<p>
|
||||
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 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">Coding Plan</a> 套餐。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。
|
||||
|
||||
<p align="center">
|
||||
<img src="blog/images/claude-code-router.png" width="720" alt="Claude Code Router Desktop 项目截图" />
|
||||
</p>
|
||||
|
||||
## 为什么使用 CCR
|
||||
|
||||
- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。
|
||||
- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。
|
||||
- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。
|
||||
|
||||
## 功能和特性
|
||||
|
||||
- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。
|
||||
- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。
|
||||
- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。
|
||||
- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。
|
||||
- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。
|
||||
- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。
|
||||
- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档见 [ccrdesk.top](https://ccrdesk.top/)。
|
||||
|
||||
## 下载和安装
|
||||
|
||||
1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。
|
||||
2. 按系统下载对应安装包:
|
||||
- macOS Apple 芯片:`Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
|
||||
- macOS Intel 芯片:`Claude-Code-Router_<version>-mac-Intel-x64.dmg` 或 `.zip`
|
||||
- Windows:`Claude Code Router_<version>.exe`
|
||||
- Linux:`Claude Code Router_<version>.AppImage`
|
||||
3. 安装并启动 **Claude Code Router**。
|
||||
4. 首次启动后,CCR 会创建本地配置数据库:
|
||||
- macOS/Linux:`~/.claude-code-router/config.sqlite`
|
||||
- Windows:`%APPDATA%\Claude Code Router\config.sqlite`
|
||||
|
||||
CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。
|
||||
|
||||
从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。
|
||||
|
||||
## 快速开始
|
||||
|
||||
CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序操作。
|
||||
|
||||
### 1. 添加 Provider
|
||||
|
||||
打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。
|
||||
|
||||
### 2. 设置路由
|
||||
|
||||
打开 **路由**,添加条件规则,配置请求改写和失败降级。
|
||||
|
||||
如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。
|
||||
|
||||
### 3. 启动网关
|
||||
|
||||
打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。
|
||||
|
||||
### 4. 连接 Agent 工具
|
||||
|
||||
打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。
|
||||
|
||||
### 5. 日常查看和调整
|
||||
|
||||
到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。
|
||||
|
||||
## 致谢
|
||||
|
||||
对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。
|
||||
|
||||
## 支持与赞助
|
||||
|
||||
<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&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&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&utm_medium=partner&utm_campaign=claudecoderouter_2026&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&utm_medium=partner&utm_campaign=claudecoderouter_2026&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&aff=9HHHAB5QLAES">
|
||||
<img src="/docs/public/provider-icons/fenno.jpg" width="42" height="42" alt="Fenno.ai 图标" />
|
||||
<br />
|
||||
<strong>Fenno.ai</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) 发布。
|
||||
@@ -0,0 +1,105 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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).
|
||||

|
||||
|
||||
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:
|
||||
> 
|
||||
> 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.
|
||||

|
||||
|
||||
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 )
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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:
|
||||

|
||||
|
||||
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:
|
||||

|
||||

|
||||
|
||||
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:
|
||||

|
||||
|
||||
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.
|
||||
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 915 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 813 KiB |
|
After Width: | Height: | Size: 353 KiB |
@@ -0,0 +1,67 @@
|
||||
<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 & 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 & 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>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 984 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 237 KiB |
@@ -0,0 +1,299 @@
|
||||
# 从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不同)。
|
||||

|
||||
由于这些脚本直接在用户本地运行,存在极大的安全风险。如果用户不能对脚本代码进行review,很容易造成数据泄露、感染病毒等严重安全性问题。相比于MCP提供一个标准化的接口,Skill提供一系列的脚本文件,不同的skill可能拥有不同类型的脚本文件,比如有些脚本使用node.js实现,有些脚本使用Python实现,要使用这些脚本还需要用户安装对应的运行时和脚本所需要的依赖。这也是我说“离经叛道”的原因所在。
|
||||
|
||||
这真的是最佳实践吗?
|
||||
|
||||
关于渐进式披露,Anthropic是这样描述的:
|
||||
> 渐进式披露是使代理技能灵活且可扩展的核心设计原则。就像一本组织良好的手册,从目录开始,然后是具体章节,最后是详细的附录一样,技能允许 Claude 仅在需要时加载信息:
|
||||
> 也过去将近两个月的时间了,其中Anthropic提到了一个术语渐进式披露(Progressive Disclosure),这到底是什么东西?解决了什么问题?
|
||||
|
||||
其实在我的Vibe Coding流程中,我很少使用MCP。因为我觉得MCP实现质量层次不齐,本质是上下文注入(工具的本质也是上下文注入),我不确定别人写的提示词会不会影响到我的工作流,干脆直接不用。现在的MCP实现基本上就是把所有的功能全都包装成工具暴露给Agent(一个功能包装成一个工具,给定详细的描述,告诉agent在什么时候进行调用,参数格式是什么),这就导致了现在的提示词爆炸。
|
||||
|
||||
直到Anthropic发布了Skills,研究了一下发现本质仍然是提示词注入。如果说MCP是提供了一套注入工具的规范,那么Skills所提倡的则是“离经叛道”。Skills给了一个Markdown文档用于描述该skill的用途和最佳用法,附带提供了一些脚本(与MCP不同)。
|
||||

|
||||
由于这些脚本直接在用户本地运行,存在极大的安全风险。如果用户不能对脚本代码进行review,很容易造成数据泄露、感染病毒等严重安全性问题。相比于MCP提供一个标准化的接口,Skill提供一系列的脚本文件,不同的skill可能拥有不同类型的脚本文件,比如有些脚本使用node.js实现,有些脚本使用Python实现,要使用这些脚本还需要用户安装对应的运行时和脚本所需要的依赖。这也是我说“离经叛道”的原因所在。
|
||||
|
||||
这真的是最佳实践吗?
|
||||
|
||||
关于渐进式披露,Anthropic是这样描述的:
|
||||
> 渐进式披露是使代理技能灵活且可扩展的核心设计原则。就像一本组织良好的手册,从目录开始,然后是具体章节,最后是详细的附录一样,技能允许 Claude 仅在需要时加载信息:
|
||||
> 
|
||||
> 拥有文件系统和代码执行工具的智能体在执行特定任务时,无需将技能的全部内容读取到上下文窗口中。这意味着技能中可以包含的上下文信息量实际上是无限的。
|
||||
|
||||
下图是使用Skill的上下文窗口变化
|
||||

|
||||
|
||||
我们真的需要这样去实现吗?
|
||||
|
||||
在我们平时使用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的上下文窗口变化
|
||||

|
||||
|
||||
我们真的需要这样去实现吗?
|
||||
|
||||
在我们平时使用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 )
|
||||
@@ -0,0 +1,95 @@
|
||||
# 或许我们能在 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`的一次小探索,或许还能做更多其他有趣的事也说不定...
|
||||
@@ -0,0 +1,96 @@
|
||||
# 项目初衷及原理
|
||||
|
||||
早在 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`功能可以重新格式化,让代码变得稍微好看一点。就像这样:
|
||||

|
||||
|
||||
现在,你可以通过阅读部分代码来了解`Claude Code`的内容工具原理与提示词。你也可以在关键地方使用`console.log`来获得更多信息,当然,也可以使用`Chrome Devtools`来进行断点调试,使用以下命令启动`Claude Code`:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS="--inspect-brk=9229" claude
|
||||
```
|
||||
|
||||
该命令会以调试模式启动`Claude Code`,并将调试的端口设置为`9229`。这时候通过 Chrome 访问`chrome://inspect/`即可看到当前的`Claude Code`进程,点击`inspect`即可进行调试。
|
||||

|
||||

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

|
||||
|
||||
到目前为止,我们获得关键信息:
|
||||
|
||||
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体验最好。
|
||||
@@ -0,0 +1,24 @@
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
copyTrayRendererHtml();
|
||||
|
||||
await Promise.all([
|
||||
buildMain({ mode }),
|
||||
buildBrowserRenderer({ mode }),
|
||||
buildRenderer({ mode }),
|
||||
buildTrayRenderer({ mode }),
|
||||
buildWebClientBridge({ mode }),
|
||||
buildStyles({ minify: mode === "production" })
|
||||
]);
|
||||
|
||||
syncUiRendererToRuntimeDists();
|
||||
|
||||
console.log(`Built monorepo package assets in ${mode} mode.`);
|
||||
@@ -0,0 +1,486 @@
|
||||
import electron from "electron";
|
||||
import esbuild from "esbuild";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildStyles,
|
||||
cleanDist,
|
||||
browserRendererHtmlInput,
|
||||
cliSourceRoot,
|
||||
coreSourceRoot,
|
||||
copyAppAssets,
|
||||
copyBrowserRendererHtml,
|
||||
copyCliRuntimeToElectronDist,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
createBrowserRendererBuildOptions,
|
||||
createCliBuildOptions,
|
||||
createMainBuildOptions,
|
||||
createRendererBuildOptions,
|
||||
createTrayRendererBuildOptions,
|
||||
createWebClientBridgeBuildOptions,
|
||||
appAssetsInput,
|
||||
modelCatalogInput,
|
||||
projectRoot,
|
||||
rendererRoot,
|
||||
rendererHtmlInput,
|
||||
syncUiRendererToRuntimeDists,
|
||||
trayRendererHtmlInput,
|
||||
watchPlugin
|
||||
} from "./esbuild.config.mjs";
|
||||
|
||||
let electronProcess = null;
|
||||
let restartTimer = null;
|
||||
let pendingRestartReasons = [];
|
||||
const watchSignatures = new Map();
|
||||
let shuttingDown = false;
|
||||
const restartDelayMs = 160;
|
||||
const styleBuildDelayMs = 160;
|
||||
const stylePollIntervalMs = 1000;
|
||||
const ignoredSignatureEntries = new Set([".DS_Store"]);
|
||||
let styleBuildTimer = null;
|
||||
let styleBuildInFlight = false;
|
||||
let queuedStyleBuildReason = null;
|
||||
const ready = {
|
||||
browser: false,
|
||||
cli: false,
|
||||
main: false,
|
||||
renderer: false,
|
||||
tray: false,
|
||||
webBridge: false
|
||||
};
|
||||
const devTarget = parseDevTarget(process.argv.slice(2));
|
||||
const enabled = {
|
||||
cli: devTarget === "cli" || devTarget === "electron",
|
||||
electron: devTarget === "electron",
|
||||
ui: true
|
||||
};
|
||||
const coreSharedSourceRoot = path.join(coreSourceRoot, "shared");
|
||||
const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot));
|
||||
const activeReadyNames = new Set([
|
||||
...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []),
|
||||
...(enabled.cli ? ["cli"] : []),
|
||||
...(enabled.electron ? ["main"] : [])
|
||||
]);
|
||||
|
||||
function parseDevTarget(args) {
|
||||
const target = args[0] ?? "electron";
|
||||
if (target === "--help" || target === "-h") {
|
||||
console.log("Usage: node build/dev.mjs [ui|cli|electron]");
|
||||
process.exit(0);
|
||||
}
|
||||
if (target === "ui" || target === "cli" || target === "electron") {
|
||||
return target;
|
||||
}
|
||||
console.error(`Unknown dev target "${target}". Expected ui, cli, or electron.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function logDev(message) {
|
||||
console.log(`[dev] ${new Date().toISOString()} ${message}`);
|
||||
}
|
||||
|
||||
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 describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) {
|
||||
const changedPath = filename
|
||||
? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename))
|
||||
: watchedPath;
|
||||
return `${label} ${eventType} ${relativePath(changedPath)}`;
|
||||
}
|
||||
|
||||
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) {
|
||||
const signature = contentSignature(targetPath);
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`);
|
||||
}
|
||||
|
||||
function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) {
|
||||
const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory);
|
||||
const signature = contentSignature(watchedPath);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
const changed = previousSignature !== signature.key;
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`);
|
||||
|
||||
if (!changed) {
|
||||
logDev(`restart skipped: ${reason} (content unchanged)`);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange();
|
||||
if (enabled.electron && options?.restart !== false) {
|
||||
scheduleRestart(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStyleBuild(reason) {
|
||||
queuedStyleBuildReason = reason;
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
styleBuildTimer = setTimeout(() => {
|
||||
styleBuildTimer = null;
|
||||
void rebuildStyles(queuedStyleBuildReason ?? reason);
|
||||
}, styleBuildDelayMs);
|
||||
}
|
||||
|
||||
async function rebuildStyles(reason) {
|
||||
if (styleBuildInFlight) {
|
||||
queuedStyleBuildReason = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
styleBuildInFlight = true;
|
||||
queuedStyleBuildReason = null;
|
||||
try {
|
||||
logDev(`rebuilding styles: ${reason}`);
|
||||
await buildStyles({ minify: false });
|
||||
syncUiRendererToRuntimeDists();
|
||||
if (enabled.electron) {
|
||||
scheduleRestart(`styles rebuilt: ${reason}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logDev(`style rebuild failed: ${message}`);
|
||||
} finally {
|
||||
styleBuildInFlight = false;
|
||||
if (queuedStyleBuildReason) {
|
||||
const queuedReason = queuedStyleBuildReason;
|
||||
queuedStyleBuildReason = null;
|
||||
scheduleStyleBuild(queuedReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pollStyleWatchRoots() {
|
||||
for (const styleWatchRoot of styleWatchRoots) {
|
||||
const label = `styles ${relativePath(styleWatchRoot)}`;
|
||||
const signature = contentSignature(styleWatchRoot);
|
||||
const previousSignature = watchSignatures.get(label);
|
||||
if (previousSignature === signature.key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
watchSignatures.set(label, signature.key);
|
||||
logDev(`watch event: ${label}; ${signature.summary}; content=changed`);
|
||||
scheduleStyleBuild(label);
|
||||
}
|
||||
}
|
||||
|
||||
function markReady(name, reason = `${name} esbuild completed`) {
|
||||
if (name === "browser" || name === "cli" || 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);
|
||||
}
|
||||
|
||||
function restartElectron() {
|
||||
const reasons = Array.from(new Set(pendingRestartReasons));
|
||||
pendingRestartReasons = [];
|
||||
restartTimer = null;
|
||||
|
||||
if (electronProcess) {
|
||||
logDev(`stopping Electron pid=${electronProcess.pid ?? "unknown"}`);
|
||||
electronProcess.kill();
|
||||
electronProcess = null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
if (enabled.cli || enabled.electron) {
|
||||
copyMarketplacePlugins();
|
||||
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);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
rememberWatchSignature("model catalog", modelCatalogInput);
|
||||
}
|
||||
|
||||
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyBrowserRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => {
|
||||
copyTrayRendererHtml();
|
||||
syncUiRendererToRuntimeDists();
|
||||
});
|
||||
});
|
||||
|
||||
const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs);
|
||||
|
||||
const appAssetsWatcher = enabled.electron
|
||||
? watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
|
||||
})
|
||||
: { close: () => undefined };
|
||||
|
||||
const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput)
|
||||
? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => {
|
||||
handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog);
|
||||
})
|
||||
: { close: () => undefined };
|
||||
|
||||
const 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 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) {
|
||||
electronProcess.kill();
|
||||
}
|
||||
if (styleBuildTimer) {
|
||||
clearTimeout(styleBuildTimer);
|
||||
}
|
||||
htmlWatcher.close();
|
||||
browserHtmlWatcher.close();
|
||||
trayHtmlWatcher.close();
|
||||
clearInterval(stylePoller);
|
||||
appAssetsWatcher.close();
|
||||
modelCatalogWatcher.close();
|
||||
await Promise.all(contexts.map((context) => context.dispose()));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
buildBrowserRenderer,
|
||||
buildCoreServer,
|
||||
buildRenderer,
|
||||
buildStyles,
|
||||
buildTrayRenderer,
|
||||
buildWebClientBridge,
|
||||
cleanDist,
|
||||
copyBrowserRendererHtml,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
syncUiRendererToRuntimeDists
|
||||
} from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
cleanDist();
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
copyTrayRendererHtml();
|
||||
|
||||
await Promise.all([
|
||||
buildCoreServer({ mode }),
|
||||
buildBrowserRenderer({ mode }),
|
||||
buildRenderer({ mode }),
|
||||
buildTrayRenderer({ mode }),
|
||||
buildWebClientBridge({ mode }),
|
||||
buildStyles({ minify: mode === "production" })
|
||||
]);
|
||||
|
||||
syncUiRendererToRuntimeDists();
|
||||
|
||||
console.log(`Built Docker core server and UI assets in ${mode} mode.`);
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,602 @@
|
||||
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 = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json"));
|
||||
export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js");
|
||||
export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js");
|
||||
export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json"));
|
||||
export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js");
|
||||
export const botGatewaySdkRunnerInput = path.join(botGatewaySdkPackageRoot, "bin", "bot-gateway-stdio.mjs");
|
||||
export const electronBotGatewaySdkRootDir = path.join(electronMainOutDir, "bot-gateway-sdk");
|
||||
export const electronBotGatewaySdkDistDir = path.join(electronBotGatewaySdkRootDir, "dist");
|
||||
export const electronBotGatewaySdkBinDir = path.join(electronBotGatewaySdkRootDir, "bin");
|
||||
export const electronBotGatewaySdkPackageOutput = path.join(electronBotGatewaySdkRootDir, "package.json");
|
||||
export const electronBotGatewaySdkEntryOutput = path.join(electronBotGatewaySdkDistDir, "index.js");
|
||||
export const electronBotGatewaySdkRunnerOutput = path.join(electronBotGatewaySdkBinDir, "bot-gateway-stdio.mjs");
|
||||
export const rendererOutDir = path.join(uiDistDir, "renderer");
|
||||
export const cliRendererOutDir = path.join(cliDistDir, "renderer");
|
||||
export const coreRendererOutDir = path.join(coreDistDir, "renderer");
|
||||
export const electronRendererOutDir = path.join(electronDistDir, "renderer");
|
||||
export const runtimeRendererOutDirs = [cliRendererOutDir, coreRendererOutDir, electronRendererOutDir];
|
||||
export const appAssetsDir = path.join(electronDistDir, "assets");
|
||||
export const rendererAssetsDir = path.join(rendererOutDir, "assets");
|
||||
export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "plugins");
|
||||
export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins");
|
||||
export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins");
|
||||
export const marketplacePluginsDir = electronMarketplacePluginsDir;
|
||||
export const appAssetsInput = path.join(electronRoot, "assets");
|
||||
export const modelCatalogInput = path.join(coreRoot, "models.json");
|
||||
export const cliModelCatalogOutput = path.join(cliDistDir, "models.json");
|
||||
export const coreModelCatalogOutput = path.join(coreDistDir, "models.json");
|
||||
export const electronModelCatalogOutput = path.join(electronDistDir, "models.json");
|
||||
export const modelCatalogOutput = electronModelCatalogOutput;
|
||||
export const rendererRoot = uiSourceRoot;
|
||||
export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html");
|
||||
export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html");
|
||||
export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html");
|
||||
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 electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
|
||||
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
|
||||
const lightweightMcpBundleMaxBytes = 128 * 1024;
|
||||
const forbiddenLightweightMcpInputs = [
|
||||
{ prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" },
|
||||
{ prefix: "packages/core/src/storage/", reason: "native SQLite storage is not allowed in lightweight MCP subprocesses" },
|
||||
{ prefix: "packages/electron/src/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" },
|
||||
{ prefix: "packages/ui/src/", reason: "UI modules do not belong in stdio MCP subprocesses" },
|
||||
{ prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" },
|
||||
{ prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" }
|
||||
];
|
||||
const forbiddenLightweightMcpExternalImports = new Set(["better-sqlite3", "electron"]);
|
||||
|
||||
const nodeExternals = [
|
||||
"electron",
|
||||
"better-sqlite3",
|
||||
...builtinModules,
|
||||
...builtinModules.map((moduleName) => `node:${moduleName}`)
|
||||
];
|
||||
|
||||
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(cliMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(coreMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(electronMarketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(rendererAssetsDir, { recursive: true });
|
||||
for (const outputDir of runtimeRendererOutDirs) {
|
||||
mkdirSync(path.join(outputDir, "assets"), { recursive: true });
|
||||
}
|
||||
mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true });
|
||||
mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true });
|
||||
}
|
||||
|
||||
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 copyMarketplacePlugins() {
|
||||
ensureDist();
|
||||
for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) {
|
||||
const source = path.join(projectRoot, "examples", "plugins", filename);
|
||||
if (existsSync(source)) {
|
||||
cpSync(source, path.join(cliMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(coreMarketplacePluginsDir, filename));
|
||||
cpSync(source, path.join(electronMarketplacePluginsDir, filename));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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, "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", "toolhub-mcp.ts"),
|
||||
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"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
],
|
||||
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"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
],
|
||||
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 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 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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 761 KiB |
@@ -0,0 +1,72 @@
|
||||
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.");
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import electron from "electron";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, 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 testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-"));
|
||||
const testSuites = ["main", "renderer"];
|
||||
const requestedSuites = process.argv.slice(2);
|
||||
const suites = requestedSuites.length === 0 ? testSuites : requestedSuites;
|
||||
|
||||
for (const suite of suites) {
|
||||
if (!testSuites.includes(suite)) {
|
||||
cleanup();
|
||||
throw new Error(`Unknown test suite: ${suite}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const suite of suites) {
|
||||
await runSuite(suite);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function runSuite(suite) {
|
||||
console.log(`\nRunning ${suite} tests...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(electron, ["--test", `dist/tests/${suite}/*.test.js`], {
|
||||
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,
|
||||
ELECTRON_RUN_AS_NODE: "1"
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${suite} tests exited with code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
rmSync(testHome, { force: true, recursive: true });
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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 testsOutDir = path.join(projectRoot, "dist", "tests");
|
||||
const rendererRoot = path.join(projectRoot, "packages", "ui", "src");
|
||||
const cliSourceRoot = path.join(projectRoot, "packages", "cli", "src");
|
||||
const coreSourceRoot = path.join(projectRoot, "packages", "core", "src");
|
||||
const testSuites = [
|
||||
{ name: "main", testDir: path.join(projectRoot, "tests", "main") },
|
||||
{ name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") }
|
||||
];
|
||||
const requestedSuites = new Set(process.argv.slice(2));
|
||||
const suiteNames = new Set(testSuites.map((suite) => suite.name));
|
||||
const unknownSuites = [...requestedSuites].filter((suite) => !suiteNames.has(suite));
|
||||
const selectedSuites = requestedSuites.size === 0
|
||||
? testSuites
|
||||
: testSuites.filter((suite) => requestedSuites.has(suite.name));
|
||||
|
||||
if (unknownSuites.length > 0) {
|
||||
throw new Error(`Unknown test suite: ${unknownSuites.join(", ")}`);
|
||||
}
|
||||
|
||||
rmSync(testsOutDir, { force: true, recursive: true });
|
||||
|
||||
for (const suite of selectedSuites) {
|
||||
const entryPoints = [
|
||||
...findTestFiles(suite.testDir),
|
||||
...runtimeEntryPointsForSuite(suite.name)
|
||||
];
|
||||
if (entryPoints.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints,
|
||||
external: [
|
||||
"better-sqlite3",
|
||||
"electron"
|
||||
],
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
loader: {
|
||||
".gif": "file",
|
||||
".ico": "file",
|
||||
".jpg": "file",
|
||||
".jpeg": "file",
|
||||
".png": "file",
|
||||
".svg": "file",
|
||||
".webp": "file"
|
||||
},
|
||||
logLevel: "info",
|
||||
outdir: path.join(testsOutDir, suite.name),
|
||||
platform: "node",
|
||||
plugins: [rendererAliasPlugin(), packageAliasPlugin()],
|
||||
target: "node22"
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeEntryPointsForSuite(suiteName) {
|
||||
if (suiteName !== "main") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
|
||||
];
|
||||
}
|
||||
|
||||
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: resolveRendererImport(args.path.slice(2)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function packageAliasPlugin() {
|
||||
return {
|
||||
name: "test-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)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRendererImport(importPath) {
|
||||
return resolvePackageImport(rendererRoot, importPath);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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]);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
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
|
||||
|
After Width: | Height: | Size: 761 KiB |
@@ -0,0 +1,302 @@
|
||||
(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();
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,3 @@
|
||||
name = "claude-code-router-cdn"
|
||||
compatibility_date = "2026-06-28"
|
||||
pages_build_output_dir = "./public"
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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:
|
||||
@@ -0,0 +1,91 @@
|
||||
# Docker deployment
|
||||
|
||||
This image runs the core server package with PM2 and serves the built UI package
|
||||
through Nginx. Nginx is the only published entrypoint: it serves the UI, proxies
|
||||
management API calls to the internal core server, and proxies gateway API calls
|
||||
to the internal gateway listener.
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- Web UI: <http://localhost:3458>
|
||||
- Gateway endpoint: <http://localhost:3458>
|
||||
|
||||
`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image
|
||||
runs separate container-private listeners for management RPC, API gateway
|
||||
routing, and the core gateway runtime. They are implementation details and are
|
||||
not published or configured by the default Compose file.
|
||||
|
||||
To use a different host port, change the Compose port mapping and keep the
|
||||
public router endpoint in sync:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ccr:
|
||||
ports:
|
||||
- "8088:8080"
|
||||
environment:
|
||||
CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088
|
||||
```
|
||||
|
||||
The container stores config and SQLite databases under `/data`, backed by the
|
||||
`ccr-data` volume in `docker-compose.yml`.
|
||||
|
||||
On a fresh data volume, the Web UI starts immediately. The gateway endpoint is
|
||||
available through the same Nginx entrypoint, but the gateway only starts after at
|
||||
least one provider and model are configured.
|
||||
|
||||
## Image scripts
|
||||
|
||||
```sh
|
||||
npm run docker:build
|
||||
npm run docker:run
|
||||
```
|
||||
|
||||
## Smoke test
|
||||
|
||||
```sh
|
||||
npm run test:docker
|
||||
```
|
||||
|
||||
The smoke test builds the image, starts an isolated temporary container with a
|
||||
special-character `CCR_WEB_AUTH_TOKEN`, verifies that only the Nginx port is
|
||||
published, checks UI and RPC authentication, confirms legacy Docker config is
|
||||
migrated to the public Nginx router endpoint, and removes its temporary
|
||||
container and volume. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an already
|
||||
built image.
|
||||
|
||||
The Dockerfile uses `node:22-bookworm` for build and native SQLite dependency
|
||||
installation, then copies the production dependencies into a smaller
|
||||
`node:22-bookworm-slim` runtime image. To use different base images:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE=node:22-bookworm \
|
||||
--build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \
|
||||
-t claude-code-router:local .
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`,
|
||||
and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not
|
||||
`http://127.0.0.1:3458`.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. |
|
||||
| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. |
|
||||
| `CCR_DATA_DIR` | `/data` | Container data root. |
|
||||
| `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. |
|
||||
| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. |
|
||||
| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Sync existing Docker config to the Nginx public router endpoint on startup. |
|
||||
|
||||
The first-run bootstrap writes a minimal legacy `config.json` only when neither
|
||||
`config.json` nor `config.sqlite` exists in the mounted data directory. Once the
|
||||
UI saves settings into SQLite, existing persisted configuration takes priority.
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/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_GATEWAY_CORE_PORT="${CCR_GATEWAY_CORE_PORT:-3457}"
|
||||
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_CORE_PORT
|
||||
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 gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457");
|
||||
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: {
|
||||
coreHost: "127.0.0.1",
|
||||
corePort: gatewayCorePort,
|
||||
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 gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457");
|
||||
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;
|
||||
value.gateway = {
|
||||
...(value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway) ? value.gateway : {}),
|
||||
coreHost: "127.0.0.1",
|
||||
corePort: gatewayCorePort,
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
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-core-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"
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
const site = process.env.ASTRO_SITE ?? "https://ccrdesk.top";
|
||||
const base = process.env.ASTRO_BASE ?? "/";
|
||||
|
||||
export default defineConfig({
|
||||
site,
|
||||
base,
|
||||
output: "static",
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
defaultColor: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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": {
|
||||
"astro": "7.0.0",
|
||||
"lucide-astro": "^0.556.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ccrdesk.top
|
||||
|
After Width: | Height: | Size: 761 KiB |
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 251 B |
|
After Width: | Height: | Size: 992 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 358 B |
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,219 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{opacity:0.3;fill:#E2E4E7;}
|
||||
.st1{opacity:0.8;fill:#E2E4E7;stroke:#FFFFFF;stroke-width:5;stroke-miterlimit:10;}
|
||||
.st2{fill:url(#SVGID_1_);}
|
||||
.st3{fill:none;stroke:#E0E4E9;stroke-width:0.25;stroke-miterlimit:10;}
|
||||
.st4{fill:none;}
|
||||
.st5{fill:#9DA1A5;}
|
||||
.st6{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
|
||||
.st7{fill-rule:evenodd;clip-rule:evenodd;fill:#DFE2E7;}
|
||||
.st8{fill-rule:evenodd;clip-rule:evenodd;fill:#CDD4DA;}
|
||||
.st9{fill-rule:evenodd;clip-rule:evenodd;fill:#B3BCC7;}
|
||||
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:#9DAAB7;}
|
||||
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:#8698A8;}
|
||||
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
|
||||
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
|
||||
.st14{fill:#1F63EC;}
|
||||
.st15{fill:#2D2D2D;}
|
||||
.st16{fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st17{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);}
|
||||
.st18{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);}
|
||||
.st19{fill:none;stroke:#677380;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st20{fill:none;stroke:url(#SVGID_6_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st21{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);}
|
||||
.st22{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);}
|
||||
.st23{fill:#FFFFFF;}
|
||||
.st24{fill-rule:evenodd;clip-rule:evenodd;fill:#2D2D2D;}
|
||||
.st25{clip-path:url(#SVGID_10_);}
|
||||
.st26{clip-path:url(#SVGID_12_);}
|
||||
.st27{fill:url(#SVGID_13_);}
|
||||
.st28{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_14_);}
|
||||
.st29{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_15_);}
|
||||
.st30{clip-path:url(#SVGID_17_);}
|
||||
.st31{clip-path:url(#SVGID_19_);}
|
||||
.st32{fill:url(#SVGID_20_);}
|
||||
.st33{fill:none;stroke:url(#SVGID_21_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st34{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_22_);}
|
||||
.st35{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_23_);}
|
||||
.st36{clip-path:url(#SVGID_25_);}
|
||||
.st37{clip-path:url(#SVGID_27_);}
|
||||
.st38{fill:url(#SVGID_28_);}
|
||||
.st39{clip-path:url(#SVGID_30_);}
|
||||
.st40{clip-path:url(#SVGID_32_);}
|
||||
.st41{fill:url(#SVGID_33_);}
|
||||
.st42{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF6;}
|
||||
.st43{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||
.st44{clip-path:url(#SVGID_35_);}
|
||||
.st45{clip-path:url(#SVGID_37_);}
|
||||
.st46{fill:url(#SVGID_38_);}
|
||||
.st47{fill-rule:evenodd;clip-rule:evenodd;fill:#9DA1A5;}
|
||||
.st48{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_39_);}
|
||||
.st49{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_40_);}
|
||||
.st50{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_41_);}
|
||||
.st51{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_42_);}
|
||||
.st52{fill:none;stroke:url(#SVGID_43_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st53{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st54{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_44_);}
|
||||
.st55{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_45_);}
|
||||
.st56{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_46_);}
|
||||
.st57{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_47_);}
|
||||
.st58{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_48_);}
|
||||
.st59{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_49_);}
|
||||
.st60{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_50_);}
|
||||
.st61{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_51_);}
|
||||
.st62{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_52_);}
|
||||
.st63{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_53_);}
|
||||
.st64{clip-path:url(#SVGID_55_);}
|
||||
.st65{clip-path:url(#SVGID_57_);}
|
||||
.st66{fill:url(#SVGID_58_);}
|
||||
.st67{clip-path:url(#SVGID_60_);}
|
||||
.st68{clip-path:url(#SVGID_62_);}
|
||||
.st69{fill:url(#SVGID_63_);}
|
||||
.st70{fill:none;stroke:url(#SVGID_64_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st71{clip-path:url(#SVGID_66_);}
|
||||
.st72{clip-path:url(#SVGID_68_);}
|
||||
.st73{fill:url(#SVGID_69_);}
|
||||
.st74{clip-path:url(#SVGID_71_);}
|
||||
.st75{clip-path:url(#SVGID_73_);}
|
||||
.st76{fill:url(#SVGID_74_);}
|
||||
.st77{clip-path:url(#SVGID_76_);}
|
||||
.st78{clip-path:url(#SVGID_78_);}
|
||||
.st79{fill:url(#SVGID_79_);}
|
||||
.st80{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_80_);}
|
||||
.st81{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_81_);}
|
||||
.st82{clip-path:url(#SVGID_83_);}
|
||||
.st83{clip-path:url(#SVGID_85_);}
|
||||
.st84{fill:url(#SVGID_86_);}
|
||||
.st85{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_87_);}
|
||||
.st86{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_88_);}
|
||||
.st87{clip-path:url(#SVGID_90_);}
|
||||
.st88{clip-path:url(#SVGID_92_);}
|
||||
.st89{fill:url(#SVGID_93_);}
|
||||
.st90{fill:none;stroke:url(#SVGID_94_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st91{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_95_);}
|
||||
.st92{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_96_);}
|
||||
.st93{clip-path:url(#SVGID_98_);}
|
||||
.st94{clip-path:url(#SVGID_100_);}
|
||||
.st95{fill:url(#SVGID_101_);}
|
||||
.st96{clip-path:url(#SVGID_103_);}
|
||||
.st97{clip-path:url(#SVGID_105_);}
|
||||
.st98{fill:url(#SVGID_106_);}
|
||||
.st99{clip-path:url(#SVGID_108_);}
|
||||
.st100{clip-path:url(#SVGID_110_);}
|
||||
.st101{fill:url(#SVGID_111_);}
|
||||
.st102{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st103{clip-path:url(#SVGID_113_);}
|
||||
.st104{fill:#FDD138;}
|
||||
.st105{fill:#FCA62F;}
|
||||
.st106{fill:#FB7927;}
|
||||
.st107{fill:#F44B22;}
|
||||
.st108{fill:#D81915;}
|
||||
.st109{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3354;stroke-miterlimit:10;}
|
||||
.st110{fill:none;stroke:#65727F;stroke-width:2;stroke-miterlimit:10;}
|
||||
.st111{fill:none;stroke:#65727F;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st112{fill:url(#SVGID_114_);}
|
||||
.st113{fill:#D06C50;}
|
||||
.st114{fill:#2D2D2D;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st115{opacity:0.2;}
|
||||
.st116{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;}
|
||||
.st117{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0212,1.0212;}
|
||||
.st118{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0205,1.0205;}
|
||||
.st119{opacity:0.2;fill:none;}
|
||||
.st120{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;}
|
||||
.st121{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;stroke-dasharray:1.0509,1.0509;}
|
||||
.st122{opacity:0.3;fill:#1F63EC;}
|
||||
.st123{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3162;stroke-miterlimit:10;}
|
||||
.st124{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.3162;stroke-miterlimit:10;}
|
||||
.st125{clip-path:url(#SVGID_118_);}
|
||||
.st126{fill:url(#SVGID_119_);}
|
||||
.st127{fill:none;stroke:#DFE2E7;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st128{fill:#9DA1A5;stroke:#FFFFFF;stroke-miterlimit:10;}
|
||||
.st129{fill:url(#SVGID_120_);}
|
||||
.st130{fill:none;stroke:#677380;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st131{opacity:0.4;}
|
||||
.st132{clip-path:url(#SVGID_122_);}
|
||||
.st133{clip-path:url(#SVGID_124_);}
|
||||
.st134{fill:url(#SVGID_125_);}
|
||||
.st135{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;}
|
||||
.st136{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:0.9951,0.9951;}
|
||||
.st137{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1.004,1.004;}
|
||||
.st138{fill:none;stroke:url(#SVGID_126_);stroke-width:1.5;stroke-miterlimit:10;}
|
||||
.st139{fill:url(#SVGID_127_);}
|
||||
.st140{fill:none;stroke:#DDE0E4;stroke-width:0.35;stroke-miterlimit:10;}
|
||||
.st141{fill:#2D2D2D;stroke:#A9B3BE;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st142{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF4;}
|
||||
.st143{fill:#FFFFFF;stroke:#B1BAC4;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st144{fill:#CE6C50;}
|
||||
.st145{fill:#5B5B5B;}
|
||||
.st146{fill:#8392A3;}
|
||||
.st147{fill:none;stroke:url(#SVGID_128_);stroke-width:1.5;stroke-miterlimit:10;}
|
||||
.st148{fill:url(#SVGID_129_);}
|
||||
.st149{fill:none;stroke:#B5BDC4;stroke-width:0.7;stroke-miterlimit:10;}
|
||||
.st150{opacity:0.6;fill:none;stroke:#78838E;stroke-width:0.35;stroke-miterlimit:10;}
|
||||
.st151{opacity:0.2;fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1,1;}
|
||||
.st152{fill:none;stroke:#DDE0E4;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st153{fill:none;stroke:#8392A3;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st154{opacity:0.2;fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0182,1.0182;}
|
||||
.st155{fill:none;stroke:#DDE0E4;stroke-width:0.765;stroke-miterlimit:10;}
|
||||
.st156{fill:url(#SVGID_130_);}
|
||||
.st157{fill:url(#SVGID_131_);}
|
||||
.st158{fill:#B1BAC4;}
|
||||
.st159{fill:#CBD1D8;}
|
||||
.st160{fill:#0B1B2B;}
|
||||
.st161{fill:#91D119;}
|
||||
.st162{opacity:0.7;}
|
||||
.st163{fill:#FFFFFF;stroke:#000000;stroke-width:0.4418;stroke-miterlimit:10;}
|
||||
.st164{fill:none;stroke:#939CAA;stroke-width:0.2209;stroke-miterlimit:10;}
|
||||
.st165{fill:none;stroke:#FFFFFF;stroke-width:3.0924;stroke-miterlimit:10;}
|
||||
.st166{fill:url(#SVGID_132_);}
|
||||
.st167{fill:none;stroke:url(#SVGID_133_);stroke-width:1.714;stroke-miterlimit:10;}
|
||||
.st168{fill:url(#SVGID_134_);}
|
||||
.st169{fill:url(#SVGID_135_);}
|
||||
.st170{fill:url(#SVGID_136_);}
|
||||
.st171{fill:url(#SVGID_137_);}
|
||||
.st172{fill:url(#SVGID_138_);}
|
||||
.st173{fill:url(#SVGID_139_);}
|
||||
.st174{fill:url(#SVGID_140_);}
|
||||
.st175{fill:url(#SVGID_141_);}
|
||||
.st176{fill:url(#SVGID_142_);}
|
||||
.st177{fill:url(#SVGID_143_);}
|
||||
.st178{fill:url(#SVGID_144_);}
|
||||
.st179{fill:none;stroke:#1F63EC;stroke-width:4;stroke-miterlimit:10;}
|
||||
.st180{fill:none;stroke:#0B1B2B;stroke-width:4;stroke-miterlimit:10;}
|
||||
.st181{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;}
|
||||
.st182{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
|
||||
.st183{fill:#257AF1;}
|
||||
.st184{opacity:0.3;fill:#FFFFFF;}
|
||||
.st185{fill:none;stroke:#98A5B2;stroke-width:4;stroke-miterlimit:10;}
|
||||
.st186{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;}
|
||||
.st187{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
|
||||
.st188{fill:none;stroke:#DDDFE4;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st189{fill:#9A9EA2;}
|
||||
.st190{fill-rule:evenodd;clip-rule:evenodd;fill:#3267AC;}
|
||||
.st191{fill:#FFFFFF;stroke:#AFB8C3;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st192{fill:#C5694E;}
|
||||
.st193{fill:#8192A2;}
|
||||
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g id="图层_2">
|
||||
</g>
|
||||
<g id="图层_1">
|
||||
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
|
||||
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
|
||||
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1 "/>
|
||||
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,219 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{opacity:0.3;fill:#E2E4E7;}
|
||||
.st1{opacity:0.8;fill:#E2E4E7;stroke:#FFFFFF;stroke-width:5;stroke-miterlimit:10;}
|
||||
.st2{fill:url(#SVGID_1_);}
|
||||
.st3{fill:none;stroke:#E0E4E9;stroke-width:0.25;stroke-miterlimit:10;}
|
||||
.st4{fill:none;}
|
||||
.st5{fill:#9DA1A5;}
|
||||
.st6{fill-rule:evenodd;clip-rule:evenodd;fill:none;}
|
||||
.st7{fill-rule:evenodd;clip-rule:evenodd;fill:#DFE2E7;}
|
||||
.st8{fill-rule:evenodd;clip-rule:evenodd;fill:#CDD4DA;}
|
||||
.st9{fill-rule:evenodd;clip-rule:evenodd;fill:#B3BCC7;}
|
||||
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:#9DAAB7;}
|
||||
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:#8698A8;}
|
||||
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
|
||||
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
|
||||
.st14{fill:#1F63EC;}
|
||||
.st15{fill:#2D2D2D;}
|
||||
.st16{fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st17{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);}
|
||||
.st18{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);}
|
||||
.st19{fill:none;stroke:#677380;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st20{fill:none;stroke:url(#SVGID_6_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st21{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);}
|
||||
.st22{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);}
|
||||
.st23{fill:#FFFFFF;}
|
||||
.st24{fill-rule:evenodd;clip-rule:evenodd;fill:#2D2D2D;}
|
||||
.st25{clip-path:url(#SVGID_10_);}
|
||||
.st26{clip-path:url(#SVGID_12_);}
|
||||
.st27{fill:url(#SVGID_13_);}
|
||||
.st28{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_14_);}
|
||||
.st29{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_15_);}
|
||||
.st30{clip-path:url(#SVGID_17_);}
|
||||
.st31{clip-path:url(#SVGID_19_);}
|
||||
.st32{fill:url(#SVGID_20_);}
|
||||
.st33{fill:none;stroke:url(#SVGID_21_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st34{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_22_);}
|
||||
.st35{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_23_);}
|
||||
.st36{clip-path:url(#SVGID_25_);}
|
||||
.st37{clip-path:url(#SVGID_27_);}
|
||||
.st38{fill:url(#SVGID_28_);}
|
||||
.st39{clip-path:url(#SVGID_30_);}
|
||||
.st40{clip-path:url(#SVGID_32_);}
|
||||
.st41{fill:url(#SVGID_33_);}
|
||||
.st42{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF6;}
|
||||
.st43{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||
.st44{clip-path:url(#SVGID_35_);}
|
||||
.st45{clip-path:url(#SVGID_37_);}
|
||||
.st46{fill:url(#SVGID_38_);}
|
||||
.st47{fill-rule:evenodd;clip-rule:evenodd;fill:#9DA1A5;}
|
||||
.st48{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_39_);}
|
||||
.st49{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_40_);}
|
||||
.st50{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_41_);}
|
||||
.st51{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_42_);}
|
||||
.st52{fill:none;stroke:url(#SVGID_43_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st53{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#E0E4E9;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st54{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_44_);}
|
||||
.st55{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_45_);}
|
||||
.st56{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_46_);}
|
||||
.st57{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_47_);}
|
||||
.st58{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_48_);}
|
||||
.st59{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_49_);}
|
||||
.st60{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_50_);}
|
||||
.st61{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_51_);}
|
||||
.st62{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_52_);}
|
||||
.st63{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_53_);}
|
||||
.st64{clip-path:url(#SVGID_55_);}
|
||||
.st65{clip-path:url(#SVGID_57_);}
|
||||
.st66{fill:url(#SVGID_58_);}
|
||||
.st67{clip-path:url(#SVGID_60_);}
|
||||
.st68{clip-path:url(#SVGID_62_);}
|
||||
.st69{fill:url(#SVGID_63_);}
|
||||
.st70{fill:none;stroke:url(#SVGID_64_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st71{clip-path:url(#SVGID_66_);}
|
||||
.st72{clip-path:url(#SVGID_68_);}
|
||||
.st73{fill:url(#SVGID_69_);}
|
||||
.st74{clip-path:url(#SVGID_71_);}
|
||||
.st75{clip-path:url(#SVGID_73_);}
|
||||
.st76{fill:url(#SVGID_74_);}
|
||||
.st77{clip-path:url(#SVGID_76_);}
|
||||
.st78{clip-path:url(#SVGID_78_);}
|
||||
.st79{fill:url(#SVGID_79_);}
|
||||
.st80{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_80_);}
|
||||
.st81{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_81_);}
|
||||
.st82{clip-path:url(#SVGID_83_);}
|
||||
.st83{clip-path:url(#SVGID_85_);}
|
||||
.st84{fill:url(#SVGID_86_);}
|
||||
.st85{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_87_);}
|
||||
.st86{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_88_);}
|
||||
.st87{clip-path:url(#SVGID_90_);}
|
||||
.st88{clip-path:url(#SVGID_92_);}
|
||||
.st89{fill:url(#SVGID_93_);}
|
||||
.st90{fill:none;stroke:url(#SVGID_94_);stroke-width:2;stroke-miterlimit:10;}
|
||||
.st91{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_95_);}
|
||||
.st92{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_96_);}
|
||||
.st93{clip-path:url(#SVGID_98_);}
|
||||
.st94{clip-path:url(#SVGID_100_);}
|
||||
.st95{fill:url(#SVGID_101_);}
|
||||
.st96{clip-path:url(#SVGID_103_);}
|
||||
.st97{clip-path:url(#SVGID_105_);}
|
||||
.st98{fill:url(#SVGID_106_);}
|
||||
.st99{clip-path:url(#SVGID_108_);}
|
||||
.st100{clip-path:url(#SVGID_110_);}
|
||||
.st101{fill:url(#SVGID_111_);}
|
||||
.st102{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st103{clip-path:url(#SVGID_113_);}
|
||||
.st104{fill:#FDD138;}
|
||||
.st105{fill:#FCA62F;}
|
||||
.st106{fill:#FB7927;}
|
||||
.st107{fill:#F44B22;}
|
||||
.st108{fill:#D81915;}
|
||||
.st109{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3354;stroke-miterlimit:10;}
|
||||
.st110{fill:none;stroke:#65727F;stroke-width:2;stroke-miterlimit:10;}
|
||||
.st111{fill:none;stroke:#65727F;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st112{fill:url(#SVGID_114_);}
|
||||
.st113{fill:#D06C50;}
|
||||
.st114{fill:#2D2D2D;stroke:#B3BCC7;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st115{opacity:0.2;}
|
||||
.st116{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;}
|
||||
.st117{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0212,1.0212;}
|
||||
.st118{fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0205,1.0205;}
|
||||
.st119{opacity:0.2;fill:none;}
|
||||
.st120{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;}
|
||||
.st121{fill:none;stroke:#677380;stroke-width:0.3689;stroke-miterlimit:10;stroke-dasharray:1.0509,1.0509;}
|
||||
.st122{opacity:0.3;fill:#1F63EC;}
|
||||
.st123{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.3162;stroke-miterlimit:10;}
|
||||
.st124{fill:#FFFFFF;stroke:#B3BCC7;stroke-width:0.3162;stroke-miterlimit:10;}
|
||||
.st125{clip-path:url(#SVGID_118_);}
|
||||
.st126{fill:url(#SVGID_119_);}
|
||||
.st127{fill:none;stroke:#DFE2E7;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st128{fill:#9DA1A5;stroke:#FFFFFF;stroke-miterlimit:10;}
|
||||
.st129{fill:url(#SVGID_120_);}
|
||||
.st130{fill:none;stroke:#677380;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st131{opacity:0.4;}
|
||||
.st132{clip-path:url(#SVGID_122_);}
|
||||
.st133{clip-path:url(#SVGID_124_);}
|
||||
.st134{fill:url(#SVGID_125_);}
|
||||
.st135{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;}
|
||||
.st136{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:0.9951,0.9951;}
|
||||
.st137{fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1.004,1.004;}
|
||||
.st138{fill:none;stroke:url(#SVGID_126_);stroke-width:1.5;stroke-miterlimit:10;}
|
||||
.st139{fill:url(#SVGID_127_);}
|
||||
.st140{fill:none;stroke:#DDE0E4;stroke-width:0.35;stroke-miterlimit:10;}
|
||||
.st141{fill:#2D2D2D;stroke:#A9B3BE;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st142{fill-rule:evenodd;clip-rule:evenodd;fill:#126EF4;}
|
||||
.st143{fill:#FFFFFF;stroke:#B1BAC4;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st144{fill:#CE6C50;}
|
||||
.st145{fill:#5B5B5B;}
|
||||
.st146{fill:#8392A3;}
|
||||
.st147{fill:none;stroke:url(#SVGID_128_);stroke-width:1.5;stroke-miterlimit:10;}
|
||||
.st148{fill:url(#SVGID_129_);}
|
||||
.st149{fill:none;stroke:#B5BDC4;stroke-width:0.7;stroke-miterlimit:10;}
|
||||
.st150{opacity:0.6;fill:none;stroke:#78838E;stroke-width:0.35;stroke-miterlimit:10;}
|
||||
.st151{opacity:0.2;fill:none;stroke:#8392A3;stroke-width:0.35;stroke-miterlimit:10;stroke-dasharray:1,1;}
|
||||
.st152{fill:none;stroke:#DDE0E4;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st153{fill:none;stroke:#8392A3;stroke-width:0.5;stroke-miterlimit:10;}
|
||||
.st154{opacity:0.2;fill:none;stroke:#677380;stroke-width:0.3564;stroke-miterlimit:10;stroke-dasharray:1.0182,1.0182;}
|
||||
.st155{fill:none;stroke:#DDE0E4;stroke-width:0.765;stroke-miterlimit:10;}
|
||||
.st156{fill:url(#SVGID_130_);}
|
||||
.st157{fill:url(#SVGID_131_);}
|
||||
.st158{fill:#B1BAC4;}
|
||||
.st159{fill:#CBD1D8;}
|
||||
.st160{fill:#0B1B2B;}
|
||||
.st161{fill:#91D119;}
|
||||
.st162{opacity:0.7;}
|
||||
.st163{fill:#FFFFFF;stroke:#000000;stroke-width:0.4418;stroke-miterlimit:10;}
|
||||
.st164{fill:none;stroke:#939CAA;stroke-width:0.2209;stroke-miterlimit:10;}
|
||||
.st165{fill:none;stroke:#FFFFFF;stroke-width:3.0924;stroke-miterlimit:10;}
|
||||
.st166{fill:url(#SVGID_132_);}
|
||||
.st167{fill:none;stroke:url(#SVGID_133_);stroke-width:1.714;stroke-miterlimit:10;}
|
||||
.st168{fill:url(#SVGID_134_);}
|
||||
.st169{fill:url(#SVGID_135_);}
|
||||
.st170{fill:url(#SVGID_136_);}
|
||||
.st171{fill:url(#SVGID_137_);}
|
||||
.st172{fill:url(#SVGID_138_);}
|
||||
.st173{fill:url(#SVGID_139_);}
|
||||
.st174{fill:url(#SVGID_140_);}
|
||||
.st175{fill:url(#SVGID_141_);}
|
||||
.st176{fill:url(#SVGID_142_);}
|
||||
.st177{fill:url(#SVGID_143_);}
|
||||
.st178{fill:url(#SVGID_144_);}
|
||||
.st179{fill:none;stroke:#1F63EC;stroke-width:4;stroke-miterlimit:10;}
|
||||
.st180{fill:none;stroke:#0B1B2B;stroke-width:4;stroke-miterlimit:10;}
|
||||
.st181{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;}
|
||||
.st182{fill:none;stroke:#677380;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
|
||||
.st183{fill:#257AF1;}
|
||||
.st184{opacity:0.3;fill:#FFFFFF;}
|
||||
.st185{fill:none;stroke:#98A5B2;stroke-width:4;stroke-miterlimit:10;}
|
||||
.st186{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;}
|
||||
.st187{fill:none;stroke:#65727F;stroke-width:0.3989;stroke-miterlimit:10;stroke-dasharray:1.14,1.14;}
|
||||
.st188{fill:none;stroke:#DDDFE4;stroke-width:0.75;stroke-miterlimit:10;}
|
||||
.st189{fill:#9A9EA2;}
|
||||
.st190{fill-rule:evenodd;clip-rule:evenodd;fill:#3267AC;}
|
||||
.st191{fill:#FFFFFF;stroke:#AFB8C3;stroke-width:0.275;stroke-miterlimit:10;}
|
||||
.st192{fill:#C5694E;}
|
||||
.st193{fill:#8192A2;}
|
||||
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g id="图层_2">
|
||||
</g>
|
||||
<g id="图层_1">
|
||||
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
|
||||
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
|
||||
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1 "/>
|
||||
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,62 @@
|
||||
export type BotPlatformModule = {
|
||||
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 zhBotDocs = import.meta.glob<BotPlatformModule>(
|
||||
"./content/docs/zh/bot-与-im-接力-agent/*.md",
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
export const enBotDocs = import.meta.glob<BotPlatformModule>(
|
||||
"./content/docs/en/relay-agents-in-im-with-bots/*.md",
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
export const BOT_PLATFORM_ORDER = [
|
||||
"slack",
|
||||
"discord",
|
||||
"telegram",
|
||||
"line",
|
||||
"weixin-ilink",
|
||||
"wecom",
|
||||
"feishu",
|
||||
"dingtalk",
|
||||
] as const;
|
||||
|
||||
export type BotPlatformSlug = (typeof BOT_PLATFORM_ORDER)[number];
|
||||
|
||||
export const BOT_PLATFORM_LABELS_ZH: Record<BotPlatformSlug, string> = {
|
||||
slack: "Slack",
|
||||
discord: "Discord",
|
||||
telegram: "Telegram",
|
||||
line: "LINE",
|
||||
"weixin-ilink": "微信",
|
||||
wecom: "企业微信",
|
||||
feishu: "飞书",
|
||||
dingtalk: "钉钉",
|
||||
};
|
||||
|
||||
export const BOT_PLATFORM_LABELS_EN: Record<BotPlatformSlug, string> = {
|
||||
slack: "Slack",
|
||||
discord: "Discord",
|
||||
telegram: "Telegram",
|
||||
line: "LINE",
|
||||
"weixin-ilink": "Weixin",
|
||||
wecom: "WeCom",
|
||||
feishu: "Feishu",
|
||||
dingtalk: "DingTalk",
|
||||
};
|
||||
|
||||
export function botPlatformFromPath(filePath: string): string {
|
||||
const file = filePath.split("/").pop() ?? filePath;
|
||||
return file.replace(/\.md$/, "");
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
import DocsLayout from "../layouts/DocsLayout.astro";
|
||||
import { docsContent, type DocPageKey, type Locale } from "../i18n/content";
|
||||
import * as enConfigurationDoc from "../content/docs/en/configuration.md";
|
||||
import * as enGuidesDoc from "../content/docs/en/guides.md";
|
||||
import * as enDoc from "../content/docs/en/index.md";
|
||||
import * as enTroubleshootingDoc from "../content/docs/en/troubleshooting.md";
|
||||
import * as zhConfigurationDoc from "../content/docs/zh/configuration.md";
|
||||
import * as zhGuidesDoc from "../content/docs/zh/guides.md";
|
||||
import * as zhDoc from "../content/docs/zh/index.md";
|
||||
import * as zhTroubleshootingDoc from "../content/docs/zh/troubleshooting.md";
|
||||
import { Check, Copy } from "lucide-astro";
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageKey?: DocPageKey;
|
||||
doc?: any;
|
||||
activeSidebarItem?: string;
|
||||
}
|
||||
|
||||
const docsByLocale = {
|
||||
zh: {
|
||||
documentation: zhDoc,
|
||||
guides: zhGuidesDoc,
|
||||
configuration: zhConfigurationDoc,
|
||||
troubleshooting: zhTroubleshootingDoc,
|
||||
},
|
||||
en: {
|
||||
documentation: enDoc,
|
||||
guides: enGuidesDoc,
|
||||
configuration: enConfigurationDoc,
|
||||
troubleshooting: enTroubleshootingDoc,
|
||||
},
|
||||
} satisfies Record<Locale, Record<DocPageKey, any>>;
|
||||
|
||||
const {
|
||||
locale,
|
||||
pageKey: pageKeyProp = "documentation",
|
||||
doc: docProp,
|
||||
activeSidebarItem: activeSidebarItemProp,
|
||||
} = Astro.props;
|
||||
const content = docsContent[locale];
|
||||
const pages = content.pages as Record<DocPageKey, any>;
|
||||
const pageKey = pageKeyProp;
|
||||
const pageContent = pages[pageKey] ?? pages.documentation;
|
||||
const sectionDoc = docsByLocale[locale][pageKey] ?? docsByLocale[locale].documentation;
|
||||
const sectionHref =
|
||||
content.navItems.find((item) => item.pageKey === pageKey)?.href ??
|
||||
(locale === "en" ? "/en/" : "/");
|
||||
const isSectionDoc = !docProp || docProp === sectionDoc;
|
||||
const doc = docProp ?? sectionDoc;
|
||||
const { Content, frontmatter } = doc;
|
||||
const allHeadings = doc.getHeadings();
|
||||
const tocHeadings = allHeadings.filter((heading) => heading.depth >= 2 && heading.depth <= 5);
|
||||
const isExplicitHref = (value: string) =>
|
||||
value.startsWith("#") ||
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
/^[a-z][a-z\d+.-]*:/i.test(value);
|
||||
|
||||
const getHeadingLinks = (targetDoc: any, baseHref: string, useLocalHashes: boolean) => {
|
||||
const links = new Map<string, string>();
|
||||
|
||||
for (const heading of targetDoc.getHeadings()) {
|
||||
if (!links.has(heading.text)) {
|
||||
links.set(
|
||||
heading.text,
|
||||
useLocalHashes ? `#${heading.slug}` : `${baseHref}#${heading.slug}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
const sectionHeadingLinks = getHeadingLinks(sectionDoc, sectionHref, isSectionDoc);
|
||||
const resolveDocTarget = (target?: string, headingLinks = sectionHeadingLinks) => {
|
||||
if (!target) return "#";
|
||||
if (isExplicitHref(target)) return target;
|
||||
return headingLinks.get(target) ?? "#";
|
||||
};
|
||||
const navItems = content.navItems.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
active: item.pageKey === pageKey,
|
||||
};
|
||||
});
|
||||
const sidebarLabels = new Set<string>();
|
||||
|
||||
for (const group of pageContent.sidebarGroups) {
|
||||
for (const item of group.items) {
|
||||
sidebarLabels.add(item);
|
||||
|
||||
for (const child of pageContent.sidebarChildren[item] ?? []) {
|
||||
sidebarLabels.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarLinks = Object.fromEntries(
|
||||
Array.from(sidebarLabels).map((label) => [
|
||||
label,
|
||||
resolveDocTarget(
|
||||
pageContent.sidebarLinks[label] ??
|
||||
pageContent.sidebarTargets[label] ??
|
||||
label
|
||||
),
|
||||
])
|
||||
);
|
||||
const sidebarNavItems = content.navItems.filter((navItem) => navItem.pageKey !== "documentation");
|
||||
const sidebarTree = sidebarNavItems.map((navItem, index) => {
|
||||
const navPageKey = navItem.pageKey as DocPageKey;
|
||||
const navPageContent = pages[navPageKey] ?? pages.documentation;
|
||||
const navSectionDoc = docsByLocale[locale][navPageKey] ?? docsByLocale[locale].documentation;
|
||||
const navHref = navItem.href ?? "#";
|
||||
const navIsCurrentSection = navPageKey === pageKey && isSectionDoc;
|
||||
const navHeadingLinks = getHeadingLinks(navSectionDoc, navHref, navIsCurrentSection);
|
||||
const navActive = navPageKey === pageKey;
|
||||
const openByDefault = pageKey === "documentation" && index === 0;
|
||||
|
||||
return {
|
||||
label: navItem.label,
|
||||
href: navHref,
|
||||
active: navActive,
|
||||
open: navActive || openByDefault,
|
||||
groups: navPageContent.sidebarGroups.map((group) => ({
|
||||
...group,
|
||||
active: navActive && group.active,
|
||||
items: group.items.map((item) => {
|
||||
const childItems = navPageContent.sidebarChildren[item] ?? [];
|
||||
const itemHref = resolveDocTarget(
|
||||
navPageContent.sidebarLinks[item] ??
|
||||
navPageContent.sidebarTargets[item] ??
|
||||
item,
|
||||
navHeadingLinks
|
||||
);
|
||||
|
||||
const children = childItems.map((child) => ({
|
||||
label: child,
|
||||
href: resolveDocTarget(
|
||||
navPageContent.sidebarLinks[child] ??
|
||||
navPageContent.sidebarTargets[child] ??
|
||||
child,
|
||||
navHeadingLinks
|
||||
),
|
||||
active: navActive && activeSidebarItemProp === child,
|
||||
}));
|
||||
|
||||
return {
|
||||
label: item,
|
||||
href: itemHref,
|
||||
active: navActive && (activeSidebarItemProp ? activeSidebarItemProp === item : group.active === item),
|
||||
children,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
const tocItems = tocHeadings.map((heading) => ({
|
||||
label: heading.text,
|
||||
href: `#${heading.slug}`,
|
||||
depth: heading.depth,
|
||||
}));
|
||||
const pageMarkdown = doc.rawContent();
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
title={frontmatter.pageTitle ?? content.pageTitle}
|
||||
htmlLang={content.htmlLang}
|
||||
locale={locale}
|
||||
languageLabel={content.languageLabel}
|
||||
languageOptions={content.languageOptions}
|
||||
navItems={navItems}
|
||||
sidebarTree={sidebarTree}
|
||||
sidebarGroups={pageContent.sidebarGroups}
|
||||
expandableSidebarItems={pageContent.expandableSidebarItems}
|
||||
sidebarChildren={pageContent.sidebarChildren}
|
||||
sidebarLinks={sidebarLinks}
|
||||
tocTitle={content.tocTitle}
|
||||
tocItems={tocItems}
|
||||
ui={content.ui}
|
||||
>
|
||||
<article class="doc-article">
|
||||
<header class="article-header">
|
||||
<div>
|
||||
<p class="eyebrow">{frontmatter.eyebrow}</p>
|
||||
<h1>{frontmatter.title}</h1>
|
||||
<p class="lead">{frontmatter.lead}</p>
|
||||
</div>
|
||||
<button class="copy-page" type="button" data-copy-page>
|
||||
<Copy size={18} aria-hidden="true" />
|
||||
<span data-copy-label>{content.ui.copyPage}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="doc-markdown" data-markdown-content>
|
||||
<Content />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<template data-code-icon-template>
|
||||
<span class="copy-icon" aria-hidden="true">
|
||||
<Copy size={18} />
|
||||
</span>
|
||||
<span class="success-icon" aria-hidden="true">
|
||||
<Check size={18} />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script define:vars={{
|
||||
pageMarkdown,
|
||||
copyPageLabel: content.ui.copyPage,
|
||||
copiedLabel: content.ui.copied,
|
||||
copyFailedLabel: content.ui.copyFailed,
|
||||
copyCodeLabel: content.ui.copyCode,
|
||||
copiedCodeLabel: content.ui.copiedCode,
|
||||
copyCodeFailedLabel: content.ui.copyCodeFailed,
|
||||
}}>
|
||||
const copyButton = document.querySelector("[data-copy-page]");
|
||||
const copyLabel = document.querySelector("[data-copy-label]");
|
||||
|
||||
const writeClipboard = async (text) => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
};
|
||||
|
||||
copyButton?.addEventListener("click", async () => {
|
||||
try {
|
||||
await writeClipboard(pageMarkdown);
|
||||
copyButton.classList.add("copied");
|
||||
if (copyLabel) copyLabel.textContent = copiedLabel;
|
||||
} catch {
|
||||
if (copyLabel) copyLabel.textContent = copyFailedLabel;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
copyButton.classList.remove("copied");
|
||||
if (copyLabel) copyLabel.textContent = copyPageLabel;
|
||||
}, 1800);
|
||||
});
|
||||
|
||||
const codeIconTemplate = document.querySelector("[data-code-icon-template]");
|
||||
const buildCopyIcon = () => codeIconTemplate instanceof HTMLTemplateElement
|
||||
? codeIconTemplate.innerHTML
|
||||
: "";
|
||||
|
||||
document.querySelectorAll("[data-markdown-content] pre.astro-code").forEach((pre) => {
|
||||
if (!(pre instanceof HTMLElement) || pre.closest(".code-panel")) return;
|
||||
|
||||
const language = pre.dataset.language || "text";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "code-panel markdown-code-panel";
|
||||
panel.setAttribute("aria-label", language);
|
||||
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "code-toolbar";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.textContent = language === "text" ? "text" : language;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "code-copy";
|
||||
button.type = "button";
|
||||
button.dataset.copyCode = "";
|
||||
button.setAttribute("aria-label", copyCodeLabel);
|
||||
button.innerHTML = buildCopyIcon();
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.dataset.codeSource = "";
|
||||
template.textContent = pre.innerText.trimEnd();
|
||||
|
||||
toolbar.append(title, button);
|
||||
panel.append(toolbar, template);
|
||||
pre.replaceWith(panel);
|
||||
panel.append(pre);
|
||||
});
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const button = event.target instanceof Element
|
||||
? event.target.closest("[data-copy-code]")
|
||||
: null;
|
||||
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
|
||||
const panel = button.closest(".code-panel");
|
||||
const source = panel?.querySelector("[data-code-source]");
|
||||
const text = source instanceof HTMLTemplateElement
|
||||
? source.content.textContent ?? ""
|
||||
: source?.textContent ?? "";
|
||||
|
||||
try {
|
||||
if (!text) throw new Error("No code content to copy");
|
||||
await writeClipboard(text);
|
||||
button.classList.add("copied");
|
||||
button.setAttribute("aria-label", copiedCodeLabel);
|
||||
} catch {
|
||||
button.classList.add("copy-failed");
|
||||
button.setAttribute("aria-label", copyCodeFailedLabel);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
button.classList.remove("copied", "copy-failed");
|
||||
button.setAttribute("aria-label", copyCodeLabel);
|
||||
}, 1600);
|
||||
});
|
||||
</script>
|
||||
</DocsLayout>
|
||||
@@ -0,0 +1,28 @@
|
||||
export type ConfigurationDocModule = {
|
||||
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 zhConfigurationDocs = import.meta.glob<ConfigurationDocModule>(
|
||||
"./content/docs/zh/configuration/*.md",
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
export const enConfigurationDocs = import.meta.glob<ConfigurationDocModule>(
|
||||
"./content/docs/en/configuration/*.md",
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
export function configurationSlugFromPath(filePath: string): string {
|
||||
const file = filePath.split("/").pop() ?? filePath;
|
||||
return file.replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Claude Code Router Detailed Configuration
|
||||
pageTitle: Detailed Configuration
|
||||
eyebrow: Detailed Configuration
|
||||
lead: "Separate main app pages from settings pages while following the app's actual order: main pages cover overview, providers, Agent Config, routing, Fusion, API keys, logs and observability, server, and extensions; settings pages cover ToolHub, Bots, data, and tray."
|
||||
---
|
||||
|
||||
## Page Structure
|
||||
|
||||
Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page. Main pages follow the app's left navigation order. Settings pages are grouped separately and follow the settings dialog order.
|
||||
|
||||
## Main Pages
|
||||
|
||||
| Page | Covers |
|
||||
| --- | --- |
|
||||
| Overview Dashboard | System status, account balance, usage widgets, layout editing, and share cards |
|
||||
| Provider Config | Upstream services, protocol, Base URL, model list, and credentials |
|
||||
| One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries |
|
||||
| Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding |
|
||||
| Routing Config | Default routing, conditional rules, fallback, and request rewrites |
|
||||
| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model |
|
||||
| API Keys | Client access keys, expiration, and local limits |
|
||||
| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results |
|
||||
| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate |
|
||||
| Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging |
|
||||
|
||||
## Settings Pages
|
||||
|
||||
| Page | Covers |
|
||||
| --- | --- |
|
||||
| ToolHub | Collapse many MCP servers into one dynamic tool resolution entry point for agents |
|
||||
| Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages |
|
||||
| Config Database Location | SQLite config database location maintained by the desktop app |
|
||||
| Tray Configuration | Tray icon, balance progress, and tray window widgets |
|
||||
|
||||
## Content Relationships
|
||||
|
||||
Overview Dashboard shows system status and usage. Provider Config and One click import cover how upstream model services enter CCR. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Routing determines where model requests go. Fusion covers vision, web search, and MCP tools. API Keys control client access to CCR. Logs & Observability cover request logs and agent execution traces. Server controls the local gateway listener and proxy features. Extension Mechanism covers local plugin creation, installation, and debugging. ToolHub, Bots, Config Database Location, and Tray Configuration match the corresponding settings pages in the settings dialog.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: API Keys
|
||||
pageTitle: API Keys
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Manage API keys that clients use to access the CCR gateway, with expiration and local limits.
|
||||
---
|
||||
|
||||
## List Fields
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Search API keys | Filters the list by key name or key value. |
|
||||
| Add API key | Opens the create dialog and generates a new client access key. |
|
||||
| Name | Display name for the key. Use it to identify a client, team, purpose, or automation. |
|
||||
| Key | Masked access key. Use `Copy API key` to copy the full key. |
|
||||
| Expires | Expiration time. After expiration, clients can no longer use the key to access CCR. |
|
||||
| Limits | Local limit summary. Shows `No limits configured` when no limits are set. |
|
||||
| Edit API key | Edits expiration and limits. The key value itself is not shown again. |
|
||||
| Remove API key | Deletes the client access key. Deleted keys stop working immediately. |
|
||||
|
||||
## Create And Edit
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Name | Display name for the new key. Examples: `Claude Code - laptop`, `CI`, or a team name. |
|
||||
| Expiration | Selects the validity period: `Never`, `7 days`, `30 days`, `90 days`, or `Custom`. |
|
||||
| Expires at | Appears for `Custom` expiration and sets the exact date and time. |
|
||||
| API key created | Confirmation dialog after creation. It displays the full key. |
|
||||
| Copy this key now. It may not be shown again. | Reminder to copy the key immediately because CCR will not show it again after the dialog closes. |
|
||||
|
||||
## Advanced Settings
|
||||
|
||||
`Advanced settings` adds local limits to a client key. When a limit is reached, requests using that key are rejected or limited; provider-side quota is not changed.
|
||||
|
||||
| Field | Capability |
|
||||
| --- | --- |
|
||||
| Advanced settings | Expands or collapses limit editing. |
|
||||
| No limits configured | The key has no local limits. |
|
||||
| Requests | Limits by request count. |
|
||||
| Tokens | Limits by token count. |
|
||||
| Images | Limits by image count. |
|
||||
| per minute | Uses a 1-minute limit window. |
|
||||
| per hour | Uses a 1-hour limit window. |
|
||||
| per day | Uses a 1-day limit window. |
|
||||
| Add limit | Adds one limit rule. |
|
||||
| Remove limit | Removes the current limit rule. |
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Setup
|
||||
pageTitle: Bot Setup
|
||||
eyebrow: Bots
|
||||
lead: Add a Bot, bind it to Agent Config, and choose message forwarding or handoff.
|
||||
---
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open **Bot Management** and click **Add Bot**.
|
||||
2. Select a platform and fill in the required token, secret, signing secret, robot code, or OAuth fields.
|
||||
3. Save the Bot.
|
||||
4. Open the target **Agent Config** and enable **Bot**.
|
||||
5. Choose **Forward agent messages** or **Handoff**, then reopen the agent from CCR.
|
||||
|
||||
## Verification
|
||||
|
||||
Open the agent from CCR and send a test message. Confirm that Logs include Bot records and the IM side receives the message.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Bots And IM Agent Relay
|
||||
pageTitle: Bots And IM Agent Relay
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Forward agent messages to instant-messaging platforms or hand off active work after desktop idle.
|
||||
---
|
||||
|
||||
## Common Modes
|
||||
|
||||
- **Forward agent messages**: mirror agent messages into IM.
|
||||
- **Handoff**: relay the interaction into IM after desktop idle.
|
||||
|
||||
## Platform Pages
|
||||
|
||||
Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, and DingTalk each have a dedicated page.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Config Database Location
|
||||
pageTitle: Config Database Location
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Locate the SQLite configuration database maintained by the CCR desktop app.
|
||||
---
|
||||
|
||||
## Default Locations
|
||||
|
||||
- **macOS/Linux**: `~/.claude-code-router/config.sqlite`
|
||||
- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite`
|
||||
|
||||
## Applying Changes
|
||||
|
||||
CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once as a migration source when no SQLite config exists; after migration, editing `config.json` does not affect the current configuration.
|
||||
|
||||
Use the desktop UI to change configuration, or export a backup from **Settings**. Do not edit `config.sqlite` directly while CCR is running; SQLite also maintains companion `config.sqlite-wal` and `config.sqlite-shm` files in the same directory.
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
title: Extension Mechanism
|
||||
pageTitle: Extension Mechanism
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Learn how CCR extensions are loaded, what they can register, and how to create, install, and debug your own extension.
|
||||
---
|
||||
|
||||
## Extension Types
|
||||
|
||||
CCR has two extension layers:
|
||||
|
||||
| Type | Config location | Runtime | Good for |
|
||||
| --- | --- | --- | --- |
|
||||
| Wrapper plugin | `plugins` | CCR Desktop's Electron wrapper process | Local HTTP routes, local backends, proxy capture routing, built-in browser entries, provider account meters |
|
||||
| Core gateway plugin | `providerPlugins` or `plugins[].coreGateway.providerPlugins` | core gateway runtime | Provider, auth, or internal core gateway behavior |
|
||||
|
||||
Most custom extensions should start as a Wrapper plugin. It receives CCR config, a private data directory, a logger, and registration helpers through `ctx`.
|
||||
|
||||
## Loading Flow
|
||||
|
||||
When the gateway starts, CCR reads the `plugins` array and processes each plugin whose `enabled !== false`:
|
||||
|
||||
1. It first applies `apps`, `proxy.routes`, `coreGateway.providerPlugins`, `coreGateway.virtualModelProfiles`, and `coreGateway.config` declared in config.
|
||||
2. It then loads the plugin module. `module` can be an absolute path, a `~/` path, a `./...` path relative to the CCR config directory, or a Node-resolvable package name.
|
||||
3. If `module` is missing, CCR tries to match the plugin `id` with built-in marketplace plugins such as `claude-design` and `cursor-proxy`.
|
||||
4. A module can export a function or an object with `setup(ctx)` or `activate(ctx)`.
|
||||
5. On stop, CCR runs `stop` and `onStop` hooks in reverse order, then closes HTTP backends and SQLite stores registered by the plugin.
|
||||
|
||||
Common module shape:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("extension loaded");
|
||||
},
|
||||
async stop() {
|
||||
// Optional: release resources owned by the extension.
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
You can also export the setup function directly:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = async function setup(ctx) {
|
||||
ctx.logger.info(`loaded ${ctx.pluginId}`);
|
||||
};
|
||||
```
|
||||
|
||||
`setup(ctx)` or `activate(ctx)` can call `ctx.register...` methods directly, or return a registration object. Returned registrations support `apps`, `gatewayRoutes`, `proxyRoutes`, `providerAccountConnectors`, `coreGateway`, `virtualModelProfiles`, `stop`, and `onStop`.
|
||||
|
||||
## ctx Reference
|
||||
|
||||
`setup(ctx)` receives these common fields and helpers:
|
||||
|
||||
| Field or method | Description |
|
||||
| --- | --- |
|
||||
| `ctx.pluginId` | Current plugin ID |
|
||||
| `ctx.pluginConfig` | Custom value from `plugins[].config` |
|
||||
| `ctx.config` | Current CCR AppConfig snapshot |
|
||||
| `ctx.logger` | `debug/info/warn/error` logger prefixed with `[plugin:<id>]` |
|
||||
| `ctx.paths.configDir` | CCR config directory |
|
||||
| `ctx.paths.dataDir` | CCR data directory |
|
||||
| `ctx.paths.pluginDataDir` | Private data directory for this plugin |
|
||||
| `ctx.registerGatewayRoute(route)` | Register a local HTTP route on the CCR gateway |
|
||||
| `ctx.registerHttpBackend(backend)` | Start a local HTTP backend and return `{ url, host, port }` |
|
||||
| `ctx.registerProxyRoute(route)` | Route proxy-captured host/path traffic to a plugin backend or another upstream |
|
||||
| `ctx.registerApp(app)` | Add an entry to the built-in browser app list |
|
||||
| `ctx.openSqliteStore(options)` | Open a SQLite store under the plugin data directory |
|
||||
| `ctx.registerProviderAccountConnector(connector)` | Register a provider balance or quota connector |
|
||||
| `ctx.registerCoreGatewayProviderPlugin(plugin)` | Inject a provider plugin into the core gateway |
|
||||
| `ctx.registerCoreGatewayVirtualModelProfile(profile)` | Inject a virtual model profile into the core gateway |
|
||||
|
||||
Gateway route handlers also receive helper functions:
|
||||
|
||||
| Helper | Description |
|
||||
| --- | --- |
|
||||
| `helpers.readBody(request)` | Read request body as a `Buffer` |
|
||||
| `helpers.readJson(request)` | Read and parse JSON request body |
|
||||
| `helpers.sendJson(response, statusCode, body)` | Send a JSON response |
|
||||
|
||||
`registerGatewayRoute` defaults to `auth: "gateway"`. If CCR has API keys configured, requests must include `Authorization: Bearer <key>` or `x-api-key: <key>`. Use `auth: "none"` only for debugging or local public status routes.
|
||||
|
||||
## Create Your First Extension
|
||||
|
||||
Create a directory such as `~/ccr-extensions/hello-extension`:
|
||||
|
||||
```text
|
||||
hello-extension/
|
||||
plugin.json
|
||||
index.cjs
|
||||
```
|
||||
|
||||
`plugin.json` lets CCR's local extension picker discover the extension ID, name, and entrypoint:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hello-extension",
|
||||
"name": "Hello Extension",
|
||||
"module": "index.cjs",
|
||||
"apps": [
|
||||
{
|
||||
"id": "hello-status",
|
||||
"name": "Hello Status",
|
||||
"url": "http://127.0.0.1:3456/plugins/hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`index.cjs` registers a status route, an echo backend, and one proxy route:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async setup(ctx) {
|
||||
ctx.registerGatewayRoute({
|
||||
auth: "none",
|
||||
id: "hello-status",
|
||||
method: "GET",
|
||||
path: "/plugins/hello",
|
||||
handler(_request, response, helpers) {
|
||||
helpers.sendJson(response, 200, {
|
||||
ok: true,
|
||||
plugin: ctx.pluginId,
|
||||
message: ctx.pluginConfig?.message || "hello from CCR"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const backend = await ctx.registerHttpBackend({
|
||||
id: "hello-echo",
|
||||
async handler(request, response, helpers) {
|
||||
const body = request.method === "POST"
|
||||
? (await helpers.readBody(request)).toString("utf8")
|
||||
: "";
|
||||
|
||||
helpers.sendJson(response, 200, {
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
body
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.registerProxyRoute({
|
||||
host: "api.example.local",
|
||||
id: "hello-example-api",
|
||||
paths: ["/v1"],
|
||||
preserveHost: true,
|
||||
upstream: backend.url
|
||||
});
|
||||
|
||||
ctx.logger.info(`hello backend listening at ${backend.url}`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This exposes:
|
||||
|
||||
- `GET /plugins/hello`: a route mounted directly on the CCR gateway to verify that the plugin loaded.
|
||||
- A local echo backend: CCR assigns a free port automatically.
|
||||
- A proxy rule: proxy-captured `api.example.local/v1...` traffic is forwarded to the echo backend.
|
||||
|
||||
## Install The Extension
|
||||
|
||||
The recommended flow is through the desktop UI:
|
||||
|
||||
1. Open **Extensions**.
|
||||
2. Add an extension and choose a local extension directory.
|
||||
3. Select the `hello-extension` directory.
|
||||
4. Save the config.
|
||||
5. Open **Server** and restart the gateway.
|
||||
|
||||
CCR stores runtime configuration in SQLite. Add extensions through the UI; the legacy JSON config file is kept here only as a reference. The extension entry has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "hello-extension",
|
||||
"enabled": true,
|
||||
"module": "/Users/you/ccr-extensions/hello-extension/index.cjs",
|
||||
"config": {
|
||||
"message": "hello from my config"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway after saving the extension config. See [Config Database Location](/en/configuration/configuration-file/).
|
||||
|
||||
The local directory picker recognizes entry metadata from:
|
||||
|
||||
- `plugin.json`
|
||||
- `ccr-plugin.json`
|
||||
- `.ccr-plugin/plugin.json`
|
||||
- `.codex-plugin/plugin.json`
|
||||
- `main`, `ccr.module`, or `ccrPlugin.module` in `package.json`
|
||||
|
||||
If no entrypoint is declared, CCR tries `index.cjs`, `index.mjs`, `index.js`, `plugin.cjs`, `plugin.mjs`, or `plugin.js` in the selected directory.
|
||||
|
||||
## Debug Extensions
|
||||
|
||||
### 1. Check syntax first
|
||||
|
||||
For CommonJS extensions:
|
||||
|
||||
```bash
|
||||
node --check ~/ccr-extensions/hello-extension/index.cjs
|
||||
```
|
||||
|
||||
If the extension depends on npm packages, install them in the extension directory and make sure Node can resolve the entrypoint.
|
||||
|
||||
### 2. Start CCR from source
|
||||
|
||||
From the CCR repository root:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`ctx.logger.info/warn/error` output appears in the terminal that started CCR, with a prefix such as `[plugin:hello-extension]`.
|
||||
|
||||
### 3. Verify the Gateway route
|
||||
|
||||
After the gateway starts, request the status route:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
If the route uses the default `auth: "gateway"` and CCR has API keys configured:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
You can also use:
|
||||
|
||||
```bash
|
||||
curl -H "x-api-key: <CCR_API_KEY>" http://127.0.0.1:3456/plugins/hello
|
||||
```
|
||||
|
||||
### 4. Verify the HTTP backend and proxy route
|
||||
|
||||
`registerHttpBackend` returns `backend.url`, which the example writes to logs. Request that URL directly first, then enable proxy mode and verify whether the target host/path hits `registerProxyRoute`.
|
||||
|
||||
Proxy route matching rules:
|
||||
|
||||
- `host` must match the target hostname. Exact host, `.example.com` suffix, and `*.example.com` wildcard patterns are supported.
|
||||
- Empty `paths` matches all paths for that host.
|
||||
- When multiple paths match, CCR chooses the longest path prefix.
|
||||
- `stripPathPrefix` removes the matched prefix from the forwarded path.
|
||||
- `rewritePathPrefix` replaces the matched prefix with a configured prefix.
|
||||
|
||||
### 5. Common Issues
|
||||
|
||||
| Symptom | What to check |
|
||||
| --- | --- |
|
||||
| Extension does not load | Check `plugins[].enabled`, `plugins[].module`, and terminal errors prefixed with `[plugin:<id>]` |
|
||||
| `GET /plugins/hello` returns 404 | Restart the gateway and confirm `path` or `pathPrefix` starts with `/` |
|
||||
| Response is 401 | Routes require gateway API key by default; set `auth: "none"` for debug routes |
|
||||
| Code changes do not apply | Wrapper plugins are not hot reloaded; restart the gateway or CCR |
|
||||
| Port is already in use | Omit `port` in `registerHttpBackend` so CCR can allocate one automatically |
|
||||
| Proxy route misses requests | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Use `auth: "none"` only for status pages, health checks, or local debugging routes.
|
||||
- Do not log API keys, OAuth tokens, cookies, or complete request headers.
|
||||
- Prefer `ctx.paths.pluginDataDir` for files written by your extension.
|
||||
- Validate all external input returned by `readJson`.
|
||||
- When proxying to external upstreams, handle headers explicitly so local auth material is not forwarded to untrusted services.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: Custom MCP Tool
|
||||
pageTitle: Custom MCP Tool
|
||||
eyebrow: Fusion
|
||||
lead: Connect local or remote MCP tools to a Fusion model.
|
||||
---
|
||||
|
||||
## Entry Point
|
||||
|
||||
Click **Add custom MCP** and select a transport.
|
||||
|
||||
## Transports
|
||||
|
||||
- **stdio**: local command-line tools.
|
||||
- **streamable-http / sse**: remote MCP services.
|
||||
- **Discover tools**: read tools exposed by the MCP server.
|
||||
|
||||
## Verification
|
||||
|
||||
Validate risky or slow tools in a dedicated Agent Config before using them in production routing.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Fusion Models
|
||||
pageTitle: Fusion Models
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Combine a base model with capability models or tools, turning a stable text model into an enhanced model that can see images, search the web, or call tools.
|
||||
---
|
||||
|
||||
## How Fusion Works
|
||||
|
||||
Fusion keeps the base model's reasoning, writing, and coding behavior, then adds the missing capability layer. When needed, CCR calls the vision, search, or MCP tool, organizes the result into context, and lets the base model produce the final answer.
|
||||
|
||||
After saving, a Fusion model appears in routing and Agent Config like a normal model. Treat it as a reusable new model: upgrade a strong text model into a vision model, a stable coding model into a web-search model, or an Agent model into one that can call internal tools.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- **Built-In Vision**: give a non-multimodal model visual ability, for example `GLM-5.2 + GLM-5V-Turbo = GLM-5.2V`.
|
||||
- **Built-In Web Search**: give a model live retrieval capability and bring fresh information into context.
|
||||
- **Custom MCP Tool**: wrap local scripts, internal systems, or remote services as model-callable tools.
|
||||
|
||||
## Composition Examples
|
||||
|
||||
Use names that make the capability source obvious:
|
||||
|
||||
- `GLM-5.2 + GLM-5V-Turbo = GLM-5.2V`
|
||||
- `Coding model + Web Search = coding model with live retrieval`
|
||||
- `General model + custom MCP tool = Agent model that can access internal systems`
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Built-In Vision
|
||||
pageTitle: Built-In Vision
|
||||
eyebrow: Fusion
|
||||
lead: Give a non-multimodal model visual ability, for example GLM-5.2 + GLM-5V-Turbo = GLM-5.2V.
|
||||
---
|
||||
|
||||
## Capability Composition
|
||||
|
||||
Built-in Vision connects a vision model in front of the base model without replacing the text model you already trust. The vision model understands images, screenshots, charts, and OCR content; CCR passes that visual result to the base model, which continues to handle reasoning, writing, coding, and final output.
|
||||
|
||||
The combined Fusion model can be selected by routing or Agent Config like any other model. A typical form is:
|
||||
|
||||
```text
|
||||
GLM-5.2 + GLM-5V-Turbo = GLM-5.2V
|
||||
```
|
||||
|
||||
This keeps the familiar text model while adding visual input support, so a non-multimodal model can still work with image context.
|
||||
|
||||
This also applies to Codex computer use. After combining GLM-5.2 with GLM-5V-Turbo, GLM-5.2 can receive screen, screenshot, and UI information prepared by the vision model, then use Codex computer use for observation, judgment, and follow-up action planning.
|
||||
|
||||
## Select The Capability
|
||||
|
||||
Select `ccr-fusion-builtins / vision_understand`, and choose a Vision model that actually supports image understanding.
|
||||
|
||||
## Model Requirement
|
||||
|
||||
The Vision model determines image, screenshot, chart, and OCR understanding quality. The base model determines the final answer style, reasoning ability, and coding ability.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
When image requests fail, relevant details include whether the Vision model supports visual input and any Fusion tool errors in Logs.
|
||||