mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
feat: bundle Claude Design for desktop app
This commit is contained in:
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyMarketplacePlugins();
|
||||
copyBundledClaudeRuntimePlugins();
|
||||
copyModelCatalog();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
|
||||
+5
-2
@@ -12,8 +12,8 @@ import {
|
||||
coreSourceRoot,
|
||||
copyAppAssets,
|
||||
copyBrowserRendererHtml,
|
||||
copyBundledClaudeRuntimePlugins,
|
||||
copyCliRuntimeToElectronDist,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
createTrayRendererBuildOptions,
|
||||
createWebClientBridgeBuildOptions,
|
||||
appAssetsInput,
|
||||
bundledClaudeRuntimePluginsInputDir,
|
||||
modelCatalogInput,
|
||||
projectRoot,
|
||||
rendererRoot,
|
||||
@@ -286,6 +287,7 @@ function pollSourceWatchTargets() {
|
||||
});
|
||||
if (enabled.electron) {
|
||||
pollWatchedInput("app assets", appAssetsInput, copyAppAssets);
|
||||
pollWatchedInput("bundled Claude runtime plugins", bundledClaudeRuntimePluginsInputDir, copyBundledClaudeRuntimePlugins);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true });
|
||||
@@ -398,9 +400,9 @@ logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} c
|
||||
cleanDist();
|
||||
if (enabled.electron) {
|
||||
copyAppAssets();
|
||||
copyBundledClaudeRuntimePlugins();
|
||||
}
|
||||
if (enabled.cli || enabled.electron) {
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
}
|
||||
copyBrowserRendererHtml();
|
||||
@@ -417,6 +419,7 @@ for (const styleWatchRoot of styleWatchRoots) {
|
||||
}
|
||||
if (enabled.electron) {
|
||||
rememberWatchSignature("app assets", appAssetsInput);
|
||||
rememberWatchSignature("bundled Claude runtime plugins", bundledClaudeRuntimePluginsInputDir);
|
||||
}
|
||||
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
|
||||
rememberWatchSignature("model catalog", modelCatalogInput, { metadataOnly: true });
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
buildWebClientBridge,
|
||||
cleanDist,
|
||||
copyBrowserRendererHtml,
|
||||
copyMarketplacePlugins,
|
||||
copyModelCatalog,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
cleanDist();
|
||||
copyMarketplacePlugins();
|
||||
copyModelCatalog();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
|
||||
+21
-48
@@ -1,5 +1,5 @@
|
||||
import esbuild from "esbuild";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
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";
|
||||
@@ -9,7 +9,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
|
||||
export const projectRoot = path.resolve(__dirname, "..");
|
||||
export const ccrExtensionsRoot = path.resolve(process.env.CCR_EXTENSIONS_DIR || path.join(projectRoot, "..", "ccr-extensions"));
|
||||
export const packagesRoot = path.join(projectRoot, "packages");
|
||||
export const cliRoot = path.join(packagesRoot, "cli");
|
||||
export const coreRoot = path.join(packagesRoot, "core");
|
||||
@@ -48,11 +47,9 @@ 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 marketplacePluginsInputDir = path.join(ccrExtensionsRoot, "plugins");
|
||||
export const bundledClaudeRuntimePluginIds = ["claude-design", "claude-ship"];
|
||||
export const bundledClaudeRuntimePluginsInputDir = path.join(electronRoot, "bundled-plugins");
|
||||
export const electronBundledRuntimePluginsDir = path.join(electronDistDir, "bundled-plugins");
|
||||
export const appAssetsInput = path.join(electronRoot, "assets");
|
||||
export const modelCatalogInput = path.join(coreRoot, "models.json");
|
||||
export const cliModelCatalogOutput = path.join(cliDistDir, "models.json");
|
||||
@@ -106,9 +103,7 @@ export function ensureDist() {
|
||||
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(electronBundledRuntimePluginsDir, { recursive: true });
|
||||
mkdirSync(rendererAssetsDir, { recursive: true });
|
||||
for (const outputDir of runtimeRendererOutDirs) {
|
||||
mkdirSync(path.join(outputDir, "assets"), { recursive: true });
|
||||
@@ -148,10 +143,11 @@ export function copyBrowserRendererHtml() {
|
||||
copyRendererPageHtml(browserRendererHtmlInput, browserRendererHtmlOutput, "browser.js");
|
||||
}
|
||||
|
||||
export function copyMarketplacePlugins() {
|
||||
export function copyBundledClaudeRuntimePlugins() {
|
||||
ensureDist();
|
||||
buildMarketplacePlugin("agent-console");
|
||||
copyMarketplacePlugin("agent-console");
|
||||
for (const pluginId of bundledClaudeRuntimePluginIds) {
|
||||
copyBundledClaudeRuntimePlugin(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUiRendererToRuntimeDists() {
|
||||
@@ -186,43 +182,20 @@ function copyRendererPageHtml(input, output, scriptName, options = {}) {
|
||||
writeFileSync(output, html, "utf8");
|
||||
}
|
||||
|
||||
function buildMarketplacePlugin(pluginId) {
|
||||
const pluginRoot = path.join(marketplacePluginsInputDir, pluginId);
|
||||
const packageJson = path.join(pluginRoot, "package.json");
|
||||
if (!existsSync(packageJson)) {
|
||||
return;
|
||||
function copyBundledClaudeRuntimePlugin(pluginId) {
|
||||
const inputDir = path.join(bundledClaudeRuntimePluginsInputDir, pluginId);
|
||||
const outputDir = path.join(electronBundledRuntimePluginsDir, pluginId);
|
||||
const moduleInput = path.join(inputDir, "index.cjs");
|
||||
if (!existsSync(moduleInput)) {
|
||||
throw new Error(`Bundled Claude runtime plugin ${pluginId} is missing: ${moduleInput}`);
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const result = spawnSync(npmCommand, ["run", "build"], {
|
||||
cwd: pluginRoot,
|
||||
shell: false,
|
||||
stdio: "inherit"
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Plugin ${pluginId} build failed with exit code ${result.status ?? "unknown"}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyMarketplacePlugin(pluginId) {
|
||||
const pluginRoot = path.join(marketplacePluginsInputDir, pluginId);
|
||||
const outputRoots = [cliMarketplacePluginsDir, coreMarketplacePluginsDir, electronMarketplacePluginsDir];
|
||||
const runtimeFiles = ["plugin.json", "index.cjs"];
|
||||
const rendererInput = path.join(pluginRoot, "dist", "renderer");
|
||||
for (const outputRoot of outputRoots) {
|
||||
const outputDir = path.join(outputRoot, pluginId);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
for (const fileName of runtimeFiles) {
|
||||
const input = path.join(pluginRoot, fileName);
|
||||
if (existsSync(input)) {
|
||||
cpSync(input, path.join(outputDir, fileName));
|
||||
}
|
||||
}
|
||||
if (existsSync(rendererInput)) {
|
||||
cpSync(rendererInput, path.join(outputDir, "dist", "renderer"), { recursive: true });
|
||||
rmSync(outputDir, { force: true, recursive: true });
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
for (const fileName of ["index.cjs", "plugin.json", "README.md"]) {
|
||||
const input = path.join(inputDir, fileName);
|
||||
if (existsSync(input)) {
|
||||
cpSync(input, path.join(outputDir, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"productName": "Claude Code Router",
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"**/*.node"
|
||||
"**/*.node",
|
||||
"dist/bundled-plugins/**"
|
||||
],
|
||||
"electronLanguages": ["en-US", "zh-CN", "zh-TW", "zh_CN", "zh_TW"],
|
||||
"npmRebuild": true,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
OIDC_CLIENT_SECRET=local-client-secret
|
||||
GATEWAY_JWT_SECRET=0123456789abcdefghijklmnopqrstuvwxyzABCDEF
|
||||
GATEWAY_POSTGRES_URL=postgres://gw:pw@postgres:5432/gateway?sslmode=disable
|
||||
ANTHROPIC_API_KEY=sk-ant-local-dev-not-used
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& install -d -m 0755 /etc/apt/keyrings \
|
||||
&& curl -fsSL https://downloads.claude.ai/keys/claude-code.asc -o /etc/apt/keyrings/claude-code.asc \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/latest latest main" > /etc/apt/sources.list.d/claude-code.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends claude-code \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV CLAUDE_CONFIG_DIR=/tmp/.claude
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["claude", "gateway", "--config", "/etc/claude/gateway.yaml"]
|
||||
@@ -1,30 +0,0 @@
|
||||
# Local Claude Apps Gateway
|
||||
|
||||
This is a local smoke-test setup for Claude apps gateway:
|
||||
|
||||
- `gateway` runs `claude gateway --config /etc/claude/gateway.yaml`.
|
||||
- `postgres` stores device grants and rate-limit state.
|
||||
- `dex` is a local OIDC provider so the login flow can be tested without a corporate IdP.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/healthz
|
||||
curl -s http://localhost:8080/.well-known/oauth-authorization-server
|
||||
curl -s -X POST http://localhost:8080/oauth/device_authorization
|
||||
```
|
||||
|
||||
Local Dex user:
|
||||
|
||||
- Email: `dev@example.com`
|
||||
- Password: `password`
|
||||
|
||||
The Anthropic upstream uses a dummy key so the gateway can boot and the SSO surface can be verified. Replace `ANTHROPIC_API_KEY` in `.env` with a real key, or replace the `upstreams` block in `gateway.yaml` with your Bedrock, Claude Platform on AWS, Vertex, Foundry, or CCR upstream before testing inference.
|
||||
|
||||
To point a local Claude Code client at this gateway, install the contents of `managed-settings.local.json` as the OS-level managed settings file. On macOS that path is `/Library/Application Support/ClaudeCode/managed-settings.json`, which usually requires admin permissions.
|
||||
@@ -1,27 +0,0 @@
|
||||
issuer: http://127.0.0.1:5556/dex
|
||||
|
||||
storage:
|
||||
type: memory
|
||||
|
||||
web:
|
||||
http: 0.0.0.0:5556
|
||||
|
||||
oauth2:
|
||||
skipApprovalScreen: true
|
||||
|
||||
staticClients:
|
||||
- id: claude-gateway-local
|
||||
name: Claude Gateway Local
|
||||
secret: local-client-secret
|
||||
redirectURIs:
|
||||
- http://localhost:8080/oauth/callback
|
||||
|
||||
enablePasswordDB: true
|
||||
|
||||
staticPasswords:
|
||||
- email: dev@example.com
|
||||
hash: "$2a$10$klluJBD.Yrmd0qbaUoM8VOy8QklhO/83qprudC1XMnMOgESwIfvCq"
|
||||
username: dev
|
||||
userID: "11111111-1111-1111-1111-111111111111"
|
||||
groups:
|
||||
- developers
|
||||
@@ -1,46 +0,0 @@
|
||||
services:
|
||||
dex:
|
||||
image: ghcr.io/dexidp/dex:v2.43.1
|
||||
command: ["dex", "serve", "/etc/dex/config.yaml"]
|
||||
ports:
|
||||
- "5556:5556"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./dex.yaml:/etc/dex/config.yaml:ro
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: gw
|
||||
POSTGRES_PASSWORD: pw
|
||||
POSTGRES_DB: gateway
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gw -d gateway"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: .
|
||||
image: claude-gateway-local:latest
|
||||
network_mode: service:dex
|
||||
depends_on:
|
||||
dex:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./gateway.yaml:/etc/claude/gateway.yaml:ro
|
||||
environment:
|
||||
CLAUDE_GATEWAY_ALLOW_LOOPBACK: "1"
|
||||
CLAUDE_GATEWAY_LOG_LEVEL: info
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
GATEWAY_JWT_SECRET: ${GATEWAY_JWT_SECRET}
|
||||
GATEWAY_POSTGRES_URL: ${GATEWAY_POSTGRES_URL}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -1,45 +0,0 @@
|
||||
listen:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
public_url: http://localhost:8080
|
||||
|
||||
oidc:
|
||||
issuer: http://127.0.0.1:5556/dex
|
||||
client_id: claude-gateway-local
|
||||
client_secret: ${OIDC_CLIENT_SECRET}
|
||||
allowed_email_domains:
|
||||
- example.com
|
||||
userinfo_fallback: true
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
- offline_access
|
||||
- groups
|
||||
|
||||
session:
|
||||
jwt_secret: ${GATEWAY_JWT_SECRET}
|
||||
ttl_hours: 1
|
||||
|
||||
store:
|
||||
postgres_url: ${GATEWAY_POSTGRES_URL}
|
||||
|
||||
upstreams:
|
||||
- provider: anthropic
|
||||
auth:
|
||||
api_key: ${ANTHROPIC_API_KEY}
|
||||
|
||||
auto_include_builtin_models: false
|
||||
|
||||
models:
|
||||
- id: claude-sonnet-4-6
|
||||
upstream_model:
|
||||
anthropic: claude-sonnet-4-6
|
||||
|
||||
managed:
|
||||
policies:
|
||||
- match: {}
|
||||
cli:
|
||||
availableModels:
|
||||
- claude-sonnet-4-6
|
||||
enforceAvailableModels: true
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"forceLoginMethod": "gateway",
|
||||
"forceLoginGatewayUrl": "http://localhost:8080"
|
||||
}
|
||||
@@ -37,7 +37,6 @@
|
||||
"build": "node build/build.mjs && electron-builder",
|
||||
"build:assets": "node build/build.mjs",
|
||||
"build:docker": "node build/docker-build.mjs",
|
||||
"build:plugin:agent-console": "npm --prefix ../ccr-extensions/plugins/agent-console run build",
|
||||
"build:app:mac": "npm run build:app:mac:local",
|
||||
"build:app:mac:local": "npm run build:assets && electron-builder --config build/electron-builder.local.cjs --mac --publish never",
|
||||
"build:app:mac:release": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --publish never",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
|
||||
import { maxRequestLogBodyBytes } from "@ccr/core/observability/request-log-limits";
|
||||
import { findProviderPresetByBaseUrl, primaryProviderPresetEndpoint, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
|
||||
import { isDesktopAppRuntime } from "@ccr/core/runtime/desktop-app";
|
||||
import type {
|
||||
AppConfig,
|
||||
ApiKeyConfig,
|
||||
@@ -2484,7 +2485,7 @@ type GatewayPluginMigrationResult = {
|
||||
plugins: GatewayPluginConfig[];
|
||||
};
|
||||
|
||||
const CCR_EXTENSIONS_PLUGIN_IDS = new Set(["agent-console", CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, "cursor-proxy"]);
|
||||
const CCR_EXTENSIONS_PLUGIN_IDS = new Set([CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, "cursor-proxy"]);
|
||||
|
||||
function migrateKnownGatewayPluginConfigs(plugins: GatewayPluginConfig[] | undefined): GatewayPluginMigrationResult {
|
||||
const sourcePlugins = plugins ?? [];
|
||||
@@ -2736,31 +2737,16 @@ function migrateClaudeShipPluginApps(apps: GatewayPluginAppConfig[] | undefined)
|
||||
};
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const migratedApps = apps.map((app) => {
|
||||
if (!isLegacyClaudeShipAppUrl(app.url)) {
|
||||
return app;
|
||||
}
|
||||
const defaultApp = shipDefaults.find((item) => item.id === (app.id || "claude-ship")) ?? shipDefaults[0];
|
||||
if (!defaultApp) {
|
||||
return app;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...app,
|
||||
url: defaultApp.url
|
||||
};
|
||||
});
|
||||
return {
|
||||
apps: migratedApps,
|
||||
changed
|
||||
apps,
|
||||
changed: false
|
||||
};
|
||||
}
|
||||
|
||||
function migratedClaudeShipPluginConfig(source: GatewayPluginConfig): GatewayPluginConfig | undefined {
|
||||
const modulePath = isLegacyClaudeDesignModule(source.module)
|
||||
? migratedClaudePluginModulePath(CLAUDE_SHIP_PLUGIN_ID, source.module)
|
||||
: resolveCcrExtensionsPluginModule(CLAUDE_SHIP_PLUGIN_ID, source.module);
|
||||
: resolveBundledOrExternalizedPluginModule(CLAUDE_SHIP_PLUGIN_ID, source.module);
|
||||
if (!modulePath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -2777,7 +2763,10 @@ function migratedClaudeShipPluginConfig(source: GatewayPluginConfig): GatewayPlu
|
||||
}
|
||||
|
||||
export function claudeDesignRuntimePluginConfig(): GatewayPluginConfig | undefined {
|
||||
const modulePath = resolveCcrExtensionsPluginModule(CLAUDE_DESIGN_PLUGIN_ID, undefined);
|
||||
if (!isDesktopAppRuntime()) {
|
||||
return undefined;
|
||||
}
|
||||
const modulePath = resolveBundledOrExternalizedPluginModule(CLAUDE_DESIGN_PLUGIN_ID, undefined);
|
||||
if (!modulePath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -2792,12 +2781,32 @@ export function claudeDesignRuntimePluginConfig(): GatewayPluginConfig | undefin
|
||||
}
|
||||
|
||||
export function withClaudeDesignRuntimePluginConfig(config: AppConfig): AppConfig {
|
||||
if (config.plugins.some((plugin) => plugin.enabled !== false && plugin.id === CLAUDE_DESIGN_PLUGIN_ID)) {
|
||||
const existingIndex = config.plugins.findIndex((plugin) => plugin.enabled !== false && plugin.id === CLAUDE_DESIGN_PLUGIN_ID);
|
||||
if (existingIndex >= 0 && config.plugins[existingIndex]?.module?.trim()) {
|
||||
return config;
|
||||
}
|
||||
if (!isDesktopAppRuntime()) {
|
||||
throw new Error("Claude Design is only available in CCR Desktop.");
|
||||
}
|
||||
const plugin = claudeDesignRuntimePluginConfig();
|
||||
if (!plugin) {
|
||||
throw new Error("Claude Design runtime module was not found. Set CCR_EXTENSIONS_DIR or keep ccr-extensions next to the CCR checkout.");
|
||||
throw new Error("Claude Design runtime module was not found. Rebuild app assets so the bundled Claude Design plugin is copied into the Electron dist.");
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
const existing = config.plugins[existingIndex];
|
||||
const plugins = [...config.plugins];
|
||||
plugins[existingIndex] = {
|
||||
...plugin,
|
||||
...existing,
|
||||
apps: existing.apps ?? plugin.apps,
|
||||
module: plugin.module,
|
||||
permissions: existing.permissions ?? plugin.permissions,
|
||||
surfaces: existing.surfaces ?? plugin.surfaces
|
||||
};
|
||||
return {
|
||||
...config,
|
||||
plugins
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
@@ -2821,41 +2830,31 @@ function isLegacyClaudeDesignAppUrl(value: string): boolean {
|
||||
if (host === "claude.ai") {
|
||||
return pathname === "/design" || pathname === "/discover/design";
|
||||
}
|
||||
if (host === "claude-design-assets.pages.dev") {
|
||||
return pathname === "/discover/design";
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyClaudeShipAppUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value, "https://claude.ai");
|
||||
const host = url.hostname.toLowerCase();
|
||||
const pathname = url.pathname.replace(/\/$/, "");
|
||||
return host === "claude-design-assets.pages.dev" && pathname === "/claude-ship";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function migratedClaudePluginModulePath(pluginId: string, previousModule: string | undefined): string {
|
||||
if (!isLegacyClaudeDesignModule(previousModule)) {
|
||||
return previousModule || "";
|
||||
}
|
||||
return resolveCcrExtensionsPluginModule(pluginId, previousModule) || previousModule || "";
|
||||
return resolveBundledOrExternalizedPluginModule(pluginId, previousModule) || previousModule || "";
|
||||
}
|
||||
|
||||
function migratedExternalizedPluginModulePath(pluginId: string, previousModule: string | undefined): string {
|
||||
if (!isLegacyExternalizedPluginModule(pluginId, previousModule)) {
|
||||
return previousModule || "";
|
||||
}
|
||||
return resolveCcrExtensionsPluginModule(pluginId, previousModule) || previousModule || "";
|
||||
return resolveBundledOrExternalizedPluginModule(pluginId, previousModule) || previousModule || "";
|
||||
}
|
||||
|
||||
function resolveCcrExtensionsPluginModule(pluginId: string, previousModule: string | undefined): string {
|
||||
function resolveBundledOrExternalizedPluginModule(pluginId: string, previousModule: string | undefined): string {
|
||||
const bundledModule = resolveBundledRuntimePluginModule(pluginId);
|
||||
if (bundledModule) {
|
||||
return bundledModule;
|
||||
}
|
||||
for (const root of ccrExtensionsRootCandidates(previousModule)) {
|
||||
const candidate = path.join(root, "plugins", pluginId, "index.cjs");
|
||||
if (existsSync(candidate)) {
|
||||
@@ -2865,6 +2864,40 @@ function resolveCcrExtensionsPluginModule(pluginId: string, previousModule: stri
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveBundledRuntimePluginModule(pluginId: string): string {
|
||||
if (!isDesktopBundledClaudeRuntimePlugin(pluginId)) {
|
||||
return "";
|
||||
}
|
||||
for (const candidate of bundledRuntimePluginModuleCandidates(pluginId)) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function isDesktopBundledClaudeRuntimePlugin(pluginId: string): boolean {
|
||||
return isDesktopAppRuntime() && (pluginId === CLAUDE_DESIGN_PLUGIN_ID || pluginId === CLAUDE_SHIP_PLUGIN_ID);
|
||||
}
|
||||
|
||||
function bundledRuntimePluginModuleCandidates(pluginId: string): string[] {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
const resourceCandidates = resourcesPath
|
||||
? [
|
||||
path.join(resourcesPath, "app.asar.unpacked", "dist", "bundled-plugins", pluginId, "index.cjs"),
|
||||
path.join(resourcesPath, "app.asar", "dist", "bundled-plugins", pluginId, "index.cjs"),
|
||||
path.join(resourcesPath, "app", "dist", "bundled-plugins", pluginId, "index.cjs")
|
||||
]
|
||||
: [];
|
||||
return uniqueStrings([
|
||||
...resourceCandidates,
|
||||
path.join(__dirname, "..", "bundled-plugins", pluginId, "index.cjs"),
|
||||
path.resolve(__dirname, "..", "..", "..", "electron", "bundled-plugins", pluginId, "index.cjs"),
|
||||
path.resolve(process.cwd(), "packages", "electron", "dist", "bundled-plugins", pluginId, "index.cjs"),
|
||||
path.resolve(process.cwd(), "packages", "electron", "bundled-plugins", pluginId, "index.cjs")
|
||||
]);
|
||||
}
|
||||
|
||||
function isLegacyClaudeDesignModule(modulePath: string | undefined): boolean {
|
||||
return isLegacyExternalizedPluginModule(CLAUDE_DESIGN_PLUGIN_ID, modulePath);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AppInfo = {
|
||||
configDir: string;
|
||||
configFile: string;
|
||||
dataDir: string;
|
||||
desktop: boolean;
|
||||
gatewayConfigFile: string;
|
||||
launchAtLoginSupported: boolean;
|
||||
requestLogsDbFile: string;
|
||||
@@ -782,7 +783,7 @@ export const DEFAULT_CLAUDE_DESIGN_APP: GatewayPluginAppConfig = {
|
||||
icon: "palette",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url: "https://claude-design-assets.pages.dev/design"
|
||||
url: "https://claude-design.ccrdesk.top/design"
|
||||
};
|
||||
export const DEFAULT_CLAUDE_SHIP_APP: GatewayPluginAppConfig = {
|
||||
description: "Open Claude Ship in a dedicated CCR Electron window.",
|
||||
@@ -824,10 +825,6 @@ export type KnownGatewayPluginDefaults = {
|
||||
};
|
||||
|
||||
export const KNOWN_GATEWAY_PLUGIN_DEFAULTS: Record<string, KnownGatewayPluginDefaults> = {
|
||||
"agent-console": {
|
||||
permissions: ["trusted-code", "apps", "gateway-routes", "system-launcher"],
|
||||
surfaces: { apps: true, gateway: true, provider: false }
|
||||
},
|
||||
"claude-design": {
|
||||
permissions: ["trusted-code", "apps", "gateway-routes", "proxy-routes", "http-backends", "sqlite-store"],
|
||||
surfaces: { apps: true, gateway: true, provider: false }
|
||||
|
||||
@@ -16,12 +16,15 @@ import {
|
||||
type ProviderAccountMeter,
|
||||
type ProviderAccountPluginConnectorConfig,
|
||||
type ProviderAccountSnapshot,
|
||||
CLAUDE_DESIGN_PLUGIN_ID,
|
||||
CLAUDE_SHIP_PLUGIN_ID,
|
||||
GATEWAY_PLUGIN_PERMISSION_IDS,
|
||||
knownGatewayPluginDefaultPermissions,
|
||||
knownGatewayPluginDefaultSurfaces
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "@ccr/core/plugins/backend-service";
|
||||
import { CONFIGDIR, DATADIR } from "@ccr/core/config/constants";
|
||||
import { isDesktopAppRuntime } from "@ccr/core/runtime/desktop-app";
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
type PluginLogger = {
|
||||
@@ -205,6 +208,9 @@ class GatewayPluginService {
|
||||
if (pluginConfig.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
if (!pluginAvailableInCurrentRuntime(pluginConfig)) {
|
||||
continue;
|
||||
}
|
||||
const snapshot = this.createStateSnapshot();
|
||||
this.resourceOwnerIds.add(pluginConfig.id);
|
||||
try {
|
||||
@@ -995,9 +1001,17 @@ function pluginRuntimeSurfacesEnabled(pluginConfig: Pick<GatewayPluginConfig, "i
|
||||
pluginSurfaceEnabled(pluginConfig, "provider");
|
||||
}
|
||||
|
||||
function pluginAvailableInCurrentRuntime(pluginConfig: Pick<GatewayPluginConfig, "id">): boolean {
|
||||
return !isDesktopOnlyClaudeBrowserPlugin(pluginConfig.id) || isDesktopAppRuntime();
|
||||
}
|
||||
|
||||
function isDesktopOnlyClaudeBrowserPlugin(pluginId: string): boolean {
|
||||
return pluginId === CLAUDE_DESIGN_PLUGIN_ID || pluginId === CLAUDE_SHIP_PLUGIN_ID;
|
||||
}
|
||||
|
||||
function enabledPluginIds(config: AppConfig): Set<string> {
|
||||
return new Set((config.plugins ?? [])
|
||||
.filter((plugin) => plugin.enabled !== false && pluginRuntimeSurfacesEnabled(plugin))
|
||||
.filter((plugin) => plugin.enabled !== false && pluginAvailableInCurrentRuntime(plugin) && pluginRuntimeSurfacesEnabled(plugin))
|
||||
.map((plugin) => plugin.id));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates }
|
||||
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
|
||||
import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, profileOpenSurfaces, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
|
||||
import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service";
|
||||
import { isDesktopAppRuntime } from "@ccr/core/runtime/desktop-app";
|
||||
import { windowsEnvironmentChangedPowerShellLines, windowsSystemCommand } from "@ccr/core/platform/windows-system";
|
||||
|
||||
const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>";
|
||||
@@ -99,6 +100,9 @@ export async function getProfileOpenCommand(config: AppConfig, request: ProfileO
|
||||
await applyProfileConfig(config);
|
||||
const profile = findProfileForOpen(config, request.profileId);
|
||||
const surface = resolveProfileOpenSurface(profile, request.surface);
|
||||
if (profile.agent === "claude-design" && !isDesktopAppRuntime()) {
|
||||
throw new Error("Claude Design profiles can only be opened from CCR Desktop.");
|
||||
}
|
||||
if (options.ensureLauncher) {
|
||||
ensureCcrCliLauncher(config);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const CCR_DESKTOP_APP_ENV = "CCR_DESKTOP_APP";
|
||||
|
||||
export function markDesktopAppRuntime(): void {
|
||||
process.env[CCR_DESKTOP_APP_ENV] = "1";
|
||||
}
|
||||
|
||||
export function isDesktopAppRuntime(): boolean {
|
||||
return process.env[CCR_DESKTOP_APP_ENV] === "1" && Boolean((process.versions as NodeJS.ProcessVersions & { electron?: string }).electron);
|
||||
}
|
||||
@@ -485,6 +485,7 @@ function getCliAppInfo(): AppInfo {
|
||||
configDir: CONFIGDIR,
|
||||
configFile: CONFIG_FILE,
|
||||
dataDir: DATADIR,
|
||||
desktop: false,
|
||||
gatewayConfigFile: GATEWAY_CONFIG_FILE,
|
||||
launchAtLoginSupported: false,
|
||||
name: APP_NAME,
|
||||
|
||||
@@ -1,687 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const require = createRequire(`${process.cwd()}/package.json`);
|
||||
const extensionsRoot = path.resolve(process.env.CCR_EXTENSIONS_DIR || path.join(process.cwd(), "..", "ccr-extensions"));
|
||||
const agentConsolePluginRoot = path.join(extensionsRoot, "plugins", "agent-console");
|
||||
const agentConsolePlugin = require(path.join(agentConsolePluginRoot, "index.cjs"));
|
||||
|
||||
const DEFAULT_LAUNCHER_BUNDLE_ID = "com.claudecoderouter.plugin.agent-console.launcher";
|
||||
|
||||
test("Agent Console catalog uses model ids for display names and model-specific reasoning levels", async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const pluginDataDir = path.join(home, "plugin-data");
|
||||
await agentConsolePlugin.setup(createPluginContext({
|
||||
config: {
|
||||
Providers: [
|
||||
{
|
||||
modelDescriptions: {
|
||||
"glm-4.5": "性能强劲,但是速度很慢",
|
||||
"glm-4-air": "性能一般,速度快"
|
||||
},
|
||||
models: ["glm-4.5", "glm-4-air", "glm-5.2"],
|
||||
name: "Zhipu AI (China) - Coding Plan"
|
||||
},
|
||||
{
|
||||
models: ["z-ai/glm-5.2"],
|
||||
name: "OpenRouter"
|
||||
},
|
||||
{
|
||||
models: ["gpt-5.5", "gpt-5.6"],
|
||||
name: "Codex API"
|
||||
},
|
||||
{
|
||||
models: ["gpt-5.5", "gpt-5.6"],
|
||||
name: "uuroute"
|
||||
}
|
||||
]
|
||||
},
|
||||
pluginConfig: { bridgePort: 34567, launch: false, systemLauncher: false },
|
||||
pluginDataDir,
|
||||
routes: []
|
||||
}));
|
||||
|
||||
const runtimeConfig = JSON.parse(readFileSync(path.join(pluginDataDir, "ccr-runtime-config.json"), "utf8"));
|
||||
const catalog = JSON.parse(readFileSync(path.join(pluginDataDir, "ccr-codex-model-catalog.json"), "utf8"));
|
||||
|
||||
assert.deepEqual(runtimeConfig.disabledAgentProviders, ["opencat", "open-cat"]);
|
||||
assert.deepEqual(runtimeConfig.agents.disabledProviders, ["opencat", "open-cat"]);
|
||||
assert.equal(runtimeConfig.models[0].displayName, "Zhipu AI (China) - Coding Plan/glm-4.5");
|
||||
assert.ok(runtimeConfig.codex.command.endsWith(process.platform === "win32" ? "ccr-agent-console-codex.cmd" : "ccr-agent-console-codex"));
|
||||
assert.ok(existsSync(runtimeConfig.codex.command));
|
||||
assert.equal(runtimeConfig.codex.env.CODEX_HOME, path.join(pluginDataDir, "codex-home"));
|
||||
assert.equal(runtimeConfig.codex.env.CCR_CODEX_MODEL_PROVIDER, "claude-code-router");
|
||||
assert.equal(runtimeConfig.codex.env.CCR_CODEX_REMOTE_FRONTEND_MODE, "app");
|
||||
assert.ok(existsSync(path.join(pluginDataDir, "bin", "ccr-codex-cli-middleware.js")));
|
||||
assert.match(readFileSync(path.join(pluginDataDir, "codex-home", "config.toml"), "utf8"), /model_provider = "claude-code-router"/);
|
||||
assert.match(readFileSync(path.join(pluginDataDir, "codex-home", "config.toml"), "utf8"), /model_catalog_json = /);
|
||||
assert.match(readFileSync(path.join(pluginDataDir, "codex-home", "config.toml"), "utf8"), /\[model_providers\."claude-code-router"\]/);
|
||||
assert.match(readFileSync(path.join(pluginDataDir, "codex-home", "config.toml"), "utf8"), /wire_api = "responses"/);
|
||||
assert.ok(runtimeConfig.claudeCode.command.endsWith(process.platform === "win32" ? "ccr-agent-console-claude-code.cmd" : "ccr-agent-console-claude-code"));
|
||||
assert.ok(existsSync(runtimeConfig.claudeCode.command));
|
||||
assert.equal(runtimeConfig.claudeCode.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:3456");
|
||||
assert.equal(runtimeConfig.claudeCode.env.CLAUDE_AGENT_API_BASE_URL, "http://127.0.0.1:3456");
|
||||
assert.equal(runtimeConfig.claudeCode.env.CCR_CLAUDE_CODE_SETTINGS_FILE, path.join(pluginDataDir, "claude-code", "claude", "settings.json"));
|
||||
assert.equal(runtimeConfig.claudeCode.env.CODEXL_CLAUDE_CODE_SETTINGS_FILE, path.join(pluginDataDir, "claude-code", "claude", "settings.json"));
|
||||
assert.equal(Object.hasOwn(runtimeConfig.claudeCode.env, "CLAUDE_CONFIG_DIR"), false);
|
||||
assert.equal(runtimeConfig.claudeCode.env.CCR_CLAUDE_CODE_WRAPPER, "1");
|
||||
assert.equal(runtimeConfig.claudeCode.env.CCR_REAL_CLAUDE_CODE_BIN, "claude");
|
||||
assert.equal(runtimeConfig.claudeCode.env.CCR_REMOTE_SYNC_PROFILE_ID, "agent-console-claude-code");
|
||||
assert.ok(existsSync(path.join(pluginDataDir, "bin", process.platform === "win32" ? "ccr-agent-console-claude-code-api-key.cmd" : "ccr-agent-console-claude-code-api-key")));
|
||||
const claudeSettings = JSON.parse(readFileSync(path.join(pluginDataDir, "claude-code", "claude", "settings.json"), "utf8"));
|
||||
assert.equal(claudeSettings.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:3456");
|
||||
assert.equal(claudeSettings.env.CCR_CLAUDE_CODE_SETTINGS_FILE, path.join(pluginDataDir, "claude-code", "claude", "settings.json"));
|
||||
assert.equal(claudeSettings.env.CODEXL_CLAUDE_CODE_SETTINGS_FILE, path.join(pluginDataDir, "claude-code", "claude", "settings.json"));
|
||||
assert.equal(Object.hasOwn(claudeSettings.env, "CLAUDE_CONFIG_DIR"), false);
|
||||
assert.equal(claudeSettings.env.CCR_CLAUDE_CODE_MODEL, "Zhipu AI (China) - Coding Plan/glm-4.5");
|
||||
assert.match(readFileSync(runtimeConfig.claudeCode.command, "utf8"), /CCR_CLAUDE_CODE_WRAPPER/);
|
||||
const runtimeToggleReasoningModel = runtimeConfig.models.find((item) => item.model === "Zhipu AI (China) - Coding Plan/glm-4.5");
|
||||
const runtimeBasicModel = runtimeConfig.models.find((item) => item.model === "Zhipu AI (China) - Coding Plan/glm-4-air");
|
||||
const runtimeZhipuEffortReasoningModel = runtimeConfig.models.find((item) => item.model === "Zhipu AI (China) - Coding Plan/glm-5.2");
|
||||
const runtimeEffortReasoningModel = runtimeConfig.models.find((item) => item.model === "OpenRouter/z-ai/glm-5.2");
|
||||
const runtimeOpenAiReasoningModel = runtimeConfig.models.find((item) => item.model === "Codex API/gpt-5.5");
|
||||
const runtimeOpenAiLatestReasoningModel = runtimeConfig.models.find((item) => item.model === "Codex API/gpt-5.6");
|
||||
const runtimeGatewayReasoningModel = runtimeConfig.models.find((item) => item.model === "uuroute/gpt-5.5");
|
||||
const runtimeGatewayLatestReasoningModel = runtimeConfig.models.find((item) => item.model === "uuroute/gpt-5.6");
|
||||
const toggleReasoningModel = catalog.models.find((item) => item.slug === "Zhipu AI (China) - Coding Plan/glm-4.5");
|
||||
const basicModel = catalog.models.find((item) => item.slug === "Zhipu AI (China) - Coding Plan/glm-4-air");
|
||||
const zhipuEffortReasoningModel = catalog.models.find((item) => item.slug === "Zhipu AI (China) - Coding Plan/glm-5.2");
|
||||
const effortReasoningModel = catalog.models.find((item) => item.slug === "OpenRouter/z-ai/glm-5.2");
|
||||
const openAiReasoningModel = catalog.models.find((item) => item.slug === "Codex API/gpt-5.5");
|
||||
const openAiLatestReasoningModel = catalog.models.find((item) => item.slug === "Codex API/gpt-5.6");
|
||||
const gatewayReasoningModel = catalog.models.find((item) => item.slug === "uuroute/gpt-5.5");
|
||||
const gatewayLatestReasoningModel = catalog.models.find((item) => item.slug === "uuroute/gpt-5.6");
|
||||
|
||||
assert.ok(runtimeToggleReasoningModel);
|
||||
assert.ok(runtimeBasicModel);
|
||||
assert.ok(runtimeZhipuEffortReasoningModel);
|
||||
assert.ok(runtimeEffortReasoningModel);
|
||||
assert.ok(runtimeOpenAiReasoningModel);
|
||||
assert.ok(runtimeOpenAiLatestReasoningModel);
|
||||
assert.ok(runtimeGatewayReasoningModel);
|
||||
assert.ok(runtimeGatewayLatestReasoningModel);
|
||||
assert.ok(toggleReasoningModel);
|
||||
assert.ok(basicModel);
|
||||
assert.ok(zhipuEffortReasoningModel);
|
||||
assert.ok(effortReasoningModel);
|
||||
assert.ok(openAiReasoningModel);
|
||||
assert.ok(openAiLatestReasoningModel);
|
||||
assert.ok(gatewayReasoningModel);
|
||||
assert.ok(gatewayLatestReasoningModel);
|
||||
assert.deepEqual(runtimeToggleReasoningModel.supportedReasoningEfforts, []);
|
||||
assert.deepEqual(runtimeToggleReasoningModel.supportedSpeeds, []);
|
||||
assert.deepEqual(runtimeBasicModel.supportedReasoningEfforts, []);
|
||||
assert.deepEqual(runtimeBasicModel.supportedSpeeds, []);
|
||||
assert.deepEqual(runtimeZhipuEffortReasoningModel.supportedReasoningEfforts, ["high", "xhigh"]);
|
||||
assert.equal(runtimeZhipuEffortReasoningModel.defaultReasoningEffort, undefined);
|
||||
assert.equal(runtimeZhipuEffortReasoningModel.contextWindowTokens, 1048576);
|
||||
assert.equal(runtimeZhipuEffortReasoningModel.context_window_tokens, 1048576);
|
||||
assert.deepEqual(runtimeZhipuEffortReasoningModel.supportedSpeeds, []);
|
||||
assert.deepEqual(runtimeEffortReasoningModel.supportedReasoningEfforts, ["xhigh", "high"]);
|
||||
assert.equal(runtimeEffortReasoningModel.defaultReasoningEffort, "high");
|
||||
assert.equal(runtimeEffortReasoningModel.contextWindowTokens, 1048576);
|
||||
assert.deepEqual(runtimeEffortReasoningModel.supportedSpeeds, []);
|
||||
assert.deepEqual(runtimeOpenAiReasoningModel.supportedReasoningEfforts, ["minimal", "low", "medium", "high"]);
|
||||
assert.equal(runtimeOpenAiReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.deepEqual(runtimeOpenAiReasoningModel.supportedSpeeds, []);
|
||||
assert.deepEqual(runtimeOpenAiLatestReasoningModel.supportedReasoningEfforts, ["minimal", "low", "medium", "high", "xhigh"]);
|
||||
assert.equal(runtimeOpenAiLatestReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.deepEqual(runtimeOpenAiLatestReasoningModel.supportedSpeeds, []);
|
||||
assert.deepEqual(runtimeGatewayReasoningModel.supportedReasoningEfforts, ["minimal", "low", "medium", "high"]);
|
||||
assert.equal(runtimeGatewayReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.equal(runtimeGatewayReasoningModel.contextWindowTokens, 1050000);
|
||||
assert.deepEqual(runtimeGatewayReasoningModel.supportedSpeeds, []);
|
||||
assert.deepEqual(runtimeGatewayLatestReasoningModel.supportedReasoningEfforts, ["minimal", "low", "medium", "high", "xhigh"]);
|
||||
assert.equal(runtimeGatewayLatestReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.equal(runtimeGatewayLatestReasoningModel.contextWindowTokens, 128000);
|
||||
assert.deepEqual(runtimeGatewayLatestReasoningModel.supportedSpeeds, []);
|
||||
assert.equal(toggleReasoningModel.display_name, toggleReasoningModel.slug);
|
||||
assert.equal(basicModel.display_name, basicModel.slug);
|
||||
assert.equal(zhipuEffortReasoningModel.display_name, zhipuEffortReasoningModel.slug);
|
||||
assert.equal(effortReasoningModel.display_name, effortReasoningModel.slug);
|
||||
assert.equal(openAiReasoningModel.display_name, openAiReasoningModel.slug);
|
||||
assert.equal(openAiLatestReasoningModel.display_name, openAiLatestReasoningModel.slug);
|
||||
assert.equal(gatewayReasoningModel.display_name, gatewayReasoningModel.slug);
|
||||
assert.equal(gatewayLatestReasoningModel.display_name, gatewayLatestReasoningModel.slug);
|
||||
assert.equal(gatewayReasoningModel.apply_patch_tool_type, "freeform");
|
||||
assert.equal(gatewayReasoningModel.context_window, 1050000);
|
||||
assert.deepEqual(gatewayReasoningModel.input_modalities, ["text", "image"]);
|
||||
assert.equal(gatewayReasoningModel.max_context_window, 1050000);
|
||||
assert.equal(gatewayReasoningModel.support_verbosity, true);
|
||||
assert.deepEqual(gatewayReasoningModel.truncation_policy, { mode: "tokens", limit: 10000 });
|
||||
assert.equal(toggleReasoningModel.default_reasoning_level, null);
|
||||
assert.equal(toggleReasoningModel.defaultReasoningEffort, null);
|
||||
assert.deepEqual(toggleReasoningModel.supportedReasoningEfforts, []);
|
||||
assert.deepEqual(toggleReasoningModel.supported_reasoning_levels, []);
|
||||
assert.equal(toggleReasoningModel.supports_reasoning_summaries, true);
|
||||
assert.equal(zhipuEffortReasoningModel.default_reasoning_level, null);
|
||||
assert.equal(zhipuEffortReasoningModel.defaultReasoningEffort, null);
|
||||
assert.equal(zhipuEffortReasoningModel.context_window, 1048576);
|
||||
assert.equal(zhipuEffortReasoningModel.max_context_window, 1048576);
|
||||
assert.deepEqual(zhipuEffortReasoningModel.supportedReasoningEfforts.map((level) => level.reasoningEffort), ["high", "xhigh"]);
|
||||
assert.deepEqual(zhipuEffortReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["high", "xhigh"]);
|
||||
assert.equal(zhipuEffortReasoningModel.supports_reasoning_summaries, true);
|
||||
assert.equal(effortReasoningModel.default_reasoning_level, "high");
|
||||
assert.equal(effortReasoningModel.defaultReasoningEffort, "high");
|
||||
assert.equal(effortReasoningModel.context_window, 1048576);
|
||||
assert.equal(effortReasoningModel.max_context_window, 1048576);
|
||||
assert.deepEqual(effortReasoningModel.supportedReasoningEfforts.map((level) => level.reasoningEffort), ["xhigh", "high"]);
|
||||
assert.deepEqual(effortReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["xhigh", "high"]);
|
||||
assert.equal(effortReasoningModel.supports_reasoning_summaries, true);
|
||||
assert.equal(openAiReasoningModel.default_reasoning_level, "medium");
|
||||
assert.equal(openAiReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.deepEqual(openAiReasoningModel.supportedReasoningEfforts.map((level) => level.reasoningEffort), ["minimal", "low", "medium", "high"]);
|
||||
assert.deepEqual(openAiReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["minimal", "low", "medium", "high"]);
|
||||
assert.equal(openAiReasoningModel.supports_reasoning_summaries, true);
|
||||
assert.equal(openAiLatestReasoningModel.default_reasoning_level, "medium");
|
||||
assert.equal(openAiLatestReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.deepEqual(openAiLatestReasoningModel.supportedReasoningEfforts.map((level) => level.reasoningEffort), ["minimal", "low", "medium", "high", "xhigh"]);
|
||||
assert.deepEqual(openAiLatestReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["minimal", "low", "medium", "high", "xhigh"]);
|
||||
assert.equal(openAiLatestReasoningModel.supports_reasoning_summaries, true);
|
||||
assert.equal(gatewayReasoningModel.default_reasoning_level, "medium");
|
||||
assert.equal(gatewayReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.deepEqual(gatewayReasoningModel.supportedReasoningEfforts.map((level) => level.reasoningEffort), ["minimal", "low", "medium", "high"]);
|
||||
assert.deepEqual(gatewayReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["minimal", "low", "medium", "high"]);
|
||||
assert.equal(gatewayReasoningModel.supports_reasoning_summaries, true);
|
||||
assert.equal(gatewayLatestReasoningModel.default_reasoning_level, "medium");
|
||||
assert.equal(gatewayLatestReasoningModel.defaultReasoningEffort, "medium");
|
||||
assert.deepEqual(gatewayLatestReasoningModel.supportedReasoningEfforts.map((level) => level.reasoningEffort), ["minimal", "low", "medium", "high", "xhigh"]);
|
||||
assert.deepEqual(gatewayLatestReasoningModel.supported_reasoning_levels.map((level) => level.effort), ["minimal", "low", "medium", "high", "xhigh"]);
|
||||
assert.equal(gatewayLatestReasoningModel.supports_reasoning_summaries, true);
|
||||
assert.equal(basicModel.default_reasoning_level, null);
|
||||
assert.equal(basicModel.defaultReasoningEffort, null);
|
||||
assert.deepEqual(basicModel.supportedReasoningEfforts, []);
|
||||
assert.deepEqual(basicModel.supported_reasoning_levels, []);
|
||||
assert.equal(basicModel.supports_reasoning_summaries, false);
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console plugin bridge removes OpenCat provider support", async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const routes = [];
|
||||
await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: { bridgePort: 34567, launch: false, systemLauncher: false },
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
|
||||
const preload = await readRendererRouteBody(routes, "/plugins/agent-console/__agent-console-preload.js");
|
||||
const { requests, window } = evaluateAgentConsolePreload(preload);
|
||||
|
||||
const providers = await window.agentConsole.agent.listProviders();
|
||||
assert.deepEqual(plainValue(providers.map((provider) => provider.id)), ["codex"]);
|
||||
|
||||
const settings = await window.agentConsole.settings.get();
|
||||
assert.deepEqual(plainValue(settings.agentProviders.map((provider) => provider.id)), ["codex"]);
|
||||
assert.deepEqual(plainValue(settings.subagents.map((subagent) => subagent.id)), ["reviewer"]);
|
||||
assert.deepEqual(plainValue(Object.keys(settings.agentEnvironments)), ["codex"]);
|
||||
assert.ok(settings.disabledAgentProviders.includes("opencat"));
|
||||
assert.ok(settings.disabledAgentProviders.includes("open-cat"));
|
||||
|
||||
await assert.rejects(
|
||||
() => window.agentConsole.agent.startThread({ providerId: "opencat" }),
|
||||
/OpenCat is not supported in CCR Agent Console plugin mode/
|
||||
);
|
||||
|
||||
await window.agentConsole.settings.setAgentProviders({
|
||||
providers: [
|
||||
{ id: "opencat", label: "OpenCat" },
|
||||
{ id: "codex", label: "Codex" }
|
||||
]
|
||||
});
|
||||
const setProviderRequest = requests.findLast((request) => request.channel === "agent-console:settings:set-agent-providers");
|
||||
assert.ok(setProviderRequest);
|
||||
assert.deepEqual(plainValue(setProviderRequest.args[0].providers.map((provider) => provider.id)), ["codex"]);
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console React composer omits the floating status row", async () => {
|
||||
const chatSource = readFileSync(path.join(agentConsolePluginRoot, "src", "renderer", "pages", "home", "components", "chat.tsx"), "utf8");
|
||||
assert.doesNotMatch(chatSource, /chat-floating-status/);
|
||||
assert.doesNotMatch(chatSource, /chat\.statusReady/);
|
||||
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const routes = [];
|
||||
await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: { bridgePort: 34567, launch: false, systemLauncher: false },
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
|
||||
const preload = await readRendererRouteBody(routes, "/plugins/agent-console/__agent-console-preload.js");
|
||||
|
||||
assert.doesNotMatch(preload, /agent-console-plugin-composer-surface-styles/);
|
||||
assert.doesNotMatch(preload, /\.chat-floating-status\{display:none!important/);
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console diff parts avoid duplicate paths and colorize unified diffs", () => {
|
||||
const chatSource = readFileSync(path.join(agentConsolePluginRoot, "src", "renderer", "pages", "home", "components", "chat.tsx"), "utf8");
|
||||
|
||||
assert.match(chatSource, /function DiffContent/);
|
||||
assert.match(chatSource, /function getDiffPartTitle/);
|
||||
assert.match(chatSource, /duplicateTitles\.has\(title\) \? "Diff" : title/);
|
||||
assert.doesNotMatch(chatSource, /if \(part\.type === "diff"\) return part\.title \|\| part\.path \|\| "Diff";/);
|
||||
assert.match(chatSource, /line\.startsWith\("@@"\)/);
|
||||
assert.ok(chatSource.includes('line.startsWith("+")'));
|
||||
assert.ok(chatSource.includes('line.startsWith("-")'));
|
||||
assert.ok(chatSource.includes('line.startsWith("--- ")'));
|
||||
assert.ok(chatSource.includes('line.startsWith("+++ ")'));
|
||||
});
|
||||
|
||||
test("Agent Console context window indicator only uses runtime usage", () => {
|
||||
const coreSource = readFileSync(path.join(agentConsolePluginRoot, "src", "renderer", "pages", "home", "utils", "core.ts"), "utf8");
|
||||
const chatSource = readFileSync(path.join(agentConsolePluginRoot, "src", "renderer", "pages", "home", "components", "chat.tsx"), "utf8");
|
||||
const docsSource = readFileSync(path.join(agentConsolePluginRoot, "src", "renderer", "pages", "home", "plugins", "docs", "index.tsx"), "utf8");
|
||||
|
||||
assert.doesNotMatch(coreSource, /usedTokens = hasUsageTokens \? usageTokens : estimatedTokens/);
|
||||
assert.doesNotMatch(coreSource, /source: hasUsageTokens \? "actual" : "estimated"/);
|
||||
assert.doesNotMatch(docsSource, /source: "estimated"/);
|
||||
assert.match(coreSource, /source: hasUsageTokens \? "actual" : "unknown"/);
|
||||
assert.match(coreSource, /const usedTokens = hasUsageTokens \? usageTokens : null/);
|
||||
assert.match(chatSource, /const usedLabel = hasRuntimeUsage \? formatTokenCount\(runtimeUsedTokens\) : t\("contextWindow\.unknown"\)/);
|
||||
assert.match(chatSource, /const percentLabel = hasRuntimeUsage && metrics\.limitTokens \? formatContextWindowPercent/);
|
||||
});
|
||||
|
||||
test("Agent Console native thread menu icons match the resolved theme", () => {
|
||||
const layoutSource = readFileSync(path.join(agentConsolePluginRoot, "src", "renderer", "pages", "home", "components", "layout.tsx"), "utf8");
|
||||
|
||||
assert.match(layoutSource, /function getNativeMenuIconStroke/);
|
||||
assert.match(layoutSource, /getComputedStyle\(root\)\.getPropertyValue\("--foreground"\)/);
|
||||
assert.match(layoutSource, /root\.dataset\.theme === "dark" \? "#e6e8eb" : "#2f2f2f"/);
|
||||
assert.match(layoutSource, /escapeSvgAttribute\(stroke\)/);
|
||||
assert.match(layoutSource, /const nativeMenuIcons = createThreadHeaderNativeMenuIcons\(\)/);
|
||||
assert.doesNotMatch(layoutSource, /stroke="#111827"/);
|
||||
});
|
||||
|
||||
test("Agent Console persists in-flight runs across renderer exits", () => {
|
||||
const appSource = readFileSync(path.join(agentConsolePluginRoot, "src", "renderer", "pages", "home", "App.tsx"), "utf8");
|
||||
|
||||
assert.match(appSource, /agentConsole\.pendingRunSnapshots\.v1/);
|
||||
assert.match(appSource, /function savePendingRunSnapshots/);
|
||||
assert.match(appSource, /function loadPendingRunSnapshots/);
|
||||
assert.match(appSource, /const persistPendingRunSnapshotForThread = useCallback/);
|
||||
assert.match(appSource, /persistPendingRunSnapshotForThread\(threadId\)/);
|
||||
assert.match(appSource, /persistPendingRunSnapshotForThread\(event\.threadId\)/);
|
||||
assert.match(appSource, /shouldDiscardRecoveredInFlightMessages\(historyMessages, inFlightMessages\)/);
|
||||
assert.match(appSource, /clearPendingRunStateForThread\(selectedThread\)/);
|
||||
assert.match(appSource, /hasPersistedAssistantReplacement/);
|
||||
});
|
||||
|
||||
test("Agent Console macOS launcher defaults to the CCR Apps folder", { skip: process.platform !== "darwin" }, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const routes = [];
|
||||
await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: { bridgePort: 34567, launch: false },
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
|
||||
const expectedLauncherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||
const status = readStatusPayload(routes);
|
||||
|
||||
assert.equal(status.launcherInstalled, true);
|
||||
assert.equal(status.launcherPath, expectedLauncherPath);
|
||||
assert.ok(existsSync(path.join(expectedLauncherPath, "Contents", "MacOS", "AgentConsole")));
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console macOS launcher migrates the previous default app location", { skip: process.platform !== "darwin" }, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const legacyLauncherPath = path.join(home, "Applications", "Agent Console.app");
|
||||
writeLauncherInfoPlist(legacyLauncherPath);
|
||||
|
||||
const routes = [];
|
||||
await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: { bridgePort: 34567, launch: false },
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
|
||||
const expectedLauncherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||
const status = readStatusPayload(routes);
|
||||
|
||||
assert.equal(status.launcherInstalled, true);
|
||||
assert.equal(status.launcherPath, expectedLauncherPath);
|
||||
assert.equal(existsSync(legacyLauncherPath), false);
|
||||
assert.ok(existsSync(path.join(expectedLauncherPath, "Contents", "MacOS", "AgentConsole")));
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console macOS launcher keeps an explicit launcherPath", { skip: process.platform !== "darwin" }, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const explicitLauncherPath = path.join(home, "Custom Launchers", "Console.app");
|
||||
const routes = [];
|
||||
await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: {
|
||||
bridgePort: 34567,
|
||||
launch: false,
|
||||
launcherPath: explicitLauncherPath
|
||||
},
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
|
||||
const status = readStatusPayload(routes);
|
||||
|
||||
assert.equal(status.launcherInstalled, true);
|
||||
assert.equal(status.launcherPath, explicitLauncherPath);
|
||||
assert.ok(existsSync(path.join(explicitLauncherPath, "Contents", "MacOS", "AgentConsole")));
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console macOS launcher is removed when the plugin is disabled", { skip: process.platform !== "darwin" }, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const routes = [];
|
||||
const registration = await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: { bridgePort: 34567, launch: false },
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
const launcherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||
|
||||
assert.ok(existsSync(launcherPath));
|
||||
await registration.stop({ reason: "disabled" });
|
||||
assert.equal(existsSync(launcherPath), false);
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console macOS launcher is kept during a normal gateway stop", { skip: process.platform !== "darwin" }, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const routes = [];
|
||||
const registration = await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: { bridgePort: 34567, launch: false },
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
const launcherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||
|
||||
assert.ok(existsSync(launcherPath));
|
||||
await registration.stop({ reason: "stop" });
|
||||
assert.ok(existsSync(launcherPath));
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent Console macOS launcher removal skips apps with a different bundle id", { skip: process.platform !== "darwin" }, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-agent-console-home-"));
|
||||
try {
|
||||
await withHome(home, async () => {
|
||||
const routes = [];
|
||||
const registration = await agentConsolePlugin.setup(createPluginContext({
|
||||
pluginConfig: { bridgePort: 34567, launch: false },
|
||||
pluginDataDir: path.join(home, "plugin-data"),
|
||||
routes
|
||||
}));
|
||||
const launcherPath = path.join(home, "Applications", "CCR Apps", "Agent Console.app");
|
||||
writeLauncherInfoPlist(launcherPath, "com.example.other-launcher");
|
||||
|
||||
await registration.stop({ reason: "disabled" });
|
||||
assert.ok(existsSync(launcherPath));
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function withHome(home, run) {
|
||||
const previousHome = process.env.HOME;
|
||||
try {
|
||||
process.env.HOME = home;
|
||||
await run();
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeLauncherInfoPlist(launcherPath, bundleId = DEFAULT_LAUNCHER_BUNDLE_ID) {
|
||||
const contentsDir = path.join(launcherPath, "Contents");
|
||||
mkdirSync(contentsDir, { recursive: true });
|
||||
writeFileSync(path.join(contentsDir, "Info.plist"), [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
||||
"<plist version=\"1.0\">",
|
||||
"<dict>",
|
||||
" <key>CFBundleIdentifier</key>",
|
||||
` <string>${bundleId}</string>`,
|
||||
"</dict>",
|
||||
"</plist>",
|
||||
""
|
||||
].join("\n"), "utf8");
|
||||
}
|
||||
|
||||
function createPluginContext({ config = {}, pluginConfig, pluginDataDir, routes }) {
|
||||
return {
|
||||
config,
|
||||
logger: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {}
|
||||
},
|
||||
paths: { pluginDataDir },
|
||||
pluginConfig,
|
||||
permissions: ["apps", "gateway-routes", "system-launcher", "trusted-code"],
|
||||
registerApp() {},
|
||||
registerGatewayRoute(route) {
|
||||
routes.push(route);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readStatusPayload(routes) {
|
||||
const statusRoute = routes.find((route) => route.id === "agent-console-status");
|
||||
assert.ok(statusRoute, "Agent Console status route should be registered.");
|
||||
|
||||
let payload;
|
||||
statusRoute.handler({}, {}, {
|
||||
sendJson(_response, statusCode, body) {
|
||||
assert.equal(statusCode, 200);
|
||||
payload = body;
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(payload, "Agent Console status route should return a payload.");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function readRendererRouteBody(routes, url) {
|
||||
const rendererRoute = routes.find((route) => route.id === "agent-console-renderer");
|
||||
assert.ok(rendererRoute, "Agent Console renderer route should be registered.");
|
||||
|
||||
let body = "";
|
||||
await rendererRoute.handler({ method: "GET", url }, {
|
||||
end(chunk = "") {
|
||||
body += String(chunk);
|
||||
},
|
||||
writeHead() {}
|
||||
});
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function evaluateAgentConsolePreload(preload) {
|
||||
const requests = [];
|
||||
const localStorage = new Map();
|
||||
const document = {
|
||||
body: { appendChild() {} },
|
||||
createElement() {
|
||||
return {
|
||||
addEventListener() {},
|
||||
appendChild() {},
|
||||
className: "",
|
||||
contains() { return false; },
|
||||
focus() {},
|
||||
offsetHeight: 32,
|
||||
offsetWidth: 220,
|
||||
setAttribute() {},
|
||||
style: {}
|
||||
};
|
||||
},
|
||||
getElementById() { return null; },
|
||||
head: { appendChild() {} },
|
||||
removeEventListener() {}
|
||||
};
|
||||
const window = {
|
||||
addEventListener() {},
|
||||
clearTimeout,
|
||||
document,
|
||||
innerHeight: 800,
|
||||
innerWidth: 1200,
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return localStorage.get(key) ?? null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
localStorage.set(key, String(value));
|
||||
}
|
||||
},
|
||||
location: {
|
||||
href: "http://127.0.0.1/plugins/agent-console/pages/home/?mode=main",
|
||||
search: "?mode=main"
|
||||
},
|
||||
open() { return null; },
|
||||
setTimeout
|
||||
};
|
||||
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1;
|
||||
|
||||
constructor(url) {
|
||||
this.listeners = new Map();
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.url = url;
|
||||
queueMicrotask(() => this.emit("open", {}));
|
||||
}
|
||||
|
||||
addEventListener(type, callback) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(callback);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
this.emit("close", {});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
const request = JSON.parse(message);
|
||||
requests.push(request);
|
||||
queueMicrotask(() => {
|
||||
this.emit("message", {
|
||||
data: JSON.stringify({
|
||||
id: request.id,
|
||||
result: preloadBridgeResponse(request.channel, request.args),
|
||||
type: "result"
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
emit(type, event) {
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.WebSocket = FakeWebSocket;
|
||||
vm.runInNewContext(preload, {
|
||||
clearTimeout,
|
||||
console,
|
||||
document,
|
||||
queueMicrotask,
|
||||
setTimeout,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
WebSocket: FakeWebSocket,
|
||||
window
|
||||
});
|
||||
|
||||
return { requests, window };
|
||||
}
|
||||
|
||||
function plainValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function preloadBridgeResponse(channel, args) {
|
||||
if (channel === "agent-console:agent:list-providers") {
|
||||
return [
|
||||
{ enabled: true, id: "opencat", label: "OpenCat", models: [] },
|
||||
{ enabled: true, id: "codex", label: "Codex", models: [] }
|
||||
];
|
||||
}
|
||||
if (channel === "agent-console:settings:get") {
|
||||
return {
|
||||
agentEnvironments: {
|
||||
codex: { CODEX_HOME: "codex-home" },
|
||||
opencat: { OPENCAT_HOME: "cat-home" }
|
||||
},
|
||||
agentProviders: [
|
||||
{ id: "opencat", label: "OpenCat" },
|
||||
{ id: "codex", label: "Codex" }
|
||||
],
|
||||
disabledAgentProviders: [],
|
||||
subagents: [
|
||||
{ id: "cat-reviewer", providerId: "opencat" },
|
||||
{ id: "reviewer", providerId: "codex" }
|
||||
]
|
||||
};
|
||||
}
|
||||
if (channel === "agent-console:settings:set-agent-providers") {
|
||||
return {
|
||||
agentProviders: args[0]?.providers ?? [],
|
||||
disabledAgentProviders: [],
|
||||
subagents: []
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { pluginService } from "@ccr/core/plugins/service.ts";
|
||||
import { CCR_DESKTOP_APP_ENV } from "@ccr/core/runtime/desktop-app.ts";
|
||||
|
||||
test("plugin permissions gate dynamic gateway route registration", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-plugin-permissions-"));
|
||||
@@ -102,15 +103,59 @@ test("plugin permissions gate configured browser apps", { skip: !process.env.CCR
|
||||
test("known bundled plugins without persisted permissions receive scoped defaults", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-plugin-known-defaults-"));
|
||||
try {
|
||||
const pluginFile = path.join(dir, "agent-console-plugin.cjs");
|
||||
const pluginFile = path.join(dir, "claude-design-plugin.cjs");
|
||||
writeFileSync(pluginFile, [
|
||||
"\"use strict\";",
|
||||
"module.exports = {",
|
||||
" setup(ctx) {",
|
||||
" ctx.registerGatewayRoute({",
|
||||
" auth: \"none\",",
|
||||
" id: \"agent-console-status\",",
|
||||
" path: \"/plugins/agent-console/__status\",",
|
||||
" id: \"claude-design-status\",",
|
||||
" path: \"/plugins/claude-design/__status\",",
|
||||
" handler(_request, response, helpers) {",
|
||||
" helpers.sendJson(response, 200, { ok: true });",
|
||||
" }",
|
||||
" });",
|
||||
" }",
|
||||
"};",
|
||||
""
|
||||
].join("\n"), "utf8");
|
||||
|
||||
await withDesktopRuntime(async () => {
|
||||
await pluginService.start({
|
||||
...baseConfig(dir),
|
||||
plugins: [{
|
||||
apps: [{ id: "claude-design", name: "Claude Design", url: "/plugins/claude-design/pages/home/" }],
|
||||
enabled: true,
|
||||
id: "claude-design",
|
||||
module: pluginFile
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(pluginService.getApps().map((app) => app.id), ["claude-design"]);
|
||||
assert.equal(pluginService.hasGatewayRoutes(), true);
|
||||
} finally {
|
||||
await pluginService.stop();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude browser plugins are skipped outside CCR Desktop", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-plugin-desktop-only-"));
|
||||
const previousDesktopApp = process.env[CCR_DESKTOP_APP_ENV];
|
||||
try {
|
||||
delete process.env[CCR_DESKTOP_APP_ENV];
|
||||
const pluginFile = path.join(dir, "claude-design-plugin.cjs");
|
||||
writeFileSync(pluginFile, [
|
||||
"\"use strict\";",
|
||||
"module.exports = {",
|
||||
" setup(ctx) {",
|
||||
" ctx.registerApp({ id: \"claude-design\", name: \"Claude Design\", url: \"/plugins/claude-design\" });",
|
||||
" ctx.registerGatewayRoute({",
|
||||
" auth: \"none\",",
|
||||
" id: \"claude-design-status\",",
|
||||
" path: \"/plugins/claude-design/__status\",",
|
||||
" handler(_request, response, helpers) {",
|
||||
" helpers.sendJson(response, 200, { ok: true });",
|
||||
" }",
|
||||
@@ -123,16 +168,17 @@ test("known bundled plugins without persisted permissions receive scoped default
|
||||
await pluginService.start({
|
||||
...baseConfig(dir),
|
||||
plugins: [{
|
||||
apps: [{ id: "agent-console", name: "Agent Console", url: "/plugins/agent-console/pages/home/" }],
|
||||
enabled: true,
|
||||
id: "agent-console",
|
||||
module: pluginFile
|
||||
id: "claude-design",
|
||||
module: pluginFile,
|
||||
permissions: ["trusted-code", "apps", "gateway-routes"]
|
||||
}]
|
||||
});
|
||||
|
||||
assert.deepEqual(pluginService.getApps().map((app) => app.id), ["agent-console"]);
|
||||
assert.equal(pluginService.hasGatewayRoutes(), true);
|
||||
assert.deepEqual(pluginService.getApps(), []);
|
||||
assert.equal(pluginService.hasGatewayRoutes(), false);
|
||||
} finally {
|
||||
restoreEnv(CCR_DESKTOP_APP_ENV, previousDesktopApp);
|
||||
await pluginService.stop();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
@@ -276,6 +322,24 @@ function baseConfig(dir) {
|
||||
return config;
|
||||
}
|
||||
|
||||
async function withDesktopRuntime(run) {
|
||||
const previousDesktopApp = process.env[CCR_DESKTOP_APP_ENV];
|
||||
try {
|
||||
process.env[CCR_DESKTOP_APP_ENV] = "1";
|
||||
return await run();
|
||||
} finally {
|
||||
restoreEnv(CCR_DESKTOP_APP_ENV, previousDesktopApp);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv(key, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function writeReloadPlugin(pluginFile, name, url) {
|
||||
writeFileSync(pluginFile, [
|
||||
"\"use strict\";",
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { claudeDesignRuntimePluginConfig, migrateKnownGatewayPluginConfigsForTest, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config.ts";
|
||||
import { CCR_DESKTOP_APP_ENV } from "@ccr/core/runtime/desktop-app.ts";
|
||||
|
||||
test("legacy combined Claude Design plugin config migrates to split Design and Ship plugins", () => {
|
||||
const extensionsRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-extensions-migration-"));
|
||||
const previousExtensionsDir = process.env.CCR_EXTENSIONS_DIR;
|
||||
const previousDesktopApp = process.env[CCR_DESKTOP_APP_ENV];
|
||||
try {
|
||||
writePluginModule(extensionsRoot, "claude-design");
|
||||
writePluginModule(extensionsRoot, "claude-ship");
|
||||
process.env.CCR_EXTENSIONS_DIR = extensionsRoot;
|
||||
process.env[CCR_DESKTOP_APP_ENV] = "1";
|
||||
|
||||
const result = migrateKnownGatewayPluginConfigsForTest([{
|
||||
apps: [
|
||||
@@ -44,14 +47,14 @@ test("legacy combined Claude Design plugin config migrates to split Design and S
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.plugins.map((plugin) => plugin.id), ["claude-design", "claude-ship"]);
|
||||
assert.equal(result.plugins[0].module, path.join(extensionsRoot, "plugins", "claude-design", "index.cjs"));
|
||||
assert.equal(result.plugins[0].module, bundledPluginModule("claude-design"));
|
||||
assert.deepEqual(result.plugins[0].apps?.map((app) => app.id), ["claude-design"]);
|
||||
assert.equal(result.plugins[0].apps?.[0]?.url, "https://claude-design-assets.pages.dev/design");
|
||||
assert.equal(result.plugins[0].apps?.[0]?.url, "https://claude-design.ccrdesk.top/design");
|
||||
assert.deepEqual(result.plugins[0].config, {
|
||||
adminAuth: "gateway",
|
||||
host: "claude.ai"
|
||||
});
|
||||
assert.equal(result.plugins[1].module, path.join(extensionsRoot, "plugins", "claude-ship", "index.cjs"));
|
||||
assert.equal(result.plugins[1].module, bundledPluginModule("claude-ship"));
|
||||
assert.equal(result.plugins[1].apps?.[0]?.url, "https://claude.ai/claude-ship");
|
||||
assert.deepEqual(result.plugins[1].config, {
|
||||
adminAuth: "gateway",
|
||||
@@ -59,6 +62,7 @@ test("legacy combined Claude Design plugin config migrates to split Design and S
|
||||
});
|
||||
} finally {
|
||||
restoreEnv("CCR_EXTENSIONS_DIR", previousExtensionsDir);
|
||||
restoreEnv(CCR_DESKTOP_APP_ENV, previousDesktopApp);
|
||||
rmSync(extensionsRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -93,42 +97,14 @@ test("legacy Claude Design migration does not duplicate an existing Claude Ship
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy Claude Ship app URL migrates back to the local runtime host", () => {
|
||||
const extensionsRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-extensions-migration-"));
|
||||
const previousExtensionsDir = process.env.CCR_EXTENSIONS_DIR;
|
||||
test("Claude Design runtime plugin config resolves from the bundled plugin in CCR Desktop without persisting", () => {
|
||||
const previousDesktopApp = process.env[CCR_DESKTOP_APP_ENV];
|
||||
try {
|
||||
writePluginModule(extensionsRoot, "claude-ship");
|
||||
process.env.CCR_EXTENSIONS_DIR = extensionsRoot;
|
||||
|
||||
const result = migrateKnownGatewayPluginConfigsForTest([{
|
||||
apps: [{
|
||||
id: "claude-ship",
|
||||
name: "Claude Ship",
|
||||
url: "https://claude-design-assets.pages.dev/claude-ship"
|
||||
}],
|
||||
enabled: true,
|
||||
id: "claude-ship",
|
||||
module: path.join(extensionsRoot, "plugins", "claude-ship", "index.cjs")
|
||||
}]);
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.plugins[0].apps?.[0]?.url, "https://claude.ai/claude-ship");
|
||||
} finally {
|
||||
restoreEnv("CCR_EXTENSIONS_DIR", previousExtensionsDir);
|
||||
rmSync(extensionsRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude Design runtime plugin config resolves from ccr-extensions without persisting", () => {
|
||||
const extensionsRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-extensions-runtime-"));
|
||||
const previousExtensionsDir = process.env.CCR_EXTENSIONS_DIR;
|
||||
try {
|
||||
writePluginModule(extensionsRoot, "claude-design");
|
||||
process.env.CCR_EXTENSIONS_DIR = extensionsRoot;
|
||||
process.env[CCR_DESKTOP_APP_ENV] = "1";
|
||||
|
||||
const plugin = claudeDesignRuntimePluginConfig();
|
||||
assert.equal(plugin?.id, "claude-design");
|
||||
assert.equal(plugin?.module, path.join(extensionsRoot, "plugins", "claude-design", "index.cjs"));
|
||||
assert.equal(plugin?.module, bundledPluginModule("claude-design"));
|
||||
assert.deepEqual(plugin?.apps?.map((app) => app.id), ["claude-design"]);
|
||||
|
||||
const config = { plugins: [] };
|
||||
@@ -136,12 +112,32 @@ test("Claude Design runtime plugin config resolves from ccr-extensions without p
|
||||
assert.deepEqual(config.plugins, []);
|
||||
assert.equal(runtimeConfig.plugins.length, 1);
|
||||
assert.equal(runtimeConfig.plugins[0].id, "claude-design");
|
||||
assert.equal(runtimeConfig.plugins[0].module, bundledPluginModule("claude-design"));
|
||||
|
||||
const missingModule = { plugins: [{ enabled: true, id: "claude-design" }] };
|
||||
const filledConfig = withClaudeDesignRuntimePluginConfig(missingModule);
|
||||
assert.equal(filledConfig.plugins[0].module, bundledPluginModule("claude-design"));
|
||||
assert.equal(missingModule.plugins[0].module, undefined);
|
||||
|
||||
const configured = { plugins: [{ enabled: true, id: "claude-design", module: "/custom/design.cjs" }] };
|
||||
assert.equal(withClaudeDesignRuntimePluginConfig(configured), configured);
|
||||
} finally {
|
||||
restoreEnv("CCR_EXTENSIONS_DIR", previousExtensionsDir);
|
||||
rmSync(extensionsRoot, { force: true, recursive: true });
|
||||
restoreEnv(CCR_DESKTOP_APP_ENV, previousDesktopApp);
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude Design runtime plugin config is unavailable outside CCR Desktop", () => {
|
||||
const previousDesktopApp = process.env[CCR_DESKTOP_APP_ENV];
|
||||
try {
|
||||
delete process.env[CCR_DESKTOP_APP_ENV];
|
||||
|
||||
assert.equal(claudeDesignRuntimePluginConfig(), undefined);
|
||||
assert.throws(
|
||||
() => withClaudeDesignRuntimePluginConfig({ plugins: [] }),
|
||||
/Claude Design is only available in CCR Desktop/
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(CCR_DESKTOP_APP_ENV, previousDesktopApp);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -211,18 +207,17 @@ test("externalized plugin modules migrate from the old marketplace path to ccr-e
|
||||
const extensionsRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-extensions-migration-"));
|
||||
const previousExtensionsDir = process.env.CCR_EXTENSIONS_DIR;
|
||||
try {
|
||||
writePluginModule(extensionsRoot, "agent-console");
|
||||
writePluginModule(extensionsRoot, "cursor-proxy");
|
||||
process.env.CCR_EXTENSIONS_DIR = extensionsRoot;
|
||||
|
||||
const result = migrateKnownGatewayPluginConfigsForTest([{
|
||||
apps: [{ id: "agent-console", name: "Agent Console", url: "/plugins/agent-console/pages/home/" }],
|
||||
enabled: true,
|
||||
id: "agent-console",
|
||||
module: "/Users/example/products/CCR/claude-code-router/marketplace/plugins/agent-console/index.cjs"
|
||||
id: "cursor-proxy",
|
||||
module: "/Users/example/products/CCR/claude-code-router/marketplace/plugins/cursor-proxy/index.cjs"
|
||||
}]);
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.plugins[0].module, path.join(extensionsRoot, "plugins", "agent-console", "index.cjs"));
|
||||
assert.equal(result.plugins[0].module, path.join(extensionsRoot, "plugins", "cursor-proxy", "index.cjs"));
|
||||
} finally {
|
||||
restoreEnv("CCR_EXTENSIONS_DIR", previousExtensionsDir);
|
||||
rmSync(extensionsRoot, { force: true, recursive: true });
|
||||
@@ -235,6 +230,13 @@ function writePluginModule(root, pluginId) {
|
||||
writeFileSync(path.join(dir, "index.cjs"), "\"use strict\";\nmodule.exports = {};\n", "utf8");
|
||||
}
|
||||
|
||||
function bundledPluginModule(pluginId) {
|
||||
const distModule = path.resolve(process.cwd(), "packages", "electron", "dist", "bundled-plugins", pluginId, "index.cjs");
|
||||
return existsSync(distModule)
|
||||
? distModule
|
||||
: path.resolve(process.cwd(), "packages", "electron", "bundled-plugins", pluginId, "index.cjs");
|
||||
}
|
||||
|
||||
function restoreEnv(key, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
|
||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { CLAUDE_DESIGN_PLUGIN_ID, knownGatewayPluginDefaultApps } from "@ccr/core/contracts/app.ts";
|
||||
|
||||
test("known gateway plugin defaults open Claude Design at the current Cloudflare shell", () => {
|
||||
test("known gateway plugin defaults open Claude Design at the current shell", () => {
|
||||
assert.equal(
|
||||
knownGatewayPluginDefaultApps(CLAUDE_DESIGN_PLUGIN_ID)?.find((app) => app.id === "claude-design")?.url,
|
||||
"https://claude-design-assets.pages.dev/design"
|
||||
"https://claude-design.ccrdesk.top/design"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Claude Design Plugin
|
||||
|
||||
This directory is installable through CCR Desktop's local extension picker.
|
||||
|
||||
1. Open **Extensions**.
|
||||
2. Click **Install**.
|
||||
3. Click **Choose folder**.
|
||||
4. Select this `plugins/claude-design` directory.
|
||||
5. Install and keep the extension enabled.
|
||||
|
||||
Claude Design and Claude Ship are separate plugins. Install `plugins/claude-ship` when you also need the Ship app.
|
||||
|
||||
By default the Claude Design window loads `https://claude-design.ccrdesk.top/design` from the frontend embedded in the plugin code. Static files under `/design/*` are served by the Claude Design frontend host, while CCR still intercepts Design API paths such as `/v1/design`, `/design/v1/design`, Omelette RPC, bootstrap, and privacy consent probes.
|
||||
|
||||
Browser-saved Claude Design HTML and Claude app `ion-dist` assets are no longer auto-detected. The plugin uses the Cloudflare Pages frontend by default; for local development fixtures, set `assetDir` explicitly.
|
||||
|
||||
When the packaged app owns the `ccr://` protocol handler, the window can also be opened with:
|
||||
|
||||
```sh
|
||||
open 'ccr://plugin/claude-design/open'
|
||||
```
|
||||
|
||||
The local test is considered healthy when Claude Design opens, can create a project, can list projects, and can send an agent message.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "claude-design",
|
||||
"name": "Claude Design",
|
||||
"description": "Opens Claude Design in a dedicated Electron window and routes its local traffic through the CCR wrapper backend with configurable model routing.",
|
||||
"module": "index.cjs",
|
||||
"surfaces": {
|
||||
"apps": true,
|
||||
"gateway": true,
|
||||
"provider": false
|
||||
},
|
||||
"permissions": [
|
||||
"trusted-code",
|
||||
"apps",
|
||||
"gateway-routes",
|
||||
"proxy-routes",
|
||||
"http-backends",
|
||||
"sqlite-store"
|
||||
],
|
||||
"apps": [
|
||||
{
|
||||
"id": "claude-design",
|
||||
"name": "Claude Design",
|
||||
"description": "Open Claude Design in a dedicated CCR Electron window.",
|
||||
"icon": "palette",
|
||||
"url": "https://claude-design.ccrdesk.top/design"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Claude Ship Plugin
|
||||
|
||||
This directory is installable through CCR Desktop's local extension picker.
|
||||
|
||||
1. Open **Extensions**.
|
||||
2. Click **Install**.
|
||||
3. Click **Choose folder**.
|
||||
4. Select this `plugins/claude-ship` directory.
|
||||
5. Install and keep the extension enabled.
|
||||
|
||||
Claude Ship and Claude Design are separate plugins. Install `plugins/claude-design` when you also need the Design app.
|
||||
|
||||
By default the Claude Ship window opens `https://claude.ai/claude-ship` so relative runtime requests land on the CCR wrapper backend. The frontend shell and static assets still come from the Cloudflare Pages asset origin embedded in the plugin code. CCR intercepts Ship API paths such as `/v1/code`, `/v1/sessions`, bootstrap, billing promotion, and privacy consent probes. Ship static bundle paths such as `/ship/assets/*` are also proxied through CCR so the local runtime patch can be applied before the browser executes them.
|
||||
|
||||
Claude Ship no longer loads from Claude Desktop's local `ion-dist` assets. Keep the Cloudflare Pages frontend available, or use an explicit `assetDir` only for local development fixtures.
|
||||
|
||||
When the packaged app owns the `ccr://` protocol handler, the window can also be opened with:
|
||||
|
||||
```sh
|
||||
open 'ccr://plugin/claude-ship/open'
|
||||
```
|
||||
|
||||
The local test is considered healthy when Claude Ship opens, exposes a non-empty agent list, can create a session/project through `/v1/code/sessions`, lists it through `/v1/code/sessions` and `/v1/code/session_groupings`, previews locally, and can publish through `/v1/code/baku/sessions/:id/deploy`.
|
||||
|
||||
## Cloudflare Publishing
|
||||
|
||||
Claude Ship's Publish button posts to `/v1/code/baku/sessions/:id/deploy`. When `cloudflarePages` is configured, the plugin maps that request to Cloudflare Pages Direct Upload and returns the SSE deployment payload expected by the Claude.app frontend. Without Cloudflare credentials, the endpoint stores a local publish record so the desktop UI can complete the workflow during local testing.
|
||||
|
||||
Add this to the plugin config or use the equivalent environment variables:
|
||||
|
||||
```jsonc
|
||||
"cloudflarePages": {
|
||||
"enabled": true,
|
||||
"accountId": "your-cloudflare-account-id",
|
||||
"apiToken": "a-token-with-pages-write",
|
||||
"projectName": "my-claude-ship-project",
|
||||
"branch": "main",
|
||||
"createProject": true
|
||||
}
|
||||
```
|
||||
|
||||
Supported environment variables:
|
||||
|
||||
- `CLOUDFLARE_ACCOUNT_ID` or `CF_ACCOUNT_ID`
|
||||
- `CLOUDFLARE_API_TOKEN` or `CF_API_TOKEN`
|
||||
- `CLOUDFLARE_PAGES_PROJECT_NAME` or `CCR_CLAUDE_SHIP_CLOUDFLARE_PROJECT`
|
||||
- `CLOUDFLARE_PAGES_PROJECT_NAME_TEMPLATE` or `CCR_CLAUDE_SHIP_CLOUDFLARE_PROJECT_TEMPLATE`
|
||||
- `CLOUDFLARE_PAGES_BRANCH` or `CCR_CLAUDE_SHIP_CLOUDFLARE_BRANCH`
|
||||
|
||||
If `projectName` is omitted, the plugin derives a Pages-safe project name from the session title and id. The published artifact is static HTML plus `_headers`; the local preview debug bridge is not uploaded.
|
||||
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = require("../claude-design/index.cjs").createClaudeProductPlugin("ship");
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "claude-ship",
|
||||
"name": "Claude Ship",
|
||||
"description": "Opens Claude Ship in a dedicated Electron window and routes its local traffic through the CCR wrapper backend with configurable model routing.",
|
||||
"module": "index.cjs",
|
||||
"surfaces": {
|
||||
"apps": true,
|
||||
"gateway": true,
|
||||
"provider": false
|
||||
},
|
||||
"permissions": [
|
||||
"trusted-code",
|
||||
"apps",
|
||||
"gateway-routes",
|
||||
"proxy-routes",
|
||||
"http-backends",
|
||||
"sqlite-store"
|
||||
],
|
||||
"apps": [
|
||||
{
|
||||
"id": "claude-ship",
|
||||
"name": "Claude Ship",
|
||||
"description": "Open Claude Ship in a dedicated CCR Electron window.",
|
||||
"icon": "rocket",
|
||||
"url": "https://claude.ai/claude-ship"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import type { Event as ElectronEvent, Session, WebContents } from "electron";
|
||||
import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store";
|
||||
import {
|
||||
CLAUDE_DESIGN_PLUGIN_ID,
|
||||
CLAUDE_SHIP_PLUGIN_ID,
|
||||
@@ -67,7 +68,7 @@ export async function loadClaudeDesignWindowCdpOptions(
|
||||
pluginId = CLAUDE_DESIGN_PLUGIN_ID
|
||||
): Promise<ClaudeDesignWindowCdpOptions> {
|
||||
const statusUrl = new URL(claudePluginAdminPath(pluginId), gatewayOriginFromConfig(config));
|
||||
const headers = gatewayAuthHeaders(config);
|
||||
const headers = await gatewayAuthHeaders(config);
|
||||
const response = await fetch(statusUrl, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
@@ -487,10 +488,33 @@ function gatewayOriginFromConfig(config: AppConfig): string {
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function gatewayAuthHeaders(config: AppConfig): Record<string, string> {
|
||||
async function gatewayAuthHeaders(config: AppConfig): Promise<Record<string, string>> {
|
||||
const apiKey = gatewayAuthKeyFromConfig(config) || await persistedGatewayAuthKey();
|
||||
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
||||
}
|
||||
|
||||
function gatewayAuthKeyFromConfig(config: AppConfig): string {
|
||||
return (Array.isArray(config.APIKEYS) ? config.APIKEYS : [])
|
||||
.map((item) => item.key?.trim() || "")
|
||||
.find(Boolean) || config.APIKEY?.trim() || "";
|
||||
}
|
||||
|
||||
async function persistedGatewayAuthKey(): Promise<string> {
|
||||
try {
|
||||
return (await loadPersistedApiKeys())
|
||||
.map((item) => item.key?.trim() || "")
|
||||
.find(Boolean) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function gatewayAuthHeadersForTest(config: AppConfig, persistedApiKeys: Array<{ key?: string }> = []): Record<string, string> {
|
||||
const apiKey = (Array.isArray(config.APIKEYS) ? config.APIKEYS : [])
|
||||
.map((item) => item.key?.trim() || "")
|
||||
.find(Boolean) || config.APIKEY?.trim();
|
||||
.find(Boolean) || config.APIKEY?.trim() || persistedApiKeys
|
||||
.map((item) => item.key?.trim() || "")
|
||||
.find(Boolean);
|
||||
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-
|
||||
import { gatewayService } from "@ccr/core/gateway/service";
|
||||
import { providerIdentitySafetyIssue } from "@ccr/core/providers/presets/index";
|
||||
import { loadClaudeDesignWindowCdpOptions } from "./claude-design-window";
|
||||
import { builtInPluginAppForOpen, pluginAppUrlForOpen } from "./plugin-app-url";
|
||||
import { builtInPluginAppForOpen, configForPluginAppOpen, pluginAppUrlForOpen } from "./plugin-app-url";
|
||||
import windowsManager from "./windows";
|
||||
|
||||
type PluginDeepLinkRequest = {
|
||||
@@ -126,7 +126,7 @@ class DeepLinkService {
|
||||
|
||||
try {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const config = configForPluginAppOpen(syncedClaudeAppConfig.config, request.pluginId);
|
||||
const pluginApp = resolvePluginApp(config, request);
|
||||
if (!pluginApp) {
|
||||
throw new Error(`Plugin app is not configured or enabled: ${request.pluginId}`);
|
||||
|
||||
@@ -58,6 +58,7 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
||||
configDir: CONFIGDIR,
|
||||
configFile: CONFIG_FILE,
|
||||
dataDir: DATADIR,
|
||||
desktop: true,
|
||||
gatewayConfigFile: GATEWAY_CONFIG_FILE,
|
||||
launchAtLoginSupported: isLaunchAtLoginSupported(),
|
||||
name: APP_NAME,
|
||||
@@ -130,10 +131,6 @@ ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, async (_event, serverName: st
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => {
|
||||
const config = await loadAppConfig();
|
||||
if (hasEnabledPluginApp(config, "agent-console")) {
|
||||
await deepLinkService.openPluginApp("agent-console");
|
||||
return;
|
||||
}
|
||||
await builtInBrowserService.open(config);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenPluginApp, async (_event, pluginId: string, appId?: string) => {
|
||||
@@ -1496,14 +1493,6 @@ function isFile(file: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function hasEnabledPluginApp(config: AppConfig, pluginId: string): boolean {
|
||||
return config.plugins.some((plugin) =>
|
||||
plugin.id === pluginId &&
|
||||
plugin.enabled !== false &&
|
||||
plugin.apps?.some((app) => typeof app.url === "string" && app.url.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { app, dialog } from "electron";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat";
|
||||
import { markDesktopAppRuntime } from "@ccr/core/runtime/desktop-app";
|
||||
import { resolveRuntimeDataDir, setRuntimeAppPaths } from "@ccr/core/runtime/app-paths";
|
||||
import { copyMissingDirectoryContents, sameFilesystemPath } from "@ccr/core/storage/migration";
|
||||
|
||||
installSocketTypeOfServiceCompat();
|
||||
markDesktopAppRuntime();
|
||||
|
||||
const appDataPath = app.getPath("appData");
|
||||
const homePath = app.getPath("home");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config";
|
||||
import { CLAUDE_DESIGN_PLUGIN_ID, knownGatewayPluginDefaultApps, type AppConfig, type GatewayPluginAppConfig } from "@ccr/core/contracts/app";
|
||||
|
||||
const DEFAULT_CLAUDE_DESIGN_FRONTEND_URL = "https://claude-design-assets.pages.dev/design";
|
||||
const DEFAULT_CLAUDE_DESIGN_FRONTEND_URL = "https://claude-design.ccrdesk.top/design";
|
||||
|
||||
export function pluginAppUrlForOpen(_config: AppConfig, pluginId: string, appUrl: string): string {
|
||||
if (pluginId !== CLAUDE_DESIGN_PLUGIN_ID) {
|
||||
@@ -24,6 +25,12 @@ export function builtInPluginAppForOpen(pluginId: string, appId?: string): Gatew
|
||||
return apps[0];
|
||||
}
|
||||
|
||||
export function configForPluginAppOpen(config: AppConfig, pluginId: string): AppConfig {
|
||||
return pluginId === CLAUDE_DESIGN_PLUGIN_ID
|
||||
? withClaudeDesignRuntimePluginConfig(config)
|
||||
: config;
|
||||
}
|
||||
|
||||
export function isLegacyClaudeDesignUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value, "https://claude.ai");
|
||||
@@ -32,9 +39,6 @@ export function isLegacyClaudeDesignUrl(value: string): boolean {
|
||||
if (host === "claude.ai") {
|
||||
return pathname === "/discover/design" || pathname === "/design";
|
||||
}
|
||||
if (host === "claude-design-assets.pages.dev") {
|
||||
return pathname === "/discover/design";
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
claudeDesignCdpFetchPatterns,
|
||||
claudeDesignCdpOptionsFromStatus,
|
||||
claudeDesignRedirectUrlForRequest,
|
||||
claudePluginAdminPath
|
||||
claudePluginAdminPath,
|
||||
gatewayAuthHeadersForTest
|
||||
} from "@ccr/electron/main/claude-design-window.ts";
|
||||
|
||||
test("Claude browser plugin status paths are selected per plugin", () => {
|
||||
assert.equal(claudePluginAdminPath("claude-design"), "/plugins/claude-design");
|
||||
assert.equal(claudePluginAdminPath("claude-ship"), "/plugins/claude-ship");
|
||||
assert.throws(() => claudePluginAdminPath("agent-console"), /not supported/);
|
||||
assert.throws(() => claudePluginAdminPath("unknown-plugin"), /not supported/);
|
||||
});
|
||||
|
||||
test("Claude Design window CDP options are derived from plugin status", () => {
|
||||
@@ -106,3 +107,14 @@ test("Claude Design window CDP reads binary post data entries before proxying to
|
||||
"content-type": "application/connect+proto"
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude Design window status auth falls back to persisted gateway API keys", () => {
|
||||
assert.deepEqual(
|
||||
gatewayAuthHeadersForTest({ APIKEY: "", APIKEYS: [], plugins: [] } as any, [{ key: "persisted-key" }]),
|
||||
{ authorization: "Bearer persisted-key" }
|
||||
);
|
||||
assert.deepEqual(
|
||||
gatewayAuthHeadersForTest({ APIKEY: "config-key", APIKEYS: [], plugins: [] } as any, [{ key: "persisted-key" }]),
|
||||
{ authorization: "Bearer config-key" }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { CLAUDE_DESIGN_PLUGIN_ID } from "@ccr/core/contracts/app.ts";
|
||||
import { builtInPluginAppForOpen, isLegacyClaudeDesignUrl, pluginAppUrlForOpen } from "@ccr/electron/main/plugin-app-url.ts";
|
||||
import { CCR_DESKTOP_APP_ENV } from "@ccr/core/runtime/desktop-app.ts";
|
||||
import { builtInPluginAppForOpen, configForPluginAppOpen, isLegacyClaudeDesignUrl, pluginAppUrlForOpen } from "@ccr/electron/main/plugin-app-url.ts";
|
||||
|
||||
const configWithIgnoredSavedDesignHtml = {
|
||||
plugins: [
|
||||
@@ -16,22 +19,21 @@ const configWithIgnoredSavedDesignHtml = {
|
||||
]
|
||||
} as any;
|
||||
|
||||
test("Claude Design app URLs classify only legacy Claude and old Cloudflare Design entries as legacy", () => {
|
||||
test("Claude Design app URLs classify legacy Claude Design entries as legacy", () => {
|
||||
assert.equal(isLegacyClaudeDesignUrl("https://claude.ai/discover/design"), true);
|
||||
assert.equal(isLegacyClaudeDesignUrl("https://claude.ai/design"), true);
|
||||
assert.equal(isLegacyClaudeDesignUrl("https://claude-design-assets.pages.dev/discover/design"), true);
|
||||
assert.equal(isLegacyClaudeDesignUrl("https://claude-design-assets.pages.dev/design"), false);
|
||||
assert.equal(isLegacyClaudeDesignUrl("https://claude-design.ccrdesk.top/design"), false);
|
||||
assert.equal(isLegacyClaudeDesignUrl("https://example.com/discover/design"), false);
|
||||
});
|
||||
|
||||
test("Claude Design app opening migrates legacy Design URLs to the Cloudflare Pages shell", () => {
|
||||
test("Claude Design app opening migrates legacy Design URLs to the current shell", () => {
|
||||
assert.equal(
|
||||
pluginAppUrlForOpen(
|
||||
configWithIgnoredSavedDesignHtml,
|
||||
"claude-design",
|
||||
"https://claude-design-assets.pages.dev/discover/design"
|
||||
"https://claude.ai/discover/design"
|
||||
),
|
||||
"https://claude-design-assets.pages.dev/design"
|
||||
"https://claude-design.ccrdesk.top/design"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,17 +42,17 @@ test("Claude Design app opening keeps current and non-Design app URLs unchanged"
|
||||
pluginAppUrlForOpen(
|
||||
configWithIgnoredSavedDesignHtml,
|
||||
"claude-design",
|
||||
"https://claude-design-assets.pages.dev/design"
|
||||
"https://claude-design.ccrdesk.top/design"
|
||||
),
|
||||
"https://claude-design-assets.pages.dev/design"
|
||||
"https://claude-design.ccrdesk.top/design"
|
||||
);
|
||||
assert.equal(
|
||||
pluginAppUrlForOpen(
|
||||
configWithIgnoredSavedDesignHtml,
|
||||
"claude-ship",
|
||||
"https://claude-design-assets.pages.dev/discover/design"
|
||||
"https://example.com/discover/design"
|
||||
),
|
||||
"https://claude-design-assets.pages.dev/discover/design"
|
||||
"https://example.com/discover/design"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -59,7 +61,60 @@ test("Claude Design resolves to the built-in app without installed plugin config
|
||||
|
||||
assert.equal(pluginApp?.id, "claude-design");
|
||||
assert.equal(pluginApp?.name, "Claude Design");
|
||||
assert.equal(pluginApp?.url, "https://claude-design-assets.pages.dev/design");
|
||||
assert.equal(builtInPluginAppForOpen("agent-console"), undefined);
|
||||
assert.equal(pluginApp?.url, "https://claude-design.ccrdesk.top/design");
|
||||
assert.equal(builtInPluginAppForOpen("unknown-plugin"), undefined);
|
||||
assert.equal(builtInPluginAppForOpen(CLAUDE_DESIGN_PLUGIN_ID, "missing"), undefined);
|
||||
});
|
||||
|
||||
test("Claude Design app opening injects the runtime plugin config", () => {
|
||||
withDesktopRuntime(() => {
|
||||
const config = { plugins: [] } as any;
|
||||
const runtimeConfig = configForPluginAppOpen(config, CLAUDE_DESIGN_PLUGIN_ID);
|
||||
|
||||
assert.equal(config.plugins.length, 0);
|
||||
assert.equal(runtimeConfig.plugins.length, 1);
|
||||
assert.equal(runtimeConfig.plugins[0].id, CLAUDE_DESIGN_PLUGIN_ID);
|
||||
assert.equal(runtimeConfig.plugins[0].module, bundledPluginModule("claude-design"));
|
||||
assert.equal(configForPluginAppOpen(config, "claude-ship"), config);
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude Design app opening fills an existing built-in plugin config without a module", () => {
|
||||
withDesktopRuntime(() => {
|
||||
const config = {
|
||||
plugins: [{
|
||||
config: { savedHtmlPath: "/tmp/Claude Design.html" },
|
||||
enabled: true,
|
||||
id: CLAUDE_DESIGN_PLUGIN_ID
|
||||
}]
|
||||
} as any;
|
||||
|
||||
const runtimeConfig = configForPluginAppOpen(config, CLAUDE_DESIGN_PLUGIN_ID);
|
||||
|
||||
assert.equal(config.plugins[0].module, undefined);
|
||||
assert.equal(runtimeConfig.plugins.length, 1);
|
||||
assert.equal(runtimeConfig.plugins[0].module, bundledPluginModule("claude-design"));
|
||||
assert.deepEqual(runtimeConfig.plugins[0].config, { savedHtmlPath: "/tmp/Claude Design.html" });
|
||||
});
|
||||
});
|
||||
|
||||
function bundledPluginModule(pluginId: string): string {
|
||||
const distModule = path.resolve(process.cwd(), "packages", "electron", "dist", "bundled-plugins", pluginId, "index.cjs");
|
||||
return existsSync(distModule)
|
||||
? distModule
|
||||
: path.resolve(process.cwd(), "packages", "electron", "bundled-plugins", pluginId, "index.cjs");
|
||||
}
|
||||
|
||||
function withDesktopRuntime(run: () => void): void {
|
||||
const previousDesktopApp = process.env[CCR_DESKTOP_APP_ENV];
|
||||
try {
|
||||
process.env[CCR_DESKTOP_APP_ENV] = "1";
|
||||
run();
|
||||
} finally {
|
||||
if (previousDesktopApp === undefined) {
|
||||
delete process.env[CCR_DESKTOP_APP_ENV];
|
||||
} else {
|
||||
process.env[CCR_DESKTOP_APP_ENV] = previousDesktopApp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
OverviewWidgetConfig, parseProviderAccountDraft, pluginConfigPatchFromSettingsDraft,
|
||||
providerCredentialsFromDraft,
|
||||
persistLanguagePreference, PluginInstallCandidate, PluginMarketplaceEntry, PluginRoutingConfigTarget, PluginSettingsDraft, presetCapabilitiesFromDraft,
|
||||
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileDraftWithDetectedAppPath, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
||||
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileAgentOptionsForRuntime, profileDraftWithDetectedAppPath, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
||||
profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
|
||||
providerBaseUrl, providerCapabilitiesForProtocols, providerCapabilitiesForSave, providerConnectivityApiKeyFromDraft, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerProtocolOptions, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot,
|
||||
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
|
||||
@@ -219,6 +219,12 @@ function App() {
|
||||
const [profileActionBusy, setProfileActionBusy] = useState<ProfileActionBusy>();
|
||||
const [profileRuntimeStatus, setProfileRuntimeStatus] = useState<ProfileRuntimeStatus>({ profiles: [] });
|
||||
const [profileSubmitBusy, setProfileSubmitBusy] = useState<"" | "add" | "edit">("");
|
||||
const availableProfileAgentOptions = useMemo(() => profileAgentOptionsForRuntime(appInfo.desktop), [appInfo.desktop]);
|
||||
const defaultAvailableProfileAgent = availableProfileAgentOptions[0]?.value ?? "claude-code";
|
||||
const isProfileAgentAvailable = useMemo(() => {
|
||||
const availableAgents = new Set(availableProfileAgentOptions.map((option) => option.value));
|
||||
return (agent: ProfileConfig["agent"]) => availableAgents.has(agent);
|
||||
}, [availableProfileAgentOptions]);
|
||||
const [apiKeyAddOpen, setApiKeyAddOpen] = useState(false);
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState<AddApiKeyDraft>(() => createApiKeyDraft());
|
||||
const [apiKeyEditDraft, setApiKeyEditDraft] = useState<AddApiKeyDraft>(() => createApiKeyDraft());
|
||||
@@ -381,6 +387,18 @@ function App() {
|
||||
setProfileEditDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath));
|
||||
}, [appInfo.chatgptAppPath, appInfo.opencodeAppPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProfileAgentAvailable(profileAgentTab)) {
|
||||
setProfileAgentTab(defaultAvailableProfileAgent);
|
||||
}
|
||||
setProfileDraft((current) => isProfileAgentAvailable(current.agent)
|
||||
? current
|
||||
: createProfileDraft(defaultAvailableProfileAgent));
|
||||
setProfileEditDraft((current) => isProfileAgentAvailable(current.agent)
|
||||
? current
|
||||
: createProfileDraft(defaultAvailableProfileAgent));
|
||||
}, [defaultAvailableProfileAgent, isProfileAgentAvailable, profileAgentTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.ccr) {
|
||||
return;
|
||||
@@ -2563,8 +2581,9 @@ function App() {
|
||||
}
|
||||
|
||||
function openAddProfileDialog(agent: ProfileConfig["agent"] = profileAgentTab) {
|
||||
setProfileAgentTab(agent);
|
||||
setProfileDraft(profileDraftWithDetectedAppPath(createProfileDraft(agent), appInfo.chatgptAppPath, appInfo.opencodeAppPath));
|
||||
const resolvedAgent = isProfileAgentAvailable(agent) ? agent : defaultAvailableProfileAgent;
|
||||
setProfileAgentTab(resolvedAgent);
|
||||
setProfileDraft(profileDraftWithDetectedAppPath(createProfileDraft(resolvedAgent), appInfo.chatgptAppPath, appInfo.opencodeAppPath));
|
||||
setProfileActionError("");
|
||||
setProfileAddOpen(true);
|
||||
}
|
||||
@@ -3008,6 +3027,7 @@ function App() {
|
||||
loaded={configLoaded && onboardingStatusLoaded && providerPresetsLoaded}
|
||||
onboarding={{
|
||||
activeStep: onboardingStep,
|
||||
agentOptions: availableProfileAgentOptions,
|
||||
canSubmitProfile,
|
||||
canSubmitProvider,
|
||||
config: draftConfig,
|
||||
@@ -3129,6 +3149,7 @@ function App() {
|
||||
},
|
||||
profile: {
|
||||
addProfile: openAddProfileDialog,
|
||||
agentOptions: availableProfileAgentOptions,
|
||||
applyError: profileActionError,
|
||||
copyProfileCliCommand: (index) => void copyProfileCliCommand(index),
|
||||
config: draftConfig,
|
||||
@@ -3262,6 +3283,7 @@ function App() {
|
||||
onSubmit: submitPluginSettingsDraft
|
||||
} : undefined}
|
||||
profileAdd={profileAddOpen ? {
|
||||
agentOptions: availableProfileAgentOptions,
|
||||
botConfigs: draftConfig.botConfigs,
|
||||
canSubmit: canSubmitProfile,
|
||||
draft: profileDraft,
|
||||
@@ -3281,6 +3303,7 @@ function App() {
|
||||
profile: profileDeleteItem
|
||||
} : undefined}
|
||||
profileEdit={profileEditIndex !== undefined ? {
|
||||
agentOptions: availableProfileAgentOptions,
|
||||
botConfigs: draftConfig.botConfigs,
|
||||
canSubmit: canSubmitProfileEdit,
|
||||
draft: profileEditDraft,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
AddProfileDraft, AddProviderDraft, AppConfig, Button, Check, ChevronLeft,
|
||||
ChevronRight, cn, findProviderPreset, GatewayProviderProbeResult, GatewayStatus, Gauge, getNextOnboardingStep,
|
||||
isOnboardingProfileReady, isOnboardingProviderReady, Layers3, LucideIcon, mergeProviderModelLists, motion, motionEase,
|
||||
LoaderCircle, onboardingMascotSpriteUrl, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, providerDraftHasReadyCredentialPool, ProviderConnectivityCheckReport, reducedMotionTransition, splitLines, useAppText, useEffect, useReducedMotion,
|
||||
LoaderCircle, onboardingMascotSpriteUrl, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, type ProfileAgentOption, providerDraftHasReadyCredentialPool, ProviderConnectivityCheckReport, reducedMotionTransition, splitLines, useAppText, useEffect, useReducedMotion,
|
||||
useState,
|
||||
UserRound, X
|
||||
} from "../shared/index";
|
||||
@@ -60,6 +60,7 @@ const onboardingMascotPalettes: Record<OnboardingMascotTone, { accent: string; g
|
||||
|
||||
export function OnboardingView({
|
||||
activeStep,
|
||||
agentOptions,
|
||||
canSubmitProfile,
|
||||
canSubmitProvider,
|
||||
config,
|
||||
@@ -83,6 +84,7 @@ export function OnboardingView({
|
||||
readiness
|
||||
}: {
|
||||
activeStep: OnboardingStepId;
|
||||
agentOptions: ProfileAgentOption[];
|
||||
canSubmitProfile: boolean;
|
||||
canSubmitProvider: boolean;
|
||||
config: AppConfig;
|
||||
@@ -301,6 +303,7 @@ export function OnboardingView({
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[720px]">
|
||||
<AddProfileForm
|
||||
agentOptions={agentOptions}
|
||||
botConfigs={[]}
|
||||
draft={profileDraft}
|
||||
error={profileError}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
|
||||
DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion,
|
||||
normalizeProfileScope, normalizeProfileSurface, Pencil, Plus, PopoverContent,
|
||||
profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
|
||||
profileAgentLabel, profileAgentOptions, ProfileConfig, type ProfileAgentOption, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
|
||||
Play, Power, RefreshCw, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, useLayoutEffect, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig,
|
||||
copyTextToClipboard, validateProfileEnvRows,
|
||||
useCallback, useEffect, useMemo, useRef, useState, X
|
||||
@@ -23,6 +23,7 @@ type ProfileActionBusy = {
|
||||
export function ProfileView({
|
||||
addProfile,
|
||||
applyError,
|
||||
agentOptions = profileAgentOptions,
|
||||
copyProfileCliCommand,
|
||||
config,
|
||||
editProfile,
|
||||
@@ -34,6 +35,7 @@ export function ProfileView({
|
||||
updateProfileItem
|
||||
}: {
|
||||
addProfile: (agent?: ProfileConfig["agent"]) => void;
|
||||
agentOptions?: ProfileAgentOption[];
|
||||
applyError: string;
|
||||
copyProfileCliCommand: (index: number) => void;
|
||||
config: AppConfig;
|
||||
@@ -47,6 +49,10 @@ export function ProfileView({
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const profiles = config.profile.profiles;
|
||||
const visibleAgentValues = new Set(agentOptions.map((option) => option.value));
|
||||
const visibleProfiles = profiles
|
||||
.map((profile, index) => ({ index, profile }))
|
||||
.filter(({ profile }) => visibleAgentValues.has(profile.agent));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -74,12 +80,12 @@ export function ProfileView({
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 overflow-auto max-[720px]:p-3">
|
||||
<div className="grid min-w-0 gap-3 [grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))] max-[720px]:gap-2.5">
|
||||
{profiles.length === 0 ? (
|
||||
{visibleProfiles.length === 0 ? (
|
||||
<div className="col-span-full flex h-32 items-center justify-center rounded-md border border-dashed border-border bg-muted/20 text-[12px] text-muted-foreground">
|
||||
{t("No profiles configured")}
|
||||
</div>
|
||||
) : null}
|
||||
{profiles.map((profile, index) => {
|
||||
{visibleProfiles.map(({ profile, index }) => {
|
||||
const scope = normalizeProfileScope(profile.scope);
|
||||
const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface);
|
||||
const openSurfaces = profileOpenSurfaces(profile);
|
||||
@@ -510,10 +516,12 @@ function ProfileCliCommandBlock({
|
||||
|
||||
function ProfileAgentTabs({
|
||||
activeAgent,
|
||||
agentOptions,
|
||||
profiles,
|
||||
setActiveAgent
|
||||
}: {
|
||||
activeAgent: ProfileConfig["agent"];
|
||||
agentOptions: ProfileAgentOption[];
|
||||
profiles: ProfileConfig[];
|
||||
setActiveAgent: (agent: ProfileConfig["agent"]) => void;
|
||||
}) {
|
||||
@@ -525,7 +533,7 @@ function ProfileAgentTabs({
|
||||
className="grid grid-cols-1 gap-1 rounded-md border border-border bg-muted/20 p-1 sm:grid-cols-7"
|
||||
role="tablist"
|
||||
>
|
||||
{profileAgentOptions.map((option) => {
|
||||
{agentOptions.map((option) => {
|
||||
const agent = option.value;
|
||||
const selected = activeAgent === agent;
|
||||
const count = profiles.filter((profile) => profile.agent === agent).length;
|
||||
@@ -557,9 +565,11 @@ function ProfileAgentTabs({
|
||||
}
|
||||
|
||||
function AgentSelectControl({
|
||||
agentOptions,
|
||||
onChange,
|
||||
value
|
||||
}: {
|
||||
agentOptions: ProfileAgentOption[];
|
||||
onChange: (agent: ProfileConfig["agent"]) => void;
|
||||
value: ProfileConfig["agent"];
|
||||
}) {
|
||||
@@ -590,7 +600,7 @@ function AgentSelectControl({
|
||||
const margin = 12;
|
||||
const gap = 6;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const listHeight = profileAgentOptions.length * 36 + 8;
|
||||
const listHeight = agentOptions.length * 36 + 8;
|
||||
const below = Math.max(0, viewportHeight - anchor.bottom - margin - gap);
|
||||
const above = Math.max(0, anchor.top - margin - gap);
|
||||
const placement = below < listHeight && above > below ? "above" : "below";
|
||||
@@ -683,7 +693,7 @@ function AgentSelectControl({
|
||||
role="listbox"
|
||||
style={{ maxHeight: `${popoverLayout.maxHeight}px` }}
|
||||
>
|
||||
{profileAgentOptions.map((option) => {
|
||||
{agentOptions.map((option) => {
|
||||
const agent = option.value;
|
||||
const selected = value === agent;
|
||||
|
||||
@@ -718,6 +728,7 @@ function AgentSelectControl({
|
||||
}
|
||||
|
||||
export function AddProfileForm({
|
||||
agentOptions = profileAgentOptions,
|
||||
botConfigs,
|
||||
draft,
|
||||
error,
|
||||
@@ -727,6 +738,7 @@ export function AddProfileForm({
|
||||
providers,
|
||||
virtualModelProfiles = []
|
||||
}: {
|
||||
agentOptions?: ProfileAgentOption[];
|
||||
botConfigs: BotGatewaySavedConfig[];
|
||||
draft: AddProfileDraft;
|
||||
error: string;
|
||||
@@ -796,6 +808,7 @@ export function AddProfileForm({
|
||||
>
|
||||
<Field label={t("Agent")} requirement="required" requirementLabel={requiredFieldLabel}>
|
||||
<AgentSelectControl
|
||||
agentOptions={agentOptions}
|
||||
onChange={(agent) => onChange(agent === "grok" || agent === "kimi" || agent === "pi"
|
||||
? {
|
||||
agent,
|
||||
@@ -1509,6 +1522,7 @@ function handoffTargetMatchesSavedValue(target: BotHandoffScanTarget, savedValue
|
||||
}
|
||||
|
||||
export function AddProfileDialog({
|
||||
agentOptions,
|
||||
botConfigs,
|
||||
canSubmit,
|
||||
draft,
|
||||
@@ -1522,6 +1536,7 @@ export function AddProfileDialog({
|
||||
virtualModelProfiles = [],
|
||||
onSubmit
|
||||
}: {
|
||||
agentOptions?: ProfileAgentOption[];
|
||||
botConfigs: BotGatewaySavedConfig[];
|
||||
canSubmit: boolean;
|
||||
draft: AddProfileDraft;
|
||||
@@ -1547,6 +1562,7 @@ export function AddProfileDialog({
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<AddProfileForm
|
||||
agentOptions={agentOptions}
|
||||
botConfigs={botConfigs}
|
||||
draft={draft}
|
||||
error={error}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const fallbackInfo: AppInfo = {
|
||||
configDir: "Browser preview",
|
||||
configFile: "Browser preview",
|
||||
dataDir: "Browser preview",
|
||||
desktop: false,
|
||||
gatewayConfigFile: "Browser preview",
|
||||
launchAtLoginSupported: /^Mac|^Win/i.test(navigator.platform),
|
||||
name: "Claude Code Router",
|
||||
|
||||
@@ -122,7 +122,9 @@ export const agentFilterOptions: Array<{ label: string; value: AgentFilterValue
|
||||
{ label: "Unknown", value: "unknown" }
|
||||
];
|
||||
|
||||
export const profileAgentOptions: Array<{ label: string; value: ProfileConfig["agent"] }> = [
|
||||
export type ProfileAgentOption = { label: string; value: ProfileConfig["agent"] };
|
||||
|
||||
export const profileAgentOptions: ProfileAgentOption[] = [
|
||||
{ label: "Claude Code", value: "claude-code" },
|
||||
{ label: "Codex", value: "codex" },
|
||||
{ label: "Grok CLI", value: "grok" },
|
||||
@@ -133,6 +135,12 @@ export const profileAgentOptions: Array<{ label: string; value: ProfileConfig["a
|
||||
{ label: "Claude Design", value: "claude-design" }
|
||||
];
|
||||
|
||||
export function profileAgentOptionsForRuntime(desktop: boolean): ProfileAgentOption[] {
|
||||
return desktop
|
||||
? profileAgentOptions
|
||||
: profileAgentOptions.filter((option) => option.value !== "claude-design");
|
||||
}
|
||||
|
||||
export const profileScopeOptions: Array<{ label: string; value: ProfileScope }> = [
|
||||
{ label: "Only opened from CCR", value: "ccr" },
|
||||
{ label: "System default", value: "global" }
|
||||
|
||||
Reference in New Issue
Block a user