From 4b0868f8a7bf30dcbf74777be07159de45131468 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 24 Jul 2026 15:51:17 +0800 Subject: [PATCH 1/5] Improve responsive profile card grid layout --- packages/ui/src/pages/home/components/profiles.tsx | 4 ++-- packages/ui/test/component/profiles.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index d197009c..9f06c660 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -72,8 +72,8 @@ export function ProfileView({ - -
+ +
{profiles.length === 0 ? (
{t("No profiles configured")} diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 45223524..325d91a3 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -116,7 +116,7 @@ test("ProfileView renders agent profiles as compact cards with inline actions", ); assert.equal(html.match(/aria-label="(?:Claude Code Main|ZCode Main) Profile actions"/g)?.length, 2); - assert.match(html, /grid-template-columns:repeat\(auto-fill,minmax\(min\(100%,320px\),420px\)\)/); + assert.match(html, /grid-template-columns:repeat\(auto-fit,minmax\(min\(100%,420px\),1fr\)\)/); assert.match(html, /min-h-\[220px\]/); assert.match(html, /class="flex min-w-0 items-center gap-2"/); assert.match(html, /Configuration/); From 834da64a335969e1b4c5891fe6159b2ade459d4e Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 24 Jul 2026 16:01:11 +0800 Subject: [PATCH 2/5] Optimize provider plugin defaults and profile summaries --- .../src/pages/home/components/providers.tsx | 11 ++++-- packages/ui/src/pages/home/shared/profiles.ts | 10 +++--- packages/ui/test/component/profiles.test.tsx | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index fe997937..710d31c0 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -24,6 +24,7 @@ import type { LocalAgentProviderCandidate } from "@ccr/core/contracts/app"; import type { ReactNode } from "react"; const useClientLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; +const emptyProviderPlugins: unknown[] = []; export function ProvidersView({ accountSnapshots, addProvider, editProvider, notify, providers, removeProvider, setProviderEnabled }: { accountSnapshots: ProviderAccountSnapshot[]; @@ -1975,7 +1976,7 @@ export function AddProviderForm({ onSelectStep, probe, probeLoading, - providerPlugins = [], + providerPlugins = emptyProviderPlugins, providers }: { activeStep?: ProviderSetupStepId; @@ -2013,6 +2014,10 @@ export function AddProviderForm({ const detectedBaseUrl = providerCapabilityBaseUrlForProtocol(draft.baseUrl, detectedProtocol, probe); const safetyIssue = providerDraftSafetyIssue(draft, detectedBaseUrl); const localAgentImport = draft.providerPlugins.length > 0; + const localAgentProviderPlugins = useMemo( + () => [...providerPlugins, ...draft.providerPlugins], + [draft.providerPlugins, providerPlugins] + ); const manualProtocolDetection = draft.protocolDetectionMode === "manual"; const providerPresetOptions = [ { iconUrl: draft.icon, label: t("Other / custom API endpoint"), value: customProviderPresetId }, @@ -2265,7 +2270,7 @@ export function AddProviderForm({
@@ -3159,7 +3164,7 @@ export function AddProviderDialog({ onSubmit, probe, probeLoading, - providerPlugins = [], + providerPlugins = emptyProviderPlugins, providers, submitLabel, title diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index fc4f957c..7919585d 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -1440,12 +1440,12 @@ export function profileSummaryItems( const resolvedBotGateway = savedBot?.botGateway ?? profile.botGateway ?? config.botGateway; const botSummaryItems = surface !== "cli" && resolvedBotGateway?.enabled && resolvedBotGateway.platform !== "none" ? [{ label: t("Bot"), value: `${t("Enabled")} (${savedBot ? botGatewaySavedConfigLabel(savedBot, t) : t(botGatewayPlatformLabel(resolvedBotGateway.platform))})` }] - : surface !== "cli" && profile.botGateway - ? [{ label: t("Bot"), value: t("Disabled") }] - : []; + : []; const managedCompactItems = profile.agent === "zcode" ? [] - : [{ label: t("CCR managed compact"), value: profile.managedCompact ? t("Enabled") : t("Disabled") }]; + : profile.managedCompact + ? [{ label: t("CCR managed compact"), value: t("Enabled") }] + : []; const smallFastModel = profile.smallFastModel?.trim() || ""; const modelValue = profile.model.trim() ? profileModelDisplayValue( @@ -1497,7 +1497,7 @@ export function profileSummaryItems( return [ { label: t("Model"), value: modelValue }, { label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" }, - ...(profile.agent === "zcode" || profile.agent === "opencode" ? [] : [{ label: t("Show all sessions"), value: profile.showAllSessions ? t("Enabled") : t("Disabled") }]), + ...(profile.agent === "zcode" || profile.agent === "opencode" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]), ...managedCompactItems, ...appPathSummaryItems, ...botSummaryItems, diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 325d91a3..7c5f3bac 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -152,6 +152,42 @@ test("profileSummaryItems uses Kimi-specific model labels", () => { assert.equal(items[1]?.value, "2"); }); +test("profileSummaryItems omits disabled profile properties from cards", () => { + const config = appConfigFixture(); + const disabledItems = profileSummaryItems({ + agent: "codex", + botGateway: { enabled: false, platform: "slack" } as NonNullable, + enabled: true, + id: "codex-main", + managedCompact: false, + model: "openai/gpt-5.2", + name: "Codex Main", + providerId: "claude-code-router", + scope: "ccr", + showAllSessions: false, + surface: "auto" + }, config, (value) => value); + + assert.deepEqual(disabledItems.map((item) => item.label), ["Model", "Provider ID"]); + assert.doesNotMatch(disabledItems.map((item) => item.value).join(" "), /Disabled/); + + const enabledItems = profileSummaryItems({ + agent: "codex", + enabled: true, + id: "codex-main", + managedCompact: true, + model: "openai/gpt-5.2", + name: "Codex Main", + providerId: "claude-code-router", + scope: "ccr", + showAllSessions: true, + surface: "auto" + }, config, (value) => value); + + assert.match(enabledItems.map((item) => item.label).join(" "), /Show all sessions/); + assert.match(enabledItems.map((item) => item.label).join(" "), /CCR managed compact/); +}); + test("detected CHATGPT_APP_PATH is used as the Codex profile default", () => { const detectedPath = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT"; const draft = profileDraftWithDetectedAppPath(createProfileDraft("codex"), ` ${detectedPath} `); From 78670650998b078b41ae5aaed88f916c95c90547 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 24 Jul 2026 16:38:24 +0800 Subject: [PATCH 3/5] Support Claude Code model aliases in profiles --- .../content/docs/en/configuration/profiles.md | 6 +- .../content/docs/zh/configuration/profile.md | 6 +- packages/core/src/agents/claude-app/launch.ts | 31 +------ .../src/agents/claude-code/environment.ts | 74 ++++++++++++++++ packages/core/src/config/config.ts | 24 +++++ packages/core/src/config/default-config.ts | 8 ++ packages/core/src/contracts/app.ts | 8 ++ packages/core/src/profiles/launch-core.ts | 33 +------ packages/core/src/profiles/service.ts | 37 ++------ .../profiles/profile-service.test.mjs | 12 +++ .../profiles/profile-launch-core.test.mjs | 10 ++- .../ui/src/pages/home/components/profiles.tsx | 76 +++++++++++----- .../ui/src/pages/home/shared/controls.tsx | 28 +++++- packages/ui/src/pages/home/shared/i18n.tsx | 17 +++- packages/ui/src/pages/home/shared/profiles.ts | 88 ++++++++++++++----- packages/ui/src/pages/home/shared/types.ts | 4 + packages/ui/test/component/profiles.test.tsx | 33 ++++++- 17 files changed, 353 insertions(+), 142 deletions(-) diff --git a/docs/src/content/docs/en/configuration/profiles.md b/docs/src/content/docs/en/configuration/profiles.md index 74946adb..222a51ef 100644 --- a/docs/src/content/docs/en/configuration/profiles.md +++ b/docs/src/content/docs/en/configuration/profiles.md @@ -38,7 +38,7 @@ This lets you create multiple configs for the same agent, such as "Claude Code - | Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. | | Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. | | Entry mode | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI and Kimi CLI are fixed to `CLI only`. | -| Model | All | Default model for the opened agent, either a provider model or Fusion model. For Claude Code, leaving it empty keeps the Claude Code default. | +| Model | All | Default model for the opened agent, either a provider model or Fusion model. Claude Code requires this value. | | Available models | Kimi CLI | Models exposed by Kimi's `/model` command. The default model is always included. | | Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. | | Environment variables | All | Extra environment variables injected into this config. Claude Code includes `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` by default so gateway model discovery is enabled. | @@ -49,8 +49,8 @@ This lets you create multiple configs for the same agent, such as "Claude Code - | Option | What it does | | --- | --- | -| Model override | Writes `ANTHROPIC_MODEL` for Claude Code. Leave it empty to keep Claude Code's own default model. | -| Small fast model | Writes `ANTHROPIC_SMALL_FAST_MODEL` for Claude Code lightweight tasks. Leave it empty to keep the Claude Code default. | +| Default model | Required. Writes `ANTHROPIC_MODEL` for Claude Code. | +| Fable / Opus / Sonnet / Haiku models | Writes Claude Code model aliases through `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. Leave any alias empty to keep the Claude Code default. Existing `smallFastModel` configs are migrated to the Haiku alias. | | Settings file | System-default mode uses the Claude Code default settings file; Only opened from CCR creates an isolated settings file under CCR's config directory, separated by Agent Profiles `id`. | | Environment variables | Merged into the Claude Code settings `env`. CCR also writes the gateway endpoint, API key helper, and launch wrapper. | | Bot | Applies only to the Claude App entry. Select a saved Bot, then choose message forwarding or handoff. | diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md index e2e74d36..b3bc9d17 100644 --- a/docs/src/content/docs/zh/configuration/profile.md +++ b/docs/src/content/docs/zh/configuration/profile.md @@ -38,7 +38,7 @@ lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 创建可复用的 | 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 | | 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 | | 入口模式 | Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI 和 Kimi CLI 固定为 `CLI only`。 | -| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 留空表示保留 Claude Code 默认模型。 | +| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 必须填写该值。 | | 可用模型 | Kimi CLI | Kimi `/model` 命令中可切换的模型;默认模型始终包含在内。 | | Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 | | 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 | @@ -49,8 +49,8 @@ lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 创建可复用的 | 配置项 | 作用 | | --- | --- | -| 模型覆盖 | 写入 Claude Code 使用的 `ANTHROPIC_MODEL`。留空时不覆盖 Claude Code 自己的默认模型。 | -| 小模型 | 写入 `ANTHROPIC_SMALL_FAST_MODEL`,供 Claude Code 的轻量任务使用。留空时保留 Claude Code 默认值。 | +| 默认模型 | 必填。写入 Claude Code 使用的 `ANTHROPIC_MODEL`。 | +| Fable / Opus / Sonnet / Haiku 模型 | 通过 `ANTHROPIC_DEFAULT_FABLE_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_HAIKU_MODEL` 写入 Claude Code 模型别名。任一别名留空时保留 Claude Code 默认值;旧的 `smallFastModel` 配置会迁移为 Haiku 别名。 | | 设置文件 | 系统默认模式使用 Claude Code 默认设置文件;仅从 CCR 打开时生效会在 CCR 配置目录下按 Agent 配置档案 `id` 生成独立设置文件。 | | 环境变量 | 会合并到 Claude Code 设置文件的 `env` 中。CCR 同时写入网关地址、API Key helper 和启动包装器。 | | Bot | 只在 Claude App 入口生效,可选择已保存 Bot,并配置转发 Agent 消息或接力。 | diff --git a/packages/core/src/agents/claude-app/launch.ts b/packages/core/src/agents/claude-app/launch.ts index 7969edfd..cb42a434 100644 --- a/packages/core/src/agents/claude-app/launch.ts +++ b/packages/core/src/agents/claude-app/launch.ts @@ -5,7 +5,7 @@ import path from "node:path"; import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "@ccr/core/agents/claude-app/cdp"; -import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; +import { claudeCodeModelEnv as claudeCodeProfileModelEnv, claudeCodeUtcTimezoneEnvOverride, isClaudeCodeManagedModelEnvKey } from "@ccr/core/agents/claude-code/environment"; import { resolveClaudeCodeSettingsFile } from "@ccr/core/profiles/launch-core"; import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery"; @@ -401,7 +401,7 @@ function hasElectronDesktopAppResources(executable: string): boolean { function profileEnv(profile: ProfileConfig): Record { return Object.entries(profile.env ?? {}).reduce>((result, [key, value]) => { - if (isEnvName(key) && typeof value === "string") { + if (isEnvName(key) && typeof value === "string" && !isClaudeCodeManagedModelEnvKey(key)) { result[key] = value; } return result; @@ -409,32 +409,7 @@ function profileEnv(profile: ProfileConfig): Record { } function claudeCodeModelEnv(profile: ProfileConfig): Record { - const env: Record = {}; - const model = normalizeClientModel(profile.model); - if (model) { - env.ANTHROPIC_MODEL = model; - env.CCR_CLAUDE_CODE_MODEL = model; - env.CODEXL_CLAUDE_CODE_MODEL = model; - } - const smallFastModel = normalizeClientModel(profile.smallFastModel); - if (smallFastModel) { - env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel; - } - return env; -} - -function normalizeClientModel(value: string | undefined): string { - const trimmed = value?.trim() || ""; - if (!trimmed) { - return ""; - } - const commaIndex = trimmed.indexOf(","); - if (commaIndex > 0 && commaIndex < trimmed.length - 1) { - const provider = trimmed.slice(0, commaIndex).trim(); - const model = trimmed.slice(commaIndex + 1).trim(); - return provider && model ? `${provider}/${model}` : ""; - } - return trimmed; + return claudeCodeProfileModelEnv(profile); } function isEnvName(value: string): boolean { diff --git a/packages/core/src/agents/claude-code/environment.ts b/packages/core/src/agents/claude-code/environment.ts index 33ed5266..22d80731 100644 --- a/packages/core/src/agents/claude-code/environment.ts +++ b/packages/core/src/agents/claude-code/environment.ts @@ -1,5 +1,32 @@ export const CLAUDE_CODE_MCP_CONFIG_ENV = "CCR_CLAUDE_CODE_MCP_CONFIG"; export const CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV = "CODEXL_CLAUDE_CODE_MCP_CONFIG"; +export const CLAUDE_CODE_MODEL_ENV = "ANTHROPIC_MODEL"; +export const CCR_CLAUDE_CODE_MODEL_ENV = "CCR_CLAUDE_CODE_MODEL"; +export const CODEXL_CLAUDE_CODE_MODEL_ENV = "CODEXL_CLAUDE_CODE_MODEL"; +export const CLAUDE_CODE_DEFAULT_FABLE_MODEL_ENV = "ANTHROPIC_DEFAULT_FABLE_MODEL"; +export const CLAUDE_CODE_DEFAULT_OPUS_MODEL_ENV = "ANTHROPIC_DEFAULT_OPUS_MODEL"; +export const CLAUDE_CODE_DEFAULT_SONNET_MODEL_ENV = "ANTHROPIC_DEFAULT_SONNET_MODEL"; +export const CLAUDE_CODE_DEFAULT_HAIKU_MODEL_ENV = "ANTHROPIC_DEFAULT_HAIKU_MODEL"; +export const CLAUDE_CODE_LEGACY_SMALL_FAST_MODEL_ENV = "ANTHROPIC_SMALL_FAST_MODEL"; +export const CLAUDE_CODE_MANAGED_MODEL_ENV_KEYS = [ + CLAUDE_CODE_MODEL_ENV, + CCR_CLAUDE_CODE_MODEL_ENV, + CODEXL_CLAUDE_CODE_MODEL_ENV, + CLAUDE_CODE_DEFAULT_FABLE_MODEL_ENV, + CLAUDE_CODE_DEFAULT_OPUS_MODEL_ENV, + CLAUDE_CODE_DEFAULT_SONNET_MODEL_ENV, + CLAUDE_CODE_DEFAULT_HAIKU_MODEL_ENV, + CLAUDE_CODE_LEGACY_SMALL_FAST_MODEL_ENV +] as const; + +export type ClaudeCodeModelSelection = { + fableModel?: string; + haikuModel?: string; + model?: string; + opusModel?: string; + smallFastModel?: string; + sonnetModel?: string; +}; const chinaTimeZones = new Set([ "asia/chongqing", @@ -21,6 +48,46 @@ export function claudeCodeMcpConfigEnv(configFile: string | undefined): Record { + const env: Record = {}; + const model = normalizeClaudeCodeClientModel(selection.model); + if (model) { + env[CLAUDE_CODE_MODEL_ENV] = model; + env[CCR_CLAUDE_CODE_MODEL_ENV] = model; + env[CODEXL_CLAUDE_CODE_MODEL_ENV] = model; + } + + assignModelAliasEnv(env, CLAUDE_CODE_DEFAULT_FABLE_MODEL_ENV, selection.fableModel); + assignModelAliasEnv(env, CLAUDE_CODE_DEFAULT_OPUS_MODEL_ENV, selection.opusModel); + assignModelAliasEnv(env, CLAUDE_CODE_DEFAULT_SONNET_MODEL_ENV, selection.sonnetModel); + assignModelAliasEnv(env, CLAUDE_CODE_DEFAULT_HAIKU_MODEL_ENV, selection.haikuModel || selection.smallFastModel); + return env; +} + +export function clearClaudeCodeManagedModelEnv(env: Record): void { + for (const key of CLAUDE_CODE_MANAGED_MODEL_ENV_KEYS) { + delete env[key]; + } +} + +export function isClaudeCodeManagedModelEnvKey(key: string): boolean { + return (CLAUDE_CODE_MANAGED_MODEL_ENV_KEYS as readonly string[]).includes(key); +} + +export function normalizeClaudeCodeClientModel(value: string | undefined): string { + const trimmed = value?.trim() || ""; + if (!trimmed) { + return ""; + } + const commaIndex = trimmed.indexOf(","); + if (commaIndex > 0 && commaIndex < trimmed.length - 1) { + const provider = trimmed.slice(0, commaIndex).trim(); + const model = trimmed.slice(commaIndex + 1).trim(); + return provider && model ? `${provider}/${model}` : ""; + } + return trimmed; +} + export function claudeCodeUtcTimezoneEnvOverride(timeZone = currentTimeZone()): Record { return isChinaTimeZone(timeZone) ? { TZ: "UTC" } : {}; } @@ -37,3 +104,10 @@ export function isChinaTimeZone(timeZone: string | undefined): boolean { const normalized = timeZone?.trim().toLowerCase(); return Boolean(normalized && chinaTimeZones.has(normalized)); } + +function assignModelAliasEnv(env: Record, key: string, value: string | undefined): void { + const model = normalizeClaudeCodeClientModel(value); + if (model) { + env[key] = model; + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 436597d3..c1e2bdb0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2581,6 +2581,22 @@ function parseProfile(value: unknown): LoadedProfileConfig | undefined { if (model !== undefined) { profile.claudeCode.model = model; } + const fableModel = readString(claudeCode.fableModel) || readString(claudeCode.defaultFableModel); + if (fableModel !== undefined) { + profile.claudeCode.fableModel = fableModel; + } + const opusModel = readString(claudeCode.opusModel) || readString(claudeCode.defaultOpusModel); + if (opusModel !== undefined) { + profile.claudeCode.opusModel = opusModel; + } + const sonnetModel = readString(claudeCode.sonnetModel) || readString(claudeCode.defaultSonnetModel); + if (sonnetModel !== undefined) { + profile.claudeCode.sonnetModel = sonnetModel; + } + const haikuModel = readString(claudeCode.haikuModel) || readString(claudeCode.defaultHaikuModel) || readString(claudeCode.smallFastModel) || readString(claudeCode.smallModel); + if (haikuModel !== undefined) { + profile.claudeCode.haikuModel = haikuModel; + } const smallFastModel = readString(claudeCode.smallFastModel) || readString(claudeCode.smallModel); if (smallFastModel !== undefined) { profile.claudeCode.smallFastModel = smallFastModel; @@ -2710,12 +2726,16 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { ...(botGateway ? { botGateway } : {}), enabled, env: claudeCodeProfileEnv(env), + fableModel: readString(item.fableModel) || readString(item.defaultFableModel) || "", + haikuModel: readString(item.haikuModel) || readString(item.defaultHaikuModel) || readString(item.smallFastModel) || readString(item.smallModel) || "", id, ...(managedCompact !== undefined ? { managedCompact } : {}), model, name, + opusModel: readString(item.opusModel) || readString(item.defaultOpusModel) || "", scope: parseProfileScope(readString(item.scope) || readString(item.applyScope) || readString(item.effectScope)) || "global", settingsFile: readString(item.settingsFile) || readString(item.configFile) || "~/.claude/settings.json", + sonnetModel: readString(item.sonnetModel) || readString(item.defaultSonnetModel) || "", smallFastModel: readString(item.smallFastModel) || readString(item.smallModel) || "", surface }; @@ -2859,12 +2879,16 @@ function profileFromClaudeCodeConfig(config: ClaudeCodeProfileConfig): ProfileCo agent: "claude-code", enabled: config.enabled, env: claudeCodeProfileEnv(), + fableModel: config.fableModel, + haikuModel: config.haikuModel || config.smallFastModel, id: "default-claude-code", managedCompact: config.managedCompact, model: config.model, name: "Claude Code", + opusModel: config.opusModel, scope: "global", settingsFile: config.settingsFile, + sonnetModel: config.sonnetModel, smallFastModel: config.smallFastModel, surface: "auto" }; diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index deb53d43..86e93a60 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -131,9 +131,13 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon profile: { claudeCode: { enabled: true, + fableModel: "", + haikuModel: "", managedCompact: false, model: "", + opusModel: "", settingsFile: "~/.claude/settings.json", + sonnetModel: "", smallFastModel: "" }, codex: { @@ -155,12 +159,16 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon agent: "claude-code", enabled: true, env: { ...CLAUDE_CODE_DEFAULT_ENV }, + fableModel: "", + haikuModel: "", id: "default-claude-code", managedCompact: false, model: "", name: "Claude Code", + opusModel: "", scope: "global", settingsFile: "~/.claude/settings.json", + sonnetModel: "", smallFastModel: "", surface: "auto" }, diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index a128bd21..9d51fb1f 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1287,9 +1287,13 @@ export type ProfileOpenSurface = "cli" | "app"; export type ClaudeCodeProfileConfig = { enabled: boolean; + fableModel: string; + haikuModel: string; managedCompact: boolean; model: string; + opusModel: string; settingsFile: string; + sonnetModel: string; smallFastModel: string; }; @@ -1321,16 +1325,20 @@ export type ProfileConfig = { configFormat?: CodexProfileConfigFormat; enabled: boolean; env?: Record; + fableModel?: string; + haikuModel?: string; id: string; managedCompact?: boolean; model: string; name: string; + opusModel?: string; providerId?: string; providerName?: string; remoteFrontendMode?: CodexRemoteFrontendMode; scope?: ProfileScope; showAllSessions?: boolean; settingsFile?: string; + sonnetModel?: string; smallFastModel?: string; surface?: ProfileSurface; }; diff --git a/packages/core/src/profiles/launch-core.ts b/packages/core/src/profiles/launch-core.ts index 87ecc0a9..06da20b0 100644 --- a/packages/core/src/profiles/launch-core.ts +++ b/packages/core/src/profiles/launch-core.ts @@ -1,6 +1,6 @@ import path from "node:path"; import type { AppConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app"; -import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; +import { claudeCodeModelEnv as claudeCodeProfileModelEnv, claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; import { resolveOpenCodeConfigFile as resolveOpenCodeProfileConfigFile } from "@ccr/core/agents/opencode/profile-config"; import { resolveZcodeConfigFile } from "@ccr/core/agents/zcode/profile-config"; @@ -267,7 +267,7 @@ function buildClaudeCodeLaunchPlan( env: { CLAUDE_CONFIG_DIR: path.dirname(settingsFile), CCR_PROFILE_SURFACE: surface, - ...claudeCodeModelEnv(profile), + ...claudeCodeProfileModelEnv(profile), ...claudeCodeUtcTimezoneEnvOverride() }, profile, @@ -326,35 +326,6 @@ function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli return value === "cli" || value === "app" ? value : "auto"; } -function claudeCodeModelEnv(profile: ProfileConfig): Record { - const env: Record = {}; - const model = normalizeClientModel(profile.model); - if (model) { - env.ANTHROPIC_MODEL = model; - env.CCR_CLAUDE_CODE_MODEL = model; - env.CODEXL_CLAUDE_CODE_MODEL = model; - } - const smallFastModel = normalizeClientModel(profile.smallFastModel); - if (smallFastModel) { - env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel; - } - return env; -} - -function normalizeClientModel(value: string | undefined): string { - const trimmed = value?.trim() || ""; - if (!trimmed) { - return ""; - } - const commaIndex = trimmed.indexOf(","); - if (commaIndex > 0 && commaIndex < trimmed.length - 1) { - const provider = trimmed.slice(0, commaIndex).trim(); - const model = trimmed.slice(commaIndex + 1).trim(); - return provider && model ? `${provider}/${model}` : ""; - } - return trimmed; -} - function isGeneratedProfileScope(value: ProfileConfig["scope"]): boolean { return value === "ccr" || value === "custom"; } diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 11bcdb52..b5125a19 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -8,8 +8,11 @@ import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { CLAUDE_CODE_MCP_CONFIG_ENV, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV, + claudeCodeModelEnv, claudeCodeMcpConfigEnv, - claudeCodeUtcTimezoneEnvOverride + claudeCodeUtcTimezoneEnvOverride, + clearClaudeCodeManagedModelEnv, + isClaudeCodeManagedModelEnvKey } from "@ccr/core/agents/claude-code/environment"; import { writeCodexCompatibleAppModelCatalog } from "@ccr/core/agents/codex/app-launch"; import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime"; @@ -315,21 +318,8 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token env.CLAUDE_AGENT_API_BASE_URL = endpoint; delete env.ANTHROPIC_AUTH_TOKEN; delete env.ANTHROPIC_API_KEY; - if (profile.model.trim()) { - const model = normalizeClientModel(profile.model); - env.ANTHROPIC_MODEL = model; - env.CCR_CLAUDE_CODE_MODEL = model; - env.CODEXL_CLAUDE_CODE_MODEL = model; - } else { - delete env.ANTHROPIC_MODEL; - delete env.CCR_CLAUDE_CODE_MODEL; - delete env.CODEXL_CLAUDE_CODE_MODEL; - } - if (profile.smallFastModel?.trim()) { - env.ANTHROPIC_SMALL_FAST_MODEL = normalizeClientModel(profile.smallFastModel); - } else { - delete env.ANTHROPIC_SMALL_FAST_MODEL; - } + clearClaudeCodeManagedModelEnv(env); + Object.assign(env, claudeCodeModelEnv(profile)); const toolHubMcpConfigResult = writeClaudeCodeToolHubMcpConfig(config, profile, token); Object.assign(env, claudeCodeMcpConfigEnv(toolHubMcpConfigResult.file), claudeCodeUtcTimezoneEnvOverride()); @@ -1054,7 +1044,7 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile)); const envExports = Object.entries(profileEnv(profile)) - .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") + .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key)) .map(([key, value]) => `export ${key}=${shellQuote(value)}`); const botEnvExports = shellBotGatewayEnvExports(config, profile); return [ @@ -1086,7 +1076,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile)); const envExports = Object.entries(profileEnv(profile)) - .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") + .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key)) .map(([key, value]) => cmdSetLine(key, value)); const botEnvExports = cmdBotGatewayEnvExports(config, profile); return [ @@ -1768,16 +1758,7 @@ function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, setting CLAUDE_AGENT_API_BASE_URL: endpoint, CLAUDE_CONFIG_DIR: settingsDir }; - const model = normalizeClientModel(profile.model); - if (model) { - env.ANTHROPIC_MODEL = model; - env.CCR_CLAUDE_CODE_MODEL = model; - env.CODEXL_CLAUDE_CODE_MODEL = model; - } - const smallFastModel = normalizeClientModel(profile.smallFastModel); - if (smallFastModel) { - env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel; - } + Object.assign(env, claudeCodeModelEnv(profile)); return env; } diff --git a/packages/core/test/integration/profiles/profile-service.test.mjs b/packages/core/test/integration/profiles/profile-service.test.mjs index 63024073..18a05fbb 100644 --- a/packages/core/test/integration/profiles/profile-service.test.mjs +++ b/packages/core/test/integration/profiles/profile-service.test.mjs @@ -190,11 +190,15 @@ test("profile service overwrites generated bin files without creating backups", agent: "claude-code", enabled: true, env: {}, + fableModel: "Provider/fable", + haikuModel: "Provider/haiku", id: profileId, model: "Provider/model", name: "Generated Bin Test", + opusModel: "Provider/opus", scope: "ccr", settingsFile: "~/.claude/settings.json", + sonnetModel: "Provider/sonnet", smallFastModel: "", surface: "auto" } @@ -214,6 +218,14 @@ test("profile service overwrites generated bin files without creating backups", assert.equal(toolHubMcpServerEnv.TOOLHUB_OPENAI_BASE_URL, `http://127.0.0.1:${config.gateway.port}/v1`); assert.equal(toolHubMcpServerEnv.TOOLHUB_OPENAI_MODEL, "Provider/model"); assert.equal(contextArchiveMcpServer, undefined); + const settingsFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "settings.json"); + const settings = JSON.parse(readFileSync(settingsFile, "utf8")); + assert.equal(settings.env.ANTHROPIC_MODEL, "Provider/model"); + assert.equal(settings.env.ANTHROPIC_DEFAULT_FABLE_MODEL, "Provider/fable"); + assert.equal(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Provider/opus"); + assert.equal(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Provider/sonnet"); + assert.equal(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Provider/haiku"); + assert.equal(settings.env.ANTHROPIC_SMALL_FAST_MODEL, undefined); const backupEntries = readdirSync(binDir).filter((entry) => ( entry.startsWith(`ccr-claude-code-api-key-${profileId}`) || diff --git a/packages/core/test/unit/profiles/profile-launch-core.test.mjs b/packages/core/test/unit/profiles/profile-launch-core.test.mjs index 413ed6d4..af78fc49 100644 --- a/packages/core/test/unit/profiles/profile-launch-core.test.mjs +++ b/packages/core/test/unit/profiles/profile-launch-core.test.mjs @@ -18,10 +18,14 @@ import { const claudeProfile = { agent: "claude-code", enabled: true, + fableModel: "provider,fable", + haikuModel: "provider,haiku", id: "claude-main", model: "provider,model", name: "Claude Main", + opusModel: "provider,opus", scope: "ccr", + sonnetModel: "provider,sonnet", smallFastModel: "provider,small", surface: "auto" }; @@ -134,7 +138,11 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { assert.equal(claudePlan.env.ANTHROPIC_MODEL, "provider/model"); assert.equal(claudePlan.env.CCR_CLAUDE_CODE_MODEL, "provider/model"); assert.equal(claudePlan.env.CODEXL_CLAUDE_CODE_MODEL, "provider/model"); - assert.equal(claudePlan.env.ANTHROPIC_SMALL_FAST_MODEL, "provider/small"); + assert.equal(claudePlan.env.ANTHROPIC_DEFAULT_FABLE_MODEL, "provider/fable"); + assert.equal(claudePlan.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "provider/opus"); + assert.equal(claudePlan.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "provider/sonnet"); + assert.equal(claudePlan.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "provider/haiku"); + assert.equal(claudePlan.env.ANTHROPIC_SMALL_FAST_MODEL, undefined); assert.equal(grokPlan.surface, "cli"); assert.deepEqual(grokPlan.args, ["--debug"]); diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 9f06c660..5dd1a215 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -748,6 +748,8 @@ export function AddProfileForm({ const availableModelCount = modelProviderOptions.reduce((count, provider) => count + provider.models.length, 0); const modelPlaceholder = firstProfileModelPlaceholder(modelProviderOptions); const validation = profileDraftValidation(draft, botConfigs, availableModelCount); + const optionalFieldLabel = t("Optional"); + const requiredFieldLabel = t("Required"); const advancedIssueCount = [ validation.providerId, validation.providerName, @@ -790,7 +792,7 @@ export function AddProfileForm({ onDragOver={showAppPathField ? handleAppPathDragOver : undefined} onDrop={showAppPathField ? handleAppPathDrop : undefined} > - + onChange(agent === "grok" || agent === "kimi" ? { @@ -809,11 +811,11 @@ export function AddProfileForm({ value={draft.agent} /> - + onChange({ name: event.target.value })} /> {validation.name ? {t(validation.name)} : null} - + onChange({ scope: normalizeProfileScope(scope) })} options={translateOptions( @@ -825,7 +827,7 @@ export function AddProfileForm({ value={draft.scope} /> - + { const nextSurface = normalizeProfileSurface(surface); @@ -851,27 +853,55 @@ export function AddProfileForm({ {draft.agent === "claude-code" ? ( <> - + onChange({ model })} /> + {validation.defaultModel ? {t(validation.defaultModel)} : null} - + onChange({ smallFastModel })} + onChange={(fableModel) => onChange({ fableModel })} + /> + + + onChange({ opusModel })} + /> + + + onChange({ sonnetModel })} + /> + + + onChange({ haikuModel, smallFastModel: haikuModel })} /> ) : draft.agent === "grok" ? ( - + ) : draft.agent === "kimi" ? ( <> - + {validation.kimiModel ? {t(validation.kimiModel)} : null} - + ) : ( <> - +
{showAppPathField && appPathLabel ? ( - +
- + onChange({ providerId: event.target.value })} /> {validation.providerId ? {t(validation.providerId)} : null} - + onChange({ providerName: event.target.value })} /> {validation.providerName ? {t(validation.providerName)} : null} @@ -998,7 +1028,7 @@ export function AddProfileForm({ {validation.handoff ? {t(validation.handoff)} : null}
) : null} - + > { - const issues: Partial> = {}; +): Partial> { + const issues: Partial> = {}; if (!draft.name.trim()) { issues.name = "Profile name is required."; } if (availableModelCount === 0) { issues.models = "Configure at least one enabled provider model before saving an agent profile."; } + if (draft.agent === "claude-code" && !draft.model.trim()) { + issues.defaultModel = "Default model is required."; + } if (draft.agent === "kimi") { if (!draft.model.trim()) { issues.kimiModel = "Kimi model is required."; @@ -1170,6 +1203,7 @@ function BotGatewaySelectForm({ }) { const t = useAppText(); const formatError = useAppErrorText(); + const requiredFieldLabel = t("Required"); const options = [ { label: t("None"), value: "none" }, ...botConfigs.map((config) => ({ label: botGatewaySavedConfigLabel(config, t), value: config.id })), @@ -1287,7 +1321,7 @@ function BotGatewaySelectForm({
{draft.botEnabled ? (
- + {selectedBot ? ( @@ -1303,7 +1337,7 @@ function BotGatewaySelectForm({
{draft.botHandoffEnabled ? (
- + - {label} + + {label} + {requirement ? ( + + {requirementLabel ?? (requirement === "required" ? "Required" : "Optional")} + + ) : null} + {children} ); diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 3e5f7404..4be029d4 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -333,7 +333,13 @@ export const appCopy: Record = { "Models to check": "Models to check", "model": "model", "models": "models", + "Fable model": "Fable model", + "Haiku model": "Haiku model", "Model overrides are optional; empty fields keep Claude Code defaults.": "Model overrides are optional; empty fields keep Claude Code defaults.", + "Optional": "Optional", + "Opus model": "Opus model", + "Required": "Required", + "Sonnet model": "Sonnet model", "Next": "Next", "Next step": "Next step", "Available model IDs": "Available model IDs", @@ -376,6 +382,7 @@ export const appCopy: Record = { "Select at least one protocol.": "Select at least one protocol.", "Select at least one available model.": "Select at least one available model.", "Select at least one allowed model.": "Select at least one allowed model.", + "Select default model": "Select default model", "Select models": "Select models", "Search added models": "Search added models", "Search provider models": "Search provider models", @@ -853,6 +860,7 @@ export const appCopy: Record = { "App Key": "App Key", "Robot Code": "Robot Code", "Optional": "可选", + "Required": "必填", "Auto": "自动", "Auto detect protocols": "自动探测协议", "Auto detect protocols description": "开启后,CCR 会在编辑时探测接口,并用探测到的协议和模型更新此供应商。关闭后,手动选择的协议和自定义模型 ID 会保持不变。", @@ -1021,7 +1029,7 @@ export const appCopy: Record = { "Local login provider": "本机登录态供应商", "Launch actions": "启动操作", "Management actions": "管理操作", - "Model overrides are optional; empty fields keep Claude Code defaults.": "模型覆盖是可选项;留空会保留 Claude Code 默认设置。", + "Model overrides are optional; empty fields keep Claude Code defaults.": "模型设置是可选项;留空会保留 Claude Code 默认设置。", "Display name": "显示名称", "Double click to copy": "双击复制", "Edit": "编辑", @@ -1143,10 +1151,14 @@ export const appCopy: Record = { "Default model": "默认模型", "Default model is required.": "默认模型不能为空。", "Model descriptions": "模型描述", - "Model override": "模型覆盖", + "Model override": "默认模型", "Model routing": "模型路由", "Model prefix": "模型前缀", "Models": "模型", + "Fable model": "Fable 模型", + "Opus model": "Opus 模型", + "Sonnet model": "Sonnet 模型", + "Haiku model": "Haiku 模型", "Describe model strengths, tradeoffs, and best-fit tasks.": "描述模型优势、取舍和最适合的任务。", "Module path": "模块路径", "Name": "名称", @@ -1372,6 +1384,7 @@ export const appCopy: Record = { "Select an existing bot or turn Bot off.": "请选择已有 Bot,或关闭 Bot。", "Select at least one available model.": "请至少选择一个可用模型。", "Select at least one allowed model.": "请至少选择一个允许模型。", + "Select default model": "选择默认模型", "Select bot": "选择 Bot", "Select data": "选择数据", "Send a request through CCR, then refresh this page to inspect it.": "通过 CCR 发送一次请求,然后刷新此页面查看日志。", diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 7919585d..4786bc0c 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -754,14 +754,18 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code" ...createBotGatewayDraft(), configFile: defaultCodexConfigFile(agent), envRows: agent === "claude-code" ? keyValueRowsFromRecord(claudeCodeProfileEnv()) : [], + fableModel: "", + haikuModel: "", managedCompact: false, model: "", name: name ?? profileAgentLabel(agent), + opusModel: "", providerId: "claude-code-router", providerName: "Claude Code Router", scope: "ccr", settingsFile: "~/.claude/settings.json", showAllSessions: false, + sonnetModel: "", smallFastModel: "", surface }; @@ -792,10 +796,14 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs botConfigId, botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled), envRows: keyValueRowsFromRecord(claudeCodeProfileEnv(profile.env ?? {})), + fableModel: profile.fableModel ?? "", + haikuModel: profile.haikuModel ?? profile.smallFastModel ?? "", managedCompact: Boolean(profile.managedCompact), model: profile.model, + opusModel: profile.opusModel ?? "", scope: normalizeProfileFormScope(profile.scope), settingsFile: profile.settingsFile ?? "~/.claude/settings.json", + sonnetModel: profile.sonnetModel ?? "", smallFastModel: profile.smallFastModel ?? "", surface }; @@ -846,7 +854,7 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { return false; } if (draft.agent === "claude-code") { - return true; + return Boolean(draft.model.trim()); } if (draft.agent === "grok") { return true; @@ -901,16 +909,20 @@ export function profileConfigFromDraft( configFile: draft.configFile, enabled: existingProfile?.enabled ?? true, env: draft.agent === "claude-code" ? recordFromKeyValueRows(draft.envRows) : codexCompatibleProfileEnv(recordFromKeyValueRows(draft.envRows)), + fableModel: draft.fableModel, + haikuModel: draft.haikuModel, id, managedCompact: draft.managedCompact, model: draft.model, name: draft.name, + opusModel: draft.opusModel, providerId: draft.providerId, providerName: draft.providerName, scope: draft.scope, settingsFile: draft.settingsFile, showAllSessions: draft.agent === "zcode" || draft.agent === "opencode" ? false : draft.showAllSessions, - smallFastModel: draft.smallFastModel, + sonnetModel: draft.sonnetModel, + smallFastModel: draft.haikuModel || draft.smallFastModel, surface: draft.surface }, existingProfiles.length); } @@ -1446,34 +1458,34 @@ export function profileSummaryItems( : profile.managedCompact ? [{ label: t("CCR managed compact"), value: t("Enabled") }] : []; - const smallFastModel = profile.smallFastModel?.trim() || ""; + const displayProfileModel = (value: string) => profileModelDisplayValue( + value, + parseProfileModelValue(value, config.Providers, config.virtualModelProfiles ?? []), + config.Providers, + undefined, + config.virtualModelProfiles ?? [] + ); const modelValue = profile.model.trim() - ? profileModelDisplayValue( - profile.model, - parseProfileModelValue(profile.model, config.Providers, config.virtualModelProfiles ?? []), - config.Providers, - undefined, - config.virtualModelProfiles ?? [] - ) + ? displayProfileModel(profile.model) : profile.agent === "claude-code" ? t("Keep Claude Code default") : defaultProfileClientModel(config); if (profile.agent === "claude-code") { + const aliasItems = [ + { label: "Fable model", value: profile.fableModel?.trim() || "" }, + { label: "Opus model", value: profile.opusModel?.trim() || "" }, + { label: "Sonnet model", value: profile.sonnetModel?.trim() || "" }, + { label: "Haiku model", value: profile.haikuModel?.trim() || profile.smallFastModel?.trim() || "" } + ] + .filter((item) => item.value) + .map((item) => ({ + label: t(item.label), + value: displayProfileModel(item.value) + })); return [ { label: t("Model"), value: modelValue }, - { - label: t("Small fast model"), - value: smallFastModel - ? profileModelDisplayValue( - smallFastModel, - parseProfileModelValue(smallFastModel, config.Providers, config.virtualModelProfiles ?? []), - config.Providers, - undefined, - config.virtualModelProfiles ?? [] - ) - : t("Keep Claude Code default") - }, + ...aliasItems, ...managedCompactItems, ...botSummaryItems, ...appPathSummaryItems, @@ -1527,12 +1539,16 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro ...(botGateway ? { botGateway } : {}), enabled: profile.enabled, env: claudeCodeProfileEnv(env), + fableModel: stringValue(profile.fableModel) || "", + haikuModel: stringValue(profile.haikuModel) || stringValue(profile.smallFastModel) || "", id: profile.id || `profile-${index + 1}`, managedCompact: Boolean(profile.managedCompact), model, name, + opusModel: stringValue(profile.opusModel) || "", scope, settingsFile: profile.settingsFile?.trim() || "~/.claude/settings.json", + sonnetModel: stringValue(profile.sonnetModel) || "", smallFastModel: profile.smallFastModel?.trim() || "", surface }; @@ -1589,12 +1605,16 @@ export function legacyProfileItemsFromProfileConfig(profile: AppConfig["profile" agent: "claude-code", enabled: profile.claudeCode.enabled, env: claudeCodeProfileEnv(), + fableModel: profile.claudeCode.fableModel, + haikuModel: profile.claudeCode.haikuModel || profile.claudeCode.smallFastModel, id: "default-claude-code", managedCompact: profile.claudeCode.managedCompact, model: profile.claudeCode.model, name: "Claude Code", + opusModel: profile.claudeCode.opusModel, scope: "global", settingsFile: profile.claudeCode.settingsFile, + sonnetModel: profile.claudeCode.sonnetModel, smallFastModel: profile.claudeCode.smallFastModel, surface: "auto" }, 0), @@ -1683,6 +1703,20 @@ export function normalizeUnknownProfileItem(value: Record, inde configFile: typeof value.configFile === "string" ? value.configFile : undefined, enabled: typeof value.enabled === "boolean" ? value.enabled : true, env: isPlainRecord(value.env) ? stringRecordValue(value.env) : {}, + fableModel: typeof value.fableModel === "string" + ? value.fableModel + : typeof value.defaultFableModel === "string" + ? value.defaultFableModel + : undefined, + haikuModel: typeof value.haikuModel === "string" + ? value.haikuModel + : typeof value.defaultHaikuModel === "string" + ? value.defaultHaikuModel + : typeof value.smallFastModel === "string" + ? value.smallFastModel + : typeof value.smallModel === "string" + ? value.smallModel + : undefined, id: typeof value.id === "string" && value.id.trim() ? value.id.trim() : `profile-${index + 1}`, managedCompact: typeof value.managedCompact === "boolean" ? value.managedCompact @@ -1699,6 +1733,11 @@ export function normalizeUnknownProfileItem(value: Record, inde : undefined, model: typeof value.model === "string" ? value.model : "", name: typeof value.name === "string" ? value.name : profileAgentLabel(agent), + opusModel: typeof value.opusModel === "string" + ? value.opusModel + : typeof value.defaultOpusModel === "string" + ? value.defaultOpusModel + : undefined, providerId: typeof value.providerId === "string" ? value.providerId : undefined, providerName: typeof value.providerName === "string" ? value.providerName : undefined, scope: typeof value.scope === "string" ? normalizeProfileScope(value.scope) : "global", @@ -1708,6 +1747,11 @@ export function normalizeUnknownProfileItem(value: Record, inde : typeof value.show_all_sessions === "boolean" ? value.show_all_sessions : undefined, + sonnetModel: typeof value.sonnetModel === "string" + ? value.sonnetModel + : typeof value.defaultSonnetModel === "string" + ? value.defaultSonnetModel + : undefined, smallFastModel: typeof value.smallFastModel === "string" ? value.smallFastModel : undefined, surface: typeof value.surface === "string" ? normalizeProfileSurface(value.surface) : "auto" }, index); diff --git a/packages/ui/src/pages/home/shared/types.ts b/packages/ui/src/pages/home/shared/types.ts index 22f9466d..872a1897 100644 --- a/packages/ui/src/pages/home/shared/types.ts +++ b/packages/ui/src/pages/home/shared/types.ts @@ -504,14 +504,18 @@ export type AddProfileDraft = { botPlatform: string; configFile: string; envRows: KeyValueDraftRow[]; + fableModel: string; + haikuModel: string; managedCompact: boolean; model: string; name: string; + opusModel: string; providerId: string; providerName: string; scope: ProfileScope; settingsFile: string; showAllSessions: boolean; + sonnetModel: string; smallFastModel: string; surface: ProfileSurface; }; diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 7c5f3bac..81a932ed 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { ProfileConfig } from "@ccr/core/contracts/app.ts"; import { AddProfileForm, DeleteProfileDialog, ProfileView } from "@ccr/ui/pages/home/components/profiles.tsx"; import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx"; -import { createProfileDraft, normalizeUnknownProfileItem, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts"; +import { createProfileDraft, isProfileDraftSubmittable, normalizeUnknownProfileItem, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts"; import { appConfigFixture } from "../fixtures/index.ts"; const profile: ProfileConfig = { @@ -61,6 +61,37 @@ test("AddProfileForm does not show the profile requirements panel", () => { assert.doesNotMatch(html, /Profile guidance/); }); +test("AddProfileForm marks required and optional fields", () => { + const config = appConfigFixture(); + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + + assert.equal(html.match(/>Required<\/span>/g)?.length, 5); + assert.equal(html.match(/>Optional<\/span>/g)?.length, 4); + assert.match(html, /Default model/); + assert.match(html, /Default model is required\./); + assert.match(html, /Fable model/); + assert.match(html, /Opus model/); + assert.match(html, /Sonnet model/); + assert.match(html, /Haiku model/); +}); + +test("Claude Code profiles require a default model before submission", () => { + const draft = createProfileDraft("claude-code"); + + assert.equal(isProfileDraftSubmittable(draft), false); + assert.equal(isProfileDraftSubmittable({ ...draft, model: "anthropic/claude-sonnet-4-5" }), true); +}); + test("AddProfileForm labels Kimi CLI model fields with Kimi-specific copy", () => { const config = appConfigFixture(); const html = renderToStaticMarkup( From b51099ebc15113bb100ff939acc26e8d3d3f369f Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 24 Jul 2026 16:52:41 +0800 Subject: [PATCH 4/5] Preserve user-managed Claude Code settings --- packages/core/src/profiles/service.ts | 119 ++++++++++- .../profiles/profile-service.test.mjs | 195 ++++++++++++++++++ 2 files changed, 305 insertions(+), 9 deletions(-) diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index b5125a19..2a346623 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -57,6 +57,15 @@ const privateDirMode = 0o700; const privateExecutableMode = 0o700; const privateFileMode = 0o600; const publicExecutableMode = 0o755; +const claudeCodeGatewayEnvKeys = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_BASE_URL", + "CLAUDE_AGENT_API_BASE_URL" +] as const; +const claudeCodeRemovedAuthEnvKeys = [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY" +] as const; let ownedGlobalProfileTakeovers: GlobalProfileTakeoverRecord[] | undefined; type CodexContextArchiveMcpConfig = { @@ -305,13 +314,14 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token try { const endpoint = gatewayEndpoint(config); - const settings = readJsonObject(settingsFile); + const settings = readClaudeCodeSettingsObject(settingsFile); const settingsEnv = withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env))); delete settingsEnv[CLAUDE_CODE_MCP_CONFIG_ENV]; delete settingsEnv[CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]; + const profileEnvValues = profileEnv(profile); const env = { ...settingsEnv, - ...profileEnv(profile) + ...profileEnvValues }; env.ANTHROPIC_BASE_URL = endpoint; env.ANTHROPIC_API_BASE_URL = endpoint; @@ -321,7 +331,9 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token clearClaudeCodeManagedModelEnv(env); Object.assign(env, claudeCodeModelEnv(profile)); const toolHubMcpConfigResult = writeClaudeCodeToolHubMcpConfig(config, profile, token); - Object.assign(env, claudeCodeMcpConfigEnv(toolHubMcpConfigResult.file), claudeCodeUtcTimezoneEnvOverride()); + const mcpConfigEnv = claudeCodeMcpConfigEnv(toolHubMcpConfigResult.file); + const timezoneEnv = claudeCodeUtcTimezoneEnvOverride(); + Object.assign(env, mcpConfigEnv, timezoneEnv); const helperResult = writeClaudeCodeApiKeyHelper(profile, token); const wrapperResult = writeClaudeCodeWrapper(config, profile, helperResult.file, toolHubMcpConfigResult.file); @@ -330,7 +342,8 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token apiKeyHelper: process.platform === "win32" ? `"${helperResult.file}"` : helperResult.file, env }; - const writeResult = writeFileWithBackup(settingsFile, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: privateFileMode }); + const managedEnvKeys = claudeCodeManagedSettingsEnvKeys(profileEnvValues, mcpConfigEnv, timezoneEnv); + const writeResult = writeClaudeCodeSettingsIfManagedChanged(settingsFile, settings, nextSettings, managedEnvKeys); const changed = writeResult.changed || helperResult.changed || wrapperResult.changed || toolHubMcpConfigResult.changed; return { appliedAt, @@ -2194,6 +2207,85 @@ function readJsonObject(file: string): Record { } } +function readClaudeCodeSettingsObject(file: string): Record { + if (!existsSync(file)) { + return {}; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + if (isRecord(parsed)) { + return parsed; + } + throw new Error("root value is not an object"); + } catch (error) { + throw new Error(`Claude Code settings file is not valid JSON: ${file}. ${formatError(error)}`); + } +} + +function writeClaudeCodeSettingsIfManagedChanged( + file: string, + settings: Record, + nextSettings: Record, + managedEnvKeys: Set +): { backupFile?: string; changed: boolean } { + if (!claudeCodeSettingsManagedFieldsChanged(settings, nextSettings, managedEnvKeys)) { + chmodFileIfRequested(file, privateFileMode); + return { changed: false }; + } + return writeFileWithBackup(file, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: privateFileMode }); +} + +function claudeCodeSettingsManagedFieldsChanged( + settings: Record, + nextSettings: Record, + managedEnvKeys: Set +): boolean { + if (settings.apiKeyHelper !== nextSettings.apiKeyHelper) { + return true; + } + + const settingsEnv = isRecord(settings.env) ? settings.env : {}; + const nextEnv = isRecord(nextSettings.env) ? nextSettings.env : {}; + const envKeys = new Set(managedEnvKeys); + for (const key of [...Object.keys(settingsEnv), ...Object.keys(nextEnv)]) { + if (isManagedClaudeCodeSettingsEnvKey(key)) { + envKeys.add(key); + } + } + + for (const key of envKeys) { + if (settingsEnv[key] !== nextEnv[key]) { + return true; + } + } + return false; +} + +function claudeCodeManagedSettingsEnvKeys( + profileEnvValues: Record, + mcpConfigEnv: Record, + timezoneEnv: Record +): Set { + return new Set([ + ...claudeCodeGatewayEnvKeys, + ...claudeCodeRemovedAuthEnvKeys, + ...Object.keys(profileEnvValues), + ...Object.keys(mcpConfigEnv), + ...Object.keys(timezoneEnv), + CLAUDE_CODE_MCP_CONFIG_ENV, + CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV + ]); +} + +function isManagedClaudeCodeSettingsEnvKey(key: string): boolean { + return (claudeCodeGatewayEnvKeys as readonly string[]).includes(key) || + (claudeCodeRemovedAuthEnvKeys as readonly string[]).includes(key) || + key === CLAUDE_CODE_MCP_CONFIG_ENV || + key === CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV || + isClaudeCodeManagedModelEnvKey(key) || + isBotGatewayEnvKey(key); +} + function writeFileWithBackup( file: string, content: string, @@ -2478,7 +2570,8 @@ function synchronizeGlobalProfileTakeovers( const previous = ownedGlobalProfileTakeovers ?? readGlobalProfileTakeoverMarker(); const preserved = previous.filter((record) => excludedAgents.has(record.agent)); const restorable = previous.filter((record) => !excludedAgents.has(record.agent)); - if (ownedGlobalProfileTakeovers !== undefined && JSON.stringify(restorable) === JSON.stringify(next)) { + if (JSON.stringify(restorable) === JSON.stringify(next)) { + storeGlobalProfileTakeoverRecords(dedupeGlobalProfileTakeovers([...preserved, ...next])); return []; } @@ -2486,13 +2579,21 @@ function synchronizeGlobalProfileTakeovers( const markerRecords = statuses.every((status) => status.ok) ? dedupeGlobalProfileTakeovers([...preserved, ...next]) : dedupeGlobalProfileTakeovers([...preserved, ...restorable, ...next]); - if (markerRecords.length > 0) { - writeGlobalProfileTakeoverMarker(markerRecords); + storeGlobalProfileTakeoverRecords(markerRecords); + return statuses; +} + +function storeGlobalProfileTakeoverRecords(records: GlobalProfileTakeoverRecord[]): void { + const previous = ownedGlobalProfileTakeovers; + ownedGlobalProfileTakeovers = records; + if (JSON.stringify(previous) === JSON.stringify(records)) { + return; + } + if (records.length > 0) { + writeGlobalProfileTakeoverMarker(records); } else { clearGlobalProfileTakeoverMarker(); } - ownedGlobalProfileTakeovers = markerRecords; - return statuses; } function globalProfileTakeoverRecords(profiles: ProfileConfig[]): GlobalProfileTakeoverRecord[] { diff --git a/packages/core/test/integration/profiles/profile-service.test.mjs b/packages/core/test/integration/profiles/profile-service.test.mjs index 18a05fbb..4ff3e5e1 100644 --- a/packages/core/test/integration/profiles/profile-service.test.mjs +++ b/packages/core/test/integration/profiles/profile-service.test.mjs @@ -80,6 +80,201 @@ test("profile service cleans stale generated bin backups only", () => { } }); +test("profile service preserves user statusLine when the active global Claude takeover marker is unchanged", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-statusline-")); + const takeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json"); + try { + rmSync(takeoverFile, { force: true }); + const settingsFile = path.join(root, ".claude", "settings.json"); + mkdirSync(path.dirname(settingsFile), { recursive: true }); + writeFileSync(`${settingsFile}.ccr-original`, `${JSON.stringify({ + env: { + USER_VALUE: "kept" + }, + theme: "dark" + }, null, 2)}\n`); + writeFileSync(settingsFile, `${JSON.stringify({ + apiKeyHelper: "/tmp/ccr-claude-code-api-key-statusline-test", + currentOnly: "kept", + env: { + ANTHROPIC_API_BASE_URL: "http://127.0.0.1:3456", + ANTHROPIC_BASE_URL: "http://127.0.0.1:3456", + CLAUDE_AGENT_API_BASE_URL: "http://127.0.0.1:3456", + USER_VALUE: "kept" + }, + statusLine: { + command: "ccstatusline", + type: "command" + } + }, null, 2)}\n`); + + const profile = { + agent: "claude-code", + enabled: true, + env: {}, + id: "default-claude-code", + model: "Provider/model", + name: "Claude Code", + scope: "global", + settingsFile, + smallFastModel: "", + surface: "auto" + }; + writeFileSync(takeoverFile, `${JSON.stringify({ + profiles: [{ + agent: "claude-code", + id: profile.id, + name: profile.name, + settingsFile + }], + version: 1 + }, null, 2)}\n`); + + const config = createDefaultAppConfig({ + generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") + }); + config.APIKEY = "ccr-profile-statusline-test"; + config.APIKEYS = [{ + createdAt: "2026-01-01T00:00:00.000Z", + id: `profile:${profile.id}`, + key: "ccr-profile-statusline-test", + name: "Profile: Claude Code" + }]; + config.Providers = [{ + api_base_url: "https://example.test/v1", + api_key: "provider-key", + models: ["model"], + name: "Provider" + }]; + config.profile.profiles = [profile]; + + const result = await applyProfileConfig(config); + assert.equal(result.clients.some((client) => client.client === "claude-code" && client.ok), true); + const current = JSON.parse(readFileSync(settingsFile, "utf8")); + assert.deepEqual(current.statusLine, { + command: "ccstatusline", + type: "command" + }); + assert.equal(current.currentOnly, "kept"); + assert.equal(current.theme, undefined); + assert.equal(current.env.USER_VALUE, "kept"); + assert.equal(current.env.ANTHROPIC_MODEL, "Provider/model"); + } finally { + restoreGlobalProfileConfigsOnExit([], { manageMarker: true }); + rmSync(takeoverFile, { force: true }); + rmSync(root, { force: true, recursive: true }); + } +}); + +test("profile service does not overwrite invalid global Claude settings JSON", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-invalid-settings-")); + const takeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json"); + try { + rmSync(takeoverFile, { force: true }); + const settingsFile = path.join(root, ".claude", "settings.json"); + mkdirSync(path.dirname(settingsFile), { recursive: true }); + const invalidContent = "{\n \"statusLine\": {\n"; + writeFileSync(settingsFile, invalidContent); + + const profile = { + agent: "claude-code", + enabled: true, + env: {}, + id: "invalid-claude-settings", + model: "Provider/model", + name: "Invalid Claude Settings", + scope: "global", + settingsFile, + smallFastModel: "", + surface: "auto" + }; + const config = createDefaultAppConfig({ + generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") + }); + config.APIKEY = "ccr-profile-invalid-settings-test"; + config.APIKEYS = [{ + createdAt: "2026-01-01T00:00:00.000Z", + id: `profile:${profile.id}`, + key: "ccr-profile-invalid-settings-test", + name: "Profile: Invalid Claude Settings" + }]; + config.Providers = [{ + api_base_url: "https://example.test/v1", + api_key: "provider-key", + models: ["model"], + name: "Provider" + }]; + config.profile.profiles = [profile]; + + const result = await applyProfileConfig(config); + const status = result.clients.find((client) => client.client === "claude-code"); + assert.equal(status?.ok, false); + assert.match(status?.message ?? "", /not valid JSON/); + assert.equal(readFileSync(settingsFile, "utf8"), invalidContent); + } finally { + restoreGlobalProfileConfigsOnExit([], { manageMarker: true }); + rmSync(takeoverFile, { force: true }); + rmSync(root, { force: true, recursive: true }); + } +}); + +test("profile service does not rewrite Claude settings when only user-managed fields change", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-user-fields-")); + const takeoverFile = path.join(CONFIGDIR, "global-profile-takeover.json"); + try { + rmSync(takeoverFile, { force: true }); + const settingsFile = path.join(root, ".claude", "settings.json"); + const profile = { + agent: "claude-code", + enabled: true, + env: {}, + id: "user-fields-claude-settings", + model: "Provider/model", + name: "User Fields Claude Settings", + scope: "global", + settingsFile, + smallFastModel: "", + surface: "auto" + }; + const config = createDefaultAppConfig({ + generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") + }); + config.APIKEY = "ccr-profile-user-fields-test"; + config.APIKEYS = [{ + createdAt: "2026-01-01T00:00:00.000Z", + id: `profile:${profile.id}`, + key: "ccr-profile-user-fields-test", + name: "Profile: User Fields Claude Settings" + }]; + config.Providers = [{ + api_base_url: "https://example.test/v1", + api_key: "provider-key", + models: ["model"], + name: "Provider" + }]; + config.profile.profiles = [profile]; + + const initialResult = await applyProfileConfig(config); + assert.equal(initialResult.clients.some((client) => client.client === "claude-code" && client.ok), true); + const settings = JSON.parse(readFileSync(settingsFile, "utf8")); + settings.disableAllHooks = true; + settings.statusLine = { + command: "ccstatusline", + type: "command" + }; + const userEditedContent = JSON.stringify(settings); + writeFileSync(settingsFile, userEditedContent); + + const secondResult = await applyProfileConfig(config); + assert.equal(secondResult.clients.some((client) => client.client === "claude-code" && client.ok), true); + assert.equal(readFileSync(settingsFile, "utf8"), userEditedContent); + } finally { + restoreGlobalProfileConfigsOnExit([], { manageMarker: true }); + rmSync(takeoverFile, { force: true }); + rmSync(root, { force: true, recursive: true }); + } +}); + test("profile service can exclude ZCode from automatic synchronization", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "ccr-zcode-auto-sync-")); try { From 084b660cd4ea7ab5fe0a3756895f9f6a29287088 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 24 Jul 2026 17:38:39 +0800 Subject: [PATCH 5/5] Document Pi agent profile support --- README.md | 10 +- README_zh.md | 10 +- .../content/docs/en/configuration/profiles.md | 25 ++- docs/src/content/docs/en/guides.md | 8 +- .../content/docs/en/guides/agent-profile.md | 8 +- docs/src/content/docs/en/guides/cli.md | 6 +- docs/src/content/docs/en/index.md | 4 +- .../content/docs/zh/configuration/profile.md | 25 ++- docs/src/content/docs/zh/guides.md | 8 +- .../content/docs/zh/guides/agent-profile.md | 8 +- docs/src/content/docs/zh/guides/cli.md | 6 +- docs/src/content/docs/zh/index.md | 4 +- packages/cli/README.md | 6 +- packages/cli/README_zh.md | 6 +- packages/core/src/agents/pi/profile-config.ts | 183 ++++++++++++++++++ packages/core/src/config/config.ts | 12 +- packages/core/src/contracts/app.ts | 4 +- .../src/observability/request-log-store.ts | 25 ++- packages/core/src/profiles/launch-core.ts | 32 ++- packages/core/src/profiles/service.ts | 164 ++++++++++++++-- .../profiles/profile-service.test.mjs | 80 ++++++++ .../unit/agents/pi-profile-config.test.mjs | 61 ++++++ .../profiles/profile-launch-core.test.mjs | 21 ++ packages/ui/src/assets/agent-logos/pi.svg | 21 ++ .../ui/src/pages/home/components/profiles.tsx | 22 ++- packages/ui/src/pages/home/shared/i18n.tsx | 4 + packages/ui/src/pages/home/shared/options.ts | 2 + packages/ui/src/pages/home/shared/profiles.ts | 28 ++- packages/ui/src/pages/home/shared/usage.ts | 5 +- packages/ui/test/component/profiles.test.tsx | 37 ++++ 30 files changed, 752 insertions(+), 83 deletions(-) create mode 100644 packages/core/src/agents/pi/profile-config.ts create mode 100644 packages/core/test/unit/agents/pi-profile-config.test.mjs create mode 100644 packages/ui/src/assets/agent-logos/pi.svg diff --git a/README.md b/README.md index 125d9384..75f4a8b3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ### Manage every agent and provider from one place. -Connect Claude Code, Codex, Grok CLI, ZCode, and compatible API clients to the providers you choose—then route, fail over, extend, and observe every request from one app. +Connect Claude Code, Codex, Grok CLI, Kimi CLI, Pi, ZCode, and compatible API clients to the providers you choose—then route, fail over, extend, and observe every request from one app.

Download Desktop @@ -59,7 +59,7 @@ Connect Claude Code, Codex, Grok CLI, ZCode, and compatible API clients to the p ## Why use Claude Code Router? -Claude Code Router (CCR) is a local model gateway and control plane for coding agents. It gives Claude Code, Codex, Grok CLI, ZCode, and compatible API clients **one stable local endpoint**, while you manage the providers, models, accounts, routing rules, and tools behind it from one place. +Claude Code Router (CCR) is a local model gateway and control plane for coding agents. It gives Claude Code, Codex, Grok CLI, Kimi CLI, Pi, ZCode, and compatible API clients **one stable local endpoint**, while you manage the providers, models, accounts, routing rules, and tools behind it from one place. Use CCR to: @@ -78,7 +78,7 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten 1. **[Download Claude Code Router](https://github.com/musistudio/claude-code-router/releases)** for macOS, Windows, or Linux, then launch the app. 2. Open **Providers → Add Provider**. Choose a built-in preset or a custom endpoint, enter the API key, select the protocol and models, then save. 3. Open **Server** and click **Start**. The local model gateway listens on `http://127.0.0.1:3456` by default. -4. Open **Agent Profiles**, choose Claude Code, Codex, Grok CLI, or ZCode, select a model, and apply the profile. +4. Open **Agent Profiles**, choose Claude Code, Codex, Grok CLI, Kimi CLI, Pi, or ZCode, select a model, and apply the profile. 5. Start using your agent. Open **Logs** to confirm the resolved provider, model, status, tokens, latency, and errors. Your agent is now connected to CCR. To add conditions, retries, request rewrites, or fallback models, open **Routing**. @@ -117,7 +117,7 @@ Docker exposes the management UI and gateway routes through `http://127.0.0.1:34 ## How it works ```text -Claude Code · Codex · Grok CLI · ZCode · Compatible API clients +Claude Code · Codex · Grok CLI · Kimi CLI · Pi · ZCode · Compatible API clients │ ▼ Claude Code Router :3456 @@ -131,7 +131,7 @@ Claude Code · Codex · Grok CLI · ZCode · Compatible API clients | Area | Highlights | | --- | --- | -| **Agents** | Profiles for Claude Code, Codex, Grok CLI, and ZCode; model overrides; scopes; environment settings; CLI and app launch entries; multi-instance workflows | +| **Agents** | Profiles for Claude Code, Codex, Grok CLI, Kimi CLI, Pi, and ZCode; model overrides; scopes; environment settings; CLI and app launch entries; multi-instance workflows | | **Providers** | Presets and custom endpoints; protocol probing; model discovery; connectivity checks; local login import where supported; single keys and credential pools | | **Models & routing** | Searchable catalog; model descriptions for task selection; conditions on headers and bodies; prefixes; rewrites; retries; ordered fallbacks | | **Tools & extensions** | Fusion models; ToolHub; built-in browser automation; Chrome login-state import; wrapper and core gateway plugins; local routes and virtual models | diff --git a/README_zh.md b/README_zh.md index efba088d..c9d4ebb1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -36,7 +36,7 @@ ### 在一个地方,管理你所有的 Agent 与 Provider -让 Claude Code、Codex、Grok CLI、ZCode 和兼容 API 客户端连接你选择的供应商,并在一个应用里完成每次请求的路由、降级、增强与观测。 +让 Claude Code、Codex、Grok CLI、Kimi CLI、Pi、ZCode 和兼容 API 客户端连接你选择的供应商,并在一个应用里完成每次请求的路由、降级、增强与观测。

下载桌面端 @@ -59,7 +59,7 @@ ## 为什么使用 Claude Code Router? -Claude Code Router(CCR)是面向编程 Agent 的本地模型网关与控制平面。它为 Claude Code、Codex、Grok CLI、ZCode 和兼容 API 客户端提供**一个稳定的本地入口**,让你在一个地方管理入口背后的供应商、模型、账号、路由规则与工具。 +Claude Code Router(CCR)是面向编程 Agent 的本地模型网关与控制平面。它为 Claude Code、Codex、Grok CLI、Kimi CLI、Pi、ZCode 和兼容 API 客户端提供**一个稳定的本地入口**,让你在一个地方管理入口背后的供应商、模型、账号、路由规则与工具。 你可以使用 CCR: @@ -78,7 +78,7 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten 1. **[下载 Claude Code Router](https://github.com/musistudio/claude-code-router/releases)**,选择 macOS、Windows 或 Linux 版本并启动应用。 2. 打开 **供应商 → 添加供应商**。选择内置预设或自定义端点,填写 API Key,选择协议与模型,然后保存。 3. 打开 **服务** 并点击 **启动**。本地模型网关默认监听 `http://127.0.0.1:3456`。 -4. 打开 **Agent 配置档案**,选择 Claude Code、Codex、Grok CLI 或 ZCode,指定模型并应用配置档案。 +4. 打开 **Agent 配置档案**,选择 Claude Code、Codex、Grok CLI、Kimi CLI、Pi 或 ZCode,指定模型并应用配置档案。 5. 开始使用 Agent。在 **日志** 中确认最终供应商、模型、状态、Token、耗时与错误。 现在 Agent 已经连接到 CCR。如需增加条件规则、自动重试、请求改写或 Fallback 模型,请打开 **路由**。 @@ -117,7 +117,7 @@ Docker 默认通过 `http://127.0.0.1:3458` 提供管理界面与网关路由。 ## 工作方式 ```text -Claude Code · Codex · Grok CLI · ZCode · 兼容 API 客户端 +Claude Code · Codex · Grok CLI · Kimi CLI · Pi · ZCode · 兼容 API 客户端 │ ▼ Claude Code Router :3456 @@ -131,7 +131,7 @@ Claude Code · Codex · Grok CLI · ZCode · 兼容 API 客户端 | 能力领域 | 功能亮点 | | --- | --- | -| **Agent** | Claude Code、Codex、Grok CLI 和 ZCode 配置档案;模型覆盖;作用范围;环境变量;CLI / App 启动入口;多开工作流 | +| **Agent** | Claude Code、Codex、Grok CLI、Kimi CLI、Pi 和 ZCode 配置档案;模型覆盖;作用范围;环境变量;CLI / App 启动入口;多开工作流 | | **供应商** | 内置预设和自定义端点;协议探测;模型发现;连通性检测;按支持情况导入本机登录态;单 Key 与凭据池 | | **模型与路由** | 可搜索模型目录;用于任务选择的模型描述;Header / Body 条件;模型前缀;请求改写;重试;有序 Fallback | | **工具与扩展** | Fusion 模型;ToolHub;内置浏览器自动化;Chrome 登录态导入;wrapper / core gateway plugin;本地路由与虚拟模型 | diff --git a/docs/src/content/docs/en/configuration/profiles.md b/docs/src/content/docs/en/configuration/profiles.md index 222a51ef..374e58eb 100644 --- a/docs/src/content/docs/en/configuration/profiles.md +++ b/docs/src/content/docs/en/configuration/profiles.md @@ -2,7 +2,7 @@ title: Agent Profiles pageTitle: Agent Profiles eyebrow: Detailed Configuration -lead: Create reusable launch configurations for Claude Code, Codex, Grok CLI, Kimi CLI, and ZCode, and open separate agent instances from different configs. +lead: Create reusable launch configurations for Claude Code, Codex, Grok CLI, Kimi CLI, Pi, and ZCode, and open separate agent instances from different configs. --- ## Configuration Flow @@ -14,7 +14,7 @@ lead: Create reusable launch configurations for Claude Code, Codex, Grok CLI, Ki 5. If the entry mode includes App, optionally bind a Bot and choose whether to forward agent messages or enable handoff. 6. Save the config, then open it from the Agent Profiles card: the terminal button copies the CLI command, and the play button starts the App instance. -During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, Grok CLI, Kimi CLI, or ZCode setup you open directly from the system. +During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, Grok CLI, Kimi CLI, Pi, or ZCode setup you open directly from the system. ## Multi-Instance Mechanism @@ -22,8 +22,8 @@ Every Agent Profiles has its own `id` and name. When CCR opens an agent, it find | Mechanism | Actual behavior | | --- | --- | -| Separate config files | With **Only opened from CCR**, Claude Code and Codex write CCR-managed config files in directories separated by config `id` | -| Separate launchers | Claude Code, Grok CLI, and Kimi CLI use separate launch wrappers; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name | +| Separate config files | With **Only opened from CCR**, Claude Code, Codex, OpenCode, Kimi CLI, and Pi write CCR-managed config or home files in directories separated by config `id` | +| Separate launchers | Claude Code, Grok CLI, Kimi CLI, Pi, and OpenCode use separate launch wrappers; Codex and ZCode use separate middleware launchers; filenames are also separated by config `id` or name | | Separate app data directories | When opening App mode, Claude App, ChatGPT (the renamed Codex desktop app), and ZCode App use user-data directories separated by config `id` | | Runtime state | CCR tracks running app instances by entry mode and config `id`; reopening the same config activates the existing window, while a different config can open a separate instance | @@ -33,11 +33,11 @@ This lets you create multiple configs for the same agent, such as "Claude Code - | Option | Applies to | Description | | --- | --- | --- | -| Agent | All | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, or ZCode. Grok CLI and Kimi CLI support CLI only; ZCode supports App only. | +| Agent | All | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, Pi, or ZCode. Grok CLI, Kimi CLI, and Pi support CLI only; ZCode supports App only. | | Config name | All | Identifies the config in CCR and can be used as the `ccr-app ` launch target. Names can contain spaces; copied commands are quoted automatically. | | Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. | | Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. | -| Entry mode | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI and Kimi CLI are fixed to `CLI only`. | +| Entry mode | Claude Code, Codex, OpenCode, Grok CLI, Kimi CLI, Pi | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. Grok CLI, Kimi CLI, and Pi are fixed to `CLI only`. | | Model | All | Default model for the opened agent, either a provider model or Fusion model. Claude Code requires this value. | | Available models | Kimi CLI | Models exposed by Kimi's `/model` command. The default model is always included. | | Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. | @@ -107,6 +107,15 @@ The generated wrapper sets Grok's model base URL and model-list URL to CCR's `/v Kimi CLI profiles are fixed to **Only opened from CCR** and **CLI only**. Select one default model and one or more available models. The generated wrapper points `KIMI_CODE_HOME` at a profile-specific directory whose `config.toml` defines a private OpenAI-compatible CCR provider and a model entry for every selection. Kimi's `/model` command can therefore switch models without bypassing CCR. CCR preserves non-provider settings from the source config and reuses available sessions, skills, plugins, MCP configuration, and credentials without rewriting the original `~/.kimi-code/config.toml`. If CCR Desktop is not running, the launcher starts a shared temporary gateway and stops it after the last managed Kimi session exits. +### Pi + +| Option | What it does | +| --- | --- | +| Pi model | Optional default model passed to Pi. If left empty, CCR uses the first available gateway model. | +| Environment variables | Injected into the Pi wrapper. Use `CCR_PI_BIN` or `PI_BIN` when the real Pi executable is not available as `pi`. CCR manages `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and `PI_SKIP_VERSION_CHECK`. | + +Pi profiles are fixed to **Only opened from CCR** and **CLI only**. CCR writes a profile-specific `models.json` under `PI_CODING_AGENT_DIR`, with a provider that uses the local CCR `/v1` gateway as an OpenAI Responses endpoint and the profile-specific CCR API key. The generated wrapper sets `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`, then launches Pi with `--provider` and `--model` so requests stay routed through CCR. If CCR Desktop is not running, the launcher starts the same shared temporary gateway used by other CLI-only profiles. + ### ZCode | Option | What it does | @@ -160,6 +169,10 @@ Grok CLI supports CLI only. CCR opens it through a profile-specific wrapper that Kimi CLI supports CLI only. CCR opens it through a profile-specific wrapper and generated Kimi home containing the selected default model plus every available model. All generated model entries use the CCR gateway and profile API key, so `/model` switches remain routed through CCR; the user's original Kimi configuration remains untouched. +### Pi + +Pi supports CLI only. CCR opens it through a profile-specific wrapper, generated `PI_CODING_AGENT_DIR`, and generated `models.json`. The Pi provider uses CCR's OpenAI Responses gateway and the profile API key; the wrapper passes the selected provider and model to the real Pi executable without importing Pi login state. + ### ZCode ZCode supports App only. CCR writes ZCode CLI config, v2 config, and model cache based on ZCode home or a custom config file, then starts the App with the current Agent Profiles's model, provider, and separate user-data directory. diff --git a/docs/src/content/docs/en/guides.md b/docs/src/content/docs/en/guides.md index 878a8ae0..40e2417d 100644 --- a/docs/src/content/docs/en/guides.md +++ b/docs/src/content/docs/en/guides.md @@ -55,7 +55,7 @@ If you want the overview to show balance or remaining quota, open the provider's ## Connect Agent Profiles -Agent Profiles lets Claude Code, Codex, Grok CLI, ZCode, and other agents use CCR's providers, routing, and model selection. +Agent Profiles lets Claude Code, Codex, Grok CLI, Kimi CLI, Pi, ZCode, and other agents use CCR's providers, routing, and model selection. General guidance: @@ -75,13 +75,17 @@ In **Agent Profiles**, choose Codex and confirm Provider ID, Provider Name, mode Choose Grok CLI and select a default model, then run the copied `ccr-app ` command. The command starts a shared temporary gateway service when CCR Desktop is not already serving one; concurrent Grok sessions keep it alive until the last session exits. CCR points Grok model discovery and inference at the local gateway; use `/model` inside Grok to switch CCR models. +### Pi + +Choose Pi and optionally select a default model, then run the copied `ccr-app ` command. CCR writes a profile-specific `models.json` under `PI_CODING_AGENT_DIR`, registers the local CCR gateway as an OpenAI Responses provider, and launches Pi with the matching `--provider` and `--model` arguments. If CCR Desktop is not running, the launcher can start the same managed temporary gateway used by other CLI-only profiles. + ### ZCode ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields. ### Reuse A Locally Logged-In Agent -If Claude Code, Codex, Grok CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. +If Claude Code, Codex, Grok CLI, Kimi CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. Pi profiles do not import Pi login state; they use a generated CCR provider config and CCR profile API key. ## Logs & Observability diff --git a/docs/src/content/docs/en/guides/agent-profile.md b/docs/src/content/docs/en/guides/agent-profile.md index 23fe2390..a9925e2c 100644 --- a/docs/src/content/docs/en/guides/agent-profile.md +++ b/docs/src/content/docs/en/guides/agent-profile.md @@ -2,7 +2,7 @@ title: Connect Agent Profiles pageTitle: Connect Agent Profiles eyebrow: Quick Start -lead: Let Claude Code, Codex, Grok CLI, Kimi CLI, ZCode, and other agents use CCR's providers, routing, and model selection. +lead: Let Claude Code, Codex, Grok CLI, Kimi CLI, Pi, ZCode, and other agents use CCR's providers, routing, and model selection. --- ## General Guidance @@ -31,10 +31,14 @@ Choose Grok CLI, select a model, and run the copied `ccr-app ` com Choose Kimi CLI, select a default model and one or more available CCR models, then run the copied `ccr-app ` command. CCR launches Kimi with a profile-specific `KIMI_CODE_HOME` whose generated `config.toml` registers every selected model against the local CCR gateway. Use `/model` inside Kimi to switch among them. The original `~/.kimi-code/config.toml` is not changed, while sessions, skills, plugins, MCP configuration, and credentials are reused from the source Kimi home when available. The wrapper can also start the same managed temporary gateway when CCR Desktop is not running. +## Pi + +Choose Pi, optionally select a default model, and run the copied `ccr-app ` command. CCR creates a profile-specific `PI_CODING_AGENT_DIR`, writes a local `models.json` using the CCR gateway as an OpenAI Responses provider, and launches Pi with the generated provider and model arguments. This profile path does not import Pi login state; it uses the CCR profile API key and normal CCR routing. + ## ZCode ZCode mainly uses model, Provider ID, Provider Name, and whether it is launched from CCR. It uses the App surface and does not need Codex CLI path fields. ## Reuse A Locally Logged-In Agent -If Claude Code, Codex, Grok CLI, Kimi CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. Kimi CLI imports both managed OAuth logins and API-key providers from `~/.kimi-code/config.toml`. +If Claude Code, Codex, Grok CLI, Kimi CLI, or ZCode is already logged in on this machine, import it as a **Local Agent Provider** from **Providers** to reuse the existing authorization without applying for another key. Kimi CLI imports both managed OAuth logins and API-key providers from `~/.kimi-code/config.toml`. Pi profiles do not import Pi login state; they use a generated CCR provider config and CCR profile API key. diff --git a/docs/src/content/docs/en/guides/cli.md b/docs/src/content/docs/en/guides/cli.md index ad8a89af..359dc69c 100644 --- a/docs/src/content/docs/en/guides/cli.md +++ b/docs/src/content/docs/en/guides/cli.md @@ -118,13 +118,13 @@ ccr profile-id -- --help - `--cli` and `--app` are alternatives to the positional surface. - Put agent arguments after `--` to avoid ambiguity. -- Claude Code, Codex, and Grok default to CLI; ZCode defaults to App. -- Grok supports CLI only; ZCode supports App only. +- Claude Code, Codex, Grok CLI, Kimi CLI, and Pi default to CLI; ZCode defaults to App. +- Grok CLI, Kimi CLI, and Pi support CLI only; ZCode supports App only. - Claude App and ZCode App reject trailing agent arguments. - App launches require a locally installed application and graphical session. - Only enabled profiles are launchable. Use the profile ID when names are ambiguous. -Most profiles require the CCR gateway to be running. Grok CLI can create a managed temporary shared service and stops it after the final managed Grok session exits. +Most profiles require the CCR gateway to be running. Grok CLI, Kimi CLI, and Pi can create a managed temporary shared service and stop it after the final managed session exits. ## Configuration And Data diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md index 0e299b93..ce607b80 100644 --- a/docs/src/content/docs/en/index.md +++ b/docs/src/content/docs/en/index.md @@ -2,7 +2,7 @@ title: Claude Code Router pageTitle: Documentation eyebrow: Product Documentation -lead: Use CCR to connect Claude Code, Codex, Grok CLI, ZCode, and compatible API clients to the model providers you choose. If this is your first visit, get one routed request working before diving into routing, Fusion, Bots, and observability. +lead: Use CCR to connect Claude Code, Codex, Grok CLI, Kimi CLI, Pi, ZCode, and compatible API clients to the model providers you choose. If this is your first visit, get one routed request working before diving into routing, Fusion, Bots, and observability. --- ## Get One Routed Request Working @@ -11,7 +11,7 @@ Start with the shortest successful path: 1. Choose desktop, npm CLI, or Docker from [Install and launch CCR](guides/install/). 2. Open **Providers**, add a preset or custom endpoint, enter an API key, and select at least one model. -3. Open **Agent Profiles**, choose the default model and entry mode for Claude Code, Codex, Grok CLI, or ZCode. +3. Open **Agent Profiles**, choose the default model and entry mode for Claude Code, Codex, Grok CLI, Kimi CLI, Pi, or ZCode. 4. Start the local gateway service. The default endpoint is `http://127.0.0.1:3456`. 5. Send one request from your agent, then open **Logs** to confirm the resolved provider, model, status, tokens, and errors. diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md index b3bc9d17..31371458 100644 --- a/docs/src/content/docs/zh/configuration/profile.md +++ b/docs/src/content/docs/zh/configuration/profile.md @@ -2,7 +2,7 @@ title: Agent 配置档案 pageTitle: Agent 配置档案 eyebrow: 详细配置 -lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。 +lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、Pi、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。 --- ## 配置流程 @@ -14,7 +14,7 @@ lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 创建可复用的 5. 如果入口模式包含 App,可以绑定 Bot,并选择是否转发 Agent 消息或开启接力。 6. 保存后,从 Agent 配置档案卡片打开:终端图标会复制 CLI 命令,播放图标会启动 App 实例。 -试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex、Grok CLI、Kimi CLI 或 ZCode。 +试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex、Grok CLI、Kimi CLI、Pi 或 ZCode。 ## 多开机制 @@ -22,8 +22,8 @@ lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 创建可复用的 | 机制 | 实际行为 | | --- | --- | -| 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code 和 Codex 会写入 CCR 管理的独立配置目录,路径按配置 `id` 区分 | -| 独立启动器 | Claude Code、Grok CLI 和 Kimi CLI 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 | +| 独立配置文件 | 选择“仅从 CCR 打开时生效”时,Claude Code、Codex、OpenCode、Kimi CLI 和 Pi 会写入 CCR 管理的独立配置或 home 目录,路径按配置 `id` 区分 | +| 独立启动器 | Claude Code、Grok CLI、Kimi CLI、Pi 和 OpenCode 使用独立启动包装器,Codex 和 ZCode 使用独立中间层启动器,文件名同样按配置 `id` 或名称区分 | | 独立 App 数据目录 | 从 App 打开时,Claude App、ChatGPT(Codex 桌面端的新名称)、ZCode App 都会使用按配置 `id` 区分的用户数据目录 | | 运行状态 | CCR 按打开入口和配置 `id` 记录运行中的 App 实例;同一个配置再次打开会激活已有窗口,不同配置可以打开不同实例 | @@ -33,11 +33,11 @@ lead: 为 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 创建可复用的 | 选项 | 适用范围 | 说明 | | --- | --- | --- | -| Agent | 全部 | 选择 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI 或 ZCode。Grok CLI 和 Kimi CLI 只支持 CLI,ZCode 只支持 App。 | +| Agent | 全部 | 选择 Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI、Pi 或 ZCode。Grok CLI、Kimi CLI 和 Pi 只支持 CLI,ZCode 只支持 App。 | | 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr-app <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 | | 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 | | 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 | -| 入口模式 | Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI 和 Kimi CLI 固定为 `CLI only`。 | +| 入口模式 | Claude Code、Codex、OpenCode、Grok CLI、Kimi CLI、Pi | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。Grok CLI、Kimi CLI 和 Pi 固定为 `CLI only`。 | | 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 必须填写该值。 | | 可用模型 | Kimi CLI | Kimi `/model` 命令中可切换的模型;默认模型始终包含在内。 | | Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 | @@ -107,6 +107,15 @@ Grok CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。保 Kimi CLI 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。请选择一个默认模型以及一个或多个可用模型。生成的包装器会把 `KIMI_CODE_HOME` 指向配置专属目录,其中的 `config.toml` 定义私有的 OpenAI 兼容 CCR 供应商,并为每个选中模型生成模型项,因此可在 Kimi 内使用 `/model` 切换且不会绕过 CCR。CCR 会保留来源配置中的非供应商设置,并复用可用的会话、技能、插件、MCP 配置和凭据,不会改写原始 `~/.kimi-code/config.toml`。CCR Desktop 未运行时,启动器会创建共享的临时网关,并在最后一个受管 Kimi 会话退出后停止。 +### Pi + +| 配置项 | 作用 | +| --- | --- | +| Pi model | 可选的 Pi 默认模型;留空时 CCR 使用第一个可用网关模型。 | +| 环境变量 | 注入 Pi 启动包装器。如果真实 Pi 可执行文件不在 `pi` 命令上,可设置 `CCR_PI_BIN` 或 `PI_BIN`。`PI_CODING_AGENT_DIR`、`PI_CODING_AGENT_SESSION_DIR` 和 `PI_SKIP_VERSION_CHECK` 由 CCR 管理。 | + +Pi 配置固定为 **仅从 CCR 打开时生效** 和 **CLI only**。CCR 会在配置专属 `PI_CODING_AGENT_DIR` 下写入 `models.json`,其中的供应商使用本地 CCR `/v1` 网关作为 OpenAI Responses 端点,并使用该配置专属 CCR API Key。生成的包装器会设置 `PI_CODING_AGENT_DIR` 和 `PI_CODING_AGENT_SESSION_DIR`,然后用 `--provider` 与 `--model` 启动 Pi,使请求继续经过 CCR。CCR Desktop 未运行时,该启动器会启动其他 CLI-only 配置共用的受管临时网关。 + ### ZCode | 配置项 | 作用 | @@ -160,6 +169,10 @@ Grok CLI 只支持 CLI。CCR 通过配置专属包装器启动它,注入 CCR Kimi CLI 只支持 CLI。CCR 通过配置专属包装器和生成的 Kimi home 启动它,其中包含默认模型和所有选中的可用模型。每个模型项都使用 CCR 网关和配置专属 API Key,因此 `/model` 切换后仍经过 CCR;用户原有的 Kimi 配置不会被改写。 +### Pi + +Pi 只支持 CLI。CCR 通过配置专属包装器、生成的 `PI_CODING_AGENT_DIR` 和生成的 `models.json` 启动它。Pi 供应商使用 CCR 的 OpenAI Responses 网关和配置专属 API Key;包装器会把选中的 provider 和 model 传给真实 Pi 可执行文件,但不会导入 Pi 登录态。 + ### ZCode ZCode 只支持 App 打开。CCR 会根据 ZCode home 或自定义配置文件写入 ZCode 的 CLI 配置、v2 配置和模型缓存,并在 App 启动时使用当前 Agent 配置档案的模型、供应商和独立用户数据目录。 diff --git a/docs/src/content/docs/zh/guides.md b/docs/src/content/docs/zh/guides.md index aed7f0b6..a5339a0d 100644 --- a/docs/src/content/docs/zh/guides.md +++ b/docs/src/content/docs/zh/guides.md @@ -55,7 +55,7 @@ CCR 提供三种发行方式:桌面应用、Node.js 22+ 的 npm CLI,以及 D ## 接入 Agent 配置档案 -Agent 配置档案让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 +Agent 配置档案让 Claude Code、Codex、Grok CLI、Kimi CLI、Pi、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 通用建议: @@ -75,13 +75,17 @@ Agent 配置档案让 Claude Code、Codex、Grok CLI、ZCode 等 Agent 使用 CC 选择 Grok CLI 并设置默认模型,然后运行复制出的 `ccr-app <配置名称>` 命令。即使 CCR Desktop 网关尚未运行,该命令也会启动一个可共享的临时网关服务;并发 Grok 会话会共同保持服务运行,直到最后一个会话退出。CCR 会把 Grok 的模型发现和推理请求指向本地网关;进入 Grok 后可以用 `/model` 切换 CCR 模型。 +### Pi + +选择 Pi,可选设置默认模型,然后运行复制出的 `ccr-app <配置名称>` 命令。CCR 会在配置专属 `PI_CODING_AGENT_DIR` 下写入 `models.json`,把本地 CCR 网关注册为 OpenAI Responses 供应商,并使用匹配的 `--provider` 和 `--model` 参数启动 Pi。CCR Desktop 未运行时,该启动器也可以启动其他 CLI-only 配置共用的受管临时网关。 + ### ZCode ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。 ### 复用本机已登录的 Agent -如果本机已经登录过 Claude Code、Codex、Grok CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。 +如果本机已经登录过 Claude Code、Codex、Grok CLI、Kimi CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。Pi 配置不会导入 Pi 登录态;它使用生成的 CCR 供应商配置和配置专属 CCR API Key。 ## 日志&观测 diff --git a/docs/src/content/docs/zh/guides/agent-profile.md b/docs/src/content/docs/zh/guides/agent-profile.md index abf00f05..1590a57c 100644 --- a/docs/src/content/docs/zh/guides/agent-profile.md +++ b/docs/src/content/docs/zh/guides/agent-profile.md @@ -2,7 +2,7 @@ title: 接入 Agent 配置档案 pageTitle: 接入 Agent 配置档案 eyebrow: 快速开始 -lead: 让 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 +lead: 让 Claude Code、Codex、Grok CLI、Kimi CLI、Pi、ZCode 等 Agent 使用 CCR 的供应商、路由和模型选择配置。 --- ## 通用建议 @@ -31,10 +31,14 @@ lead: 让 Claude Code、Codex、Grok CLI、Kimi CLI、ZCode 等 Agent 使用 CCR 选择 Kimi CLI、设置默认模型和一个或多个可用 CCR 模型,然后运行复制出的 `ccr-app <配置名称>` 命令。CCR 会通过配置专属 `KIMI_CODE_HOME` 启动 Kimi,并在其中生成 `config.toml`,把所有选中模型注册到本地 CCR 网关。进入 Kimi 后可使用 `/model` 在这些模型之间切换。用户原有的 `~/.kimi-code/config.toml` 不会被改写;可用时,会继续复用来源 Kimi home 中的会话、技能、插件、MCP 配置和凭据。CCR Desktop 未运行时,该包装器同样可以启动受管的临时网关。 +## Pi + +选择 Pi,可选设置默认模型,然后运行复制出的 `ccr-app <配置名称>` 命令。CCR 会创建配置专属 `PI_CODING_AGENT_DIR`,写入使用 CCR 网关作为 OpenAI Responses 供应商的本地 `models.json`,并用生成的 provider 和 model 参数启动 Pi。这个配置路径不会导入 Pi 登录态;它使用 CCR 配置专属 API Key 和常规 CCR 路由。 + ## ZCode ZCode 主要关注模型、供应商 ID、供应商名称,以及是否从 CCR 启动。它走 App surface,不需要 Codex CLI 的路径字段。 ## 复用本机已登录的 Agent -如果本机已经登录过 Claude Code、Codex、Grok CLI、Kimi CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。Kimi CLI 支持从 `~/.kimi-code/config.toml` 导入受管 OAuth 登录态和 API Key 供应商。 +如果本机已经登录过 Claude Code、Codex、Grok CLI、Kimi CLI 或 ZCode,可以在 **供应商** 中导入为 **本机 Agent 供应商**,复用已有授权,不必额外申请 Key。Kimi CLI 支持从 `~/.kimi-code/config.toml` 导入受管 OAuth 登录态和 API Key 供应商。Pi 配置不会导入 Pi 登录态;它使用生成的 CCR 供应商配置和配置专属 CCR API Key。 diff --git a/docs/src/content/docs/zh/guides/cli.md b/docs/src/content/docs/zh/guides/cli.md index 9ad887ce..951d2cc6 100644 --- a/docs/src/content/docs/zh/guides/cli.md +++ b/docs/src/content/docs/zh/guides/cli.md @@ -144,13 +144,13 @@ ccr profile-id -- --help - `--cli` 和 `--app` 可以替代位置形式的 `cli` / `app`。 - Agent 自己的参数放到 `--` 后,避免与 CCR 选项或入口名冲突。 -- 省略入口时,Claude Code、Codex、Grok CLI 默认使用 CLI,ZCode 默认使用 App。 -- Grok 只支持 CLI,ZCode 只支持 App。 +- 省略入口时,Claude Code、Codex、Grok CLI、Kimi CLI、Pi 默认使用 CLI,ZCode 默认使用 App。 +- Grok CLI、Kimi CLI 和 Pi 只支持 CLI,ZCode 只支持 App。 - Claude App 和 ZCode App 不支持额外 Agent 参数。 - 启动 App 需要本机安装对应桌面应用,并且当前环境有图形会话。 - 只有已启用的配置可以启动。名称产生歧义时使用配置 ID。 -大多数配置要求 CCR 网关已经运行。Grok CLI 是例外:如果服务不存在,它可以自动启动一个受管的临时共享服务,并在最后一个 Grok 会话退出后关闭。 +大多数配置要求 CCR 网关已经运行。Grok CLI、Kimi CLI 和 Pi 是例外:如果服务不存在,它们可以自动启动一个受管的临时共享服务,并在最后一个受管会话退出后关闭。 ## 配置和数据位置 diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md index 781a23ca..36a530a9 100644 --- a/docs/src/content/docs/zh/index.md +++ b/docs/src/content/docs/zh/index.md @@ -2,7 +2,7 @@ title: Claude Code Router 文档 pageTitle: 文档 eyebrow: 产品文档 -lead: 用 CCR 把 Claude Code、Codex、Grok CLI、ZCode 和兼容 API 客户端接到你选择的模型供应商。第一次使用时,先完成一条成功路由请求,再深入路由、Fusion、Bot 和观测。 +lead: 用 CCR 把 Claude Code、Codex、Grok CLI、Kimi CLI、Pi、ZCode 和兼容 API 客户端接到你选择的模型供应商。第一次使用时,先完成一条成功路由请求,再深入路由、Fusion、Bot 和观测。 --- ## 先完成第一条路由请求 @@ -11,7 +11,7 @@ lead: 用 CCR 把 Claude Code、Codex、Grok CLI、ZCode 和兼容 API 客户端 1. 从[安装并启动 CCR](guides/install/)选择桌面版、npm CLI 或 Docker。 2. 打开 **供应商**,添加预设供应商或自定义端点,填写 API Key,并至少选择一个模型。 -3. 打开 **Agent 配置档案**,为 Claude Code、Codex、Grok CLI 或 ZCode 选择默认模型和入口模式。 +3. 打开 **Agent 配置档案**,为 Claude Code、Codex、Grok CLI、Kimi CLI、Pi 或 ZCode 选择默认模型和入口模式。 4. 启动本地网关服务,默认端点是 `http://127.0.0.1:3456`。 5. 从你的 Agent 发送一次请求,然后在 **日志** 中确认最终供应商、模型、状态、Token 和错误。 diff --git a/packages/cli/README.md b/packages/cli/README.md index 0110e721..37da1cb6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -105,10 +105,10 @@ ccr [cli|app] [-- ] - `--cli` and `--app` are accepted alternatives to the positional surface. - Put agent-specific arguments after `--` so they cannot be confused with CCR options. -- If the surface is omitted, CCR uses the first surface allowed by the profile: CLI for Claude Code, Codex, Grok CLI, and Kimi CLI; App for ZCode. -- Grok supports CLI only. ZCode supports App only. Claude App and ZCode App do not accept trailing agent arguments. +- If the surface is omitted, CCR uses the first surface allowed by the profile: CLI for Claude Code, Codex, Grok CLI, Kimi CLI, and Pi; App for ZCode. +- Grok CLI, Kimi CLI, and Pi support CLI only. ZCode supports App only. Claude App and ZCode App do not accept trailing agent arguments. - Desktop App launches require that app to be installed and a graphical session to be available. -- Start the CCR service before opening most profiles. Grok CLI and Kimi CLI profiles can start a temporary shared service automatically and stop it after the last managed session exits. +- Start the CCR service before opening most profiles. Grok CLI, Kimi CLI, and Pi profiles can start a temporary shared service automatically and stop it after the last managed session exits. The desktop application installs a related command named `ccr-app`. Commands copied from desktop Agent Profiles cards use `ccr-app`; the npm package documented here installs `ccr`. diff --git a/packages/cli/README_zh.md b/packages/cli/README_zh.md index a9ff99e4..3db9a80e 100644 --- a/packages/cli/README_zh.md +++ b/packages/cli/README_zh.md @@ -105,10 +105,10 @@ ccr <配置名称或 ID> [cli|app] [-- ] - `--cli` 和 `--app` 也可以代替位置形式的入口类型。 - Agent 自己的参数建议统一放到 `--` 后,避免被识别为 CCR 参数。 -- 省略入口类型时,Claude Code、Codex、Grok CLI、Kimi CLI 默认使用 CLI,ZCode 默认使用 App。 -- Grok 只支持 CLI,ZCode 只支持 App。Claude App 和 ZCode App 不接受额外 Agent 参数。 +- 省略入口类型时,Claude Code、Codex、Grok CLI、Kimi CLI、Pi 默认使用 CLI,ZCode 默认使用 App。 +- Grok CLI、Kimi CLI 和 Pi 只支持 CLI,ZCode 只支持 App。Claude App 和 ZCode App 不接受额外 Agent 参数。 - 启动桌面 App 时,本机必须已安装对应应用,并且当前环境必须有图形会话。 -- 大多数配置需要先启动 CCR 服务。Grok CLI 和 Kimi CLI 配置可以自动启动一个临时共享服务,并在最后一个受管会话退出后停止。 +- 大多数配置需要先启动 CCR 服务。Grok CLI、Kimi CLI 和 Pi 配置可以自动启动一个临时共享服务,并在最后一个受管会话退出后停止。 桌面应用会安装一个相关命令 `ccr-app`。桌面 Agent 配置档案卡片复制出来的命令使用 `ccr-app`;本文介绍的 npm 包安装的是 `ccr`。 diff --git a/packages/core/src/agents/pi/profile-config.ts b/packages/core/src/agents/pi/profile-config.ts new file mode 100644 index 00000000..d3b68e01 --- /dev/null +++ b/packages/core/src/agents/pi/profile-config.ts @@ -0,0 +1,183 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog"; +import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; + +export type PiProfileConfigWriteResult = { + changed: boolean; + file: string; + model: string; + profileHome: string; + providerId: string; + sessionDir: string; +}; + +const privateDirMode = 0o700; +const privateFileMode = 0o600; + +export function resolvePiAgentDir(configDir: string, profile: ProfileConfig): string { + if (profile.scope === "ccr" || profile.scope === "custom") { + const slug = sanitizePathSegment(profile.id || profile.name || "pi") || "pi"; + const baseDir = path.join(configDir, "profiles", slug); + return path.join(profile.scope === "custom" ? path.join(baseDir, "custom") : baseDir, "pi"); + } + + const configured = profile.configFile?.trim(); + return configured ? resolveUserPath(configured) : path.join(homeDir(), ".pi", "agent"); +} + +export function resolvePiSessionDir(configDir: string, profile: ProfileConfig): string { + return path.join(resolvePiAgentDir(configDir, profile), "sessions"); +} + +export function piWrapperFilename(profile: ProfileConfig): string { + const slug = sanitizePathSegment(profile.id || profile.name || profile.agent) || "pi"; + return process.platform === "win32" + ? `ccr-pi-wrapper-${slug}.cmd` + : `ccr-pi-wrapper-${slug}`; +} + +export function writePiGatewayConfig( + configDir: string, + config: AppConfig, + profile: ProfileConfig, + token: string, + defaultModel: string +): PiProfileConfigWriteResult { + const profileHome = resolvePiAgentDir(configDir, profile); + const sessionDir = resolvePiSessionDir(configDir, profile); + const file = path.join(profileHome, "models.json"); + const providerId = sanitizeProviderId(profile.providerId || "") || "claude-code-router"; + const models = piProfileModels(config, defaultModel); + const model = models.includes(defaultModel) ? defaultModel : models[0] || defaultModel || "default"; + const content = `${JSON.stringify(piModelsJson(config, profile, providerId, token, models), null, 2)}\n`; + const changed = writeJsonFileIfChanged(file, content); + mkdirSync(sessionDir, { mode: privateDirMode, recursive: true }); + chmodPrivateDir(profileHome); + chmodPrivateDir(sessionDir); + return { + changed, + file, + model, + profileHome, + providerId, + sessionDir + }; +} + +function piModelsJson( + config: AppConfig, + profile: ProfileConfig, + providerId: string, + token: string, + models: string[] +): Record { + return { + providers: { + [providerId]: { + api: "openai-responses", + apiKey: token, + authHeader: true, + baseUrl: `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`, + headers: { + "x-ccr-client": "pi", + "x-ccr-profile": profile.id || profile.name || "pi" + }, + models: models.map(piModelConfig) + } + } + }; +} + +function piModelConfig(model: string): Record { + return { + id: model, + name: model + }; +} + +function piProfileModels(config: AppConfig, defaultModel: string): string[] { + return uniqueStrings([ + defaultModel, + ...buildCodexModelCatalogIds(config, defaultModel) + ].filter(Boolean)); +} + +function gatewayEndpoint(config: AppConfig): string { + const host = config.gateway.host === "0.0.0.0" || config.gateway.host === "::" ? "127.0.0.1" : config.gateway.host; + const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `http://${normalizedHost}:${config.gateway.port}`; +} + +function writeJsonFileIfChanged(file: string, content: string): boolean { + mkdirSync(path.dirname(file), { mode: privateDirMode, recursive: true }); + chmodPrivateDir(path.dirname(file)); + const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined; + if (previous === content) { + chmodPrivateFile(file); + return false; + } + writeFileSync(file, content, { encoding: "utf8", mode: privateFileMode }); + chmodPrivateFile(file); + return true; +} + +function chmodPrivateDir(dir: string): void { + if (process.platform === "win32" || !existsSync(dir)) { + return; + } + try { + chmodSync(dir, privateDirMode); + } catch { + // Best-effort permissions only; config writes should not fail after success. + } +} + +function chmodPrivateFile(file: string): void { + if (process.platform === "win32" || !existsSync(file)) { + return; + } + try { + chmodSync(file, privateFileMode); + } catch { + // Best-effort permissions only; config writes should not fail after success. + } +} + +function uniqueStrings(values: string[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + result.push(trimmed); + } + return result; +} + +function sanitizeProviderId(value: string): string { + return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function sanitizePathSegment(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function resolveUserPath(value: string): string { + const trimmed = value.trim(); + if (trimmed === "~") { + return homeDir(); + } + if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { + return path.join(homeDir(), trimmed.slice(2)); + } + return path.resolve(trimmed || "."); +} + +function homeDir(): string { + return process.env.HOME || process.env.USERPROFILE || os.homedir() || "."; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c1e2bdb0..8f804a23 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2709,7 +2709,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { ]); const env = parseStringRecord(item.env) ?? {}; const parsedSurface = parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto"; - const surface = agent === "zcode" ? "app" : parsedSurface; + const surface = agent === "zcode" ? "app" : agent === "pi" ? "cli" : parsedSurface; const botConfigId = surface !== "cli" ? readString(item.botConfigId) || readString(item.bot_config_id) || readString(item.savedBotConfigId) || readString(item.saved_bot_config_id) : ""; @@ -2741,7 +2741,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { }; } - if (agent === "grok" || agent === "kimi") { + if (agent === "grok" || agent === "kimi" || agent === "pi") { return { agent, ...(agent === "kimi" ? { availableModels } : {}), @@ -2823,6 +2823,9 @@ function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined { if (normalized === "opencode" || normalized === "open-code" || normalized === "open code") { return "opencode"; } + if (normalized === "pi" || normalized === "pi-agent" || normalized === "pi agent" || normalized === "pi-coding-agent" || normalized === "pi coding agent") { + return "pi"; + } if (normalized === "zcode" || normalized === "z-code" || normalized === "z code") { return "zcode"; } @@ -2855,6 +2858,9 @@ function defaultProfileAgentName(agent: ProfileConfig["agent"]): string { if (agent === "opencode") { return "OpenCode"; } + if (agent === "pi") { + return "Pi"; + } return "Codex"; } @@ -2863,6 +2869,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { ? "~/.zcode/cli/config.json" : agent === "opencode" ? "~/.config/opencode/opencode.jsonc" + : agent === "pi" + ? "~/.pi/agent" : "~/.codex/config.toml"; } diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 9d51fb1f..4c545747 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1278,7 +1278,7 @@ export const DEFAULT_TRAY_WIDGETS: TrayWidgetConfig[] = [ { id: "model-share", type: "model-share", variant: DEFAULT_TRAY_COMPONENT_VARIANTS.modelShare } ]; -export type ProfileClientKind = "claude-code" | "codex" | "grok" | "kimi" | "opencode" | "zcode"; +export type ProfileClientKind = "claude-code" | "codex" | "grok" | "kimi" | "opencode" | "pi" | "zcode"; export type CodexProfileConfigFormat = "legacy" | "separate_profile_files"; export type CodexRemoteFrontendMode = "app" | "cli" | "claude-code"; export type ProfileScope = "ccr" | "global" | "custom"; @@ -2067,7 +2067,7 @@ export type UsageStatsSnapshot = { totals: UsageTotals; }; -export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "opencode" | "zcode" | "claude-design" | "unknown"; +export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "opencode" | "pi" | "zcode" | "claude-design" | "unknown"; export type AgentAnalysisFilter = { agent?: AgentKind | "all"; diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 876456c3..fc0acfec 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -1607,6 +1607,14 @@ function inferAgentFromText(value: string, options: AgentTextSignalOptions = {}) ) { return "opencode"; } + if ( + normalized === "pi" || + normalized.includes("pi-coding-agent") || + normalized.includes("pi coding agent") || + normalized.includes("pi_coding_agent") + ) { + return "pi"; + } if ( normalized.includes("xai-grok-cli") || (allowStandaloneGrok && ( @@ -1704,8 +1712,18 @@ function readAgentSessionHeader(headers: Record, agen "x-z-code-session-id", "z-code-session-id" ]; + const piHeaders = [ + "x-pi-session-id", + "pi-session-id", + "x-pi-conversation-id", + "pi-conversation-id", + "x-pi-thread-id", + "pi-thread-id" + ]; const orderedHeaders = agent === "zcode" ? [...zcodeHeaders, ...codexHeaders, ...commonHeaders, ...claudeCodeHeaders] + : agent === "pi" + ? [...piHeaders, ...commonHeaders, ...codexHeaders, ...claudeCodeHeaders] : agent === "codex" ? [...codexHeaders, ...commonHeaders, ...claudeCodeHeaders] : agent === "claude-code" @@ -3051,11 +3069,11 @@ function normalizeAgentAnalysisRange(value: UsageStatsRange | undefined): UsageS } function normalizeAgentFilter(value: AgentAnalysisFilter["agent"] | undefined): AgentKind | "all" { - return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "opencode" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; } function normalizeSessionAgentFilter(value: AgentAnalysisFilter["sessionAgent"] | undefined): AgentKind | undefined { - return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "opencode" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; } function agentDisplayName(agent: AgentKind): string { @@ -3077,6 +3095,9 @@ function agentDisplayName(agent: AgentKind): string { if (agent === "opencode") { return "OpenCode"; } + if (agent === "pi") { + return "Pi"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/packages/core/src/profiles/launch-core.ts b/packages/core/src/profiles/launch-core.ts index 06da20b0..a8bd1045 100644 --- a/packages/core/src/profiles/launch-core.ts +++ b/packages/core/src/profiles/launch-core.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { AppConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app"; import { claudeCodeModelEnv as claudeCodeProfileModelEnv, claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; import { resolveOpenCodeConfigFile as resolveOpenCodeProfileConfigFile } from "@ccr/core/agents/opencode/profile-config"; +import { piWrapperFilename, resolvePiAgentDir, resolvePiSessionDir } from "@ccr/core/agents/pi/profile-config"; import { resolveZcodeConfigFile } from "@ccr/core/agents/zcode/profile-config"; export type ProfileLaunchPlan = { @@ -50,7 +51,7 @@ export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[ if (profile.agent === "zcode") { return ["app"]; } - if (profile.agent === "grok" || profile.agent === "kimi") { + if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") { return ["cli"]; } const surface = normalizeProfileSurface(profile.surface); @@ -82,7 +83,7 @@ export function shouldAutoStartProfileGateway( profile: Pick, surface: ProfileOpenSurface ): boolean { - return (profile.agent === "grok" || profile.agent === "kimi") && surface === "cli"; + return (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") && surface === "cli"; } export function profileOpenCommand( @@ -112,6 +113,9 @@ export function buildProfileLaunchPlan( if (profile.agent === "kimi") { return buildKimiLaunchPlan(configDir, profile, resolvedSurface, extraArgs); } + if (profile.agent === "pi") { + return buildPiLaunchPlan(configDir, profile, resolvedSurface, extraArgs); + } if (profile.agent === "opencode") { return buildOpenCodeLaunchPlan(configDir, profile, resolvedSurface, extraArgs); } @@ -182,6 +186,28 @@ function buildKimiLaunchPlan( }; } +function buildPiLaunchPlan( + configDir: string, + profile: ProfileConfig, + surface: ProfileOpenSurface, + extraArgs: string[] +): ProfileLaunchPlan { + if (surface !== "cli") { + throw new Error("Pi profiles only support CLI opening."); + } + return { + args: extraArgs, + command: path.join(configDir, "bin", piWrapperFilename(profile)), + env: { + CCR_PROFILE_SURFACE: "cli", + PI_CODING_AGENT_DIR: resolvePiAgentDir(configDir, profile), + PI_CODING_AGENT_SESSION_DIR: resolvePiSessionDir(configDir, profile) + }, + profile, + surface + }; +} + export function profileLaunchSpawnCommand(plan: Pick): ProfileLaunchSpawnCommand { if (!isWindowsCommandScript(plan.command)) { return { @@ -280,7 +306,7 @@ function isCodexCompatibleAgent(agent: ProfileConfig["agent"]): boolean { } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "~/.zcode/cli/config.json" : "~/.codex/config.toml"; + return agent === "zcode" ? "~/.zcode/cli/config.json" : agent === "pi" ? "~/.pi/agent" : "~/.codex/config.toml"; } function codexConfigSubdir(agent: ProfileConfig["agent"]): string { diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 2a346623..2532b827 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -23,6 +23,12 @@ import { resolveOpenCodeConfigFile, writeOpenCodeGatewayConfig } from "@ccr/core/agents/opencode/profile-config"; +import { + piWrapperFilename, + resolvePiAgentDir, + resolvePiSessionDir, + writePiGatewayConfig +} from "@ccr/core/agents/pi/profile-config"; import { CONFIGDIR } from "@ccr/core/config/constants"; import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; import { CONTEXT_ARCHIVE_MCP_SERVER_NAME, contextArchiveConfigForProfile, contextArchiveMcpServer } from "@ccr/core/gateway/context-archive"; @@ -155,11 +161,13 @@ export async function applyProfileConfig( ? applyGrokProfile(config, profile, token, appliedAt) : profile.agent === "kimi" ? applyKimiProfile(config, profile, token, appliedAt) - : profile.agent === "opencode" - ? applyOpenCodeProfile(config, profile, token, appliedAt) - : profile.agent === "zcode" - ? applyZcodeProfile(config, profile, token, appliedAt) - : applyCodexProfile(config, profile, token, appliedAt) + : profile.agent === "pi" + ? applyPiProfile(config, profile, token, appliedAt) + : profile.agent === "opencode" + ? applyOpenCodeProfile(config, profile, token, appliedAt) + : profile.agent === "zcode" + ? applyZcodeProfile(config, profile, token, appliedAt) + : applyCodexProfile(config, profile, token, appliedAt) ); } result.clients.push(...takeoverStatuses); @@ -299,11 +307,13 @@ export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileCon ? applyGrokProfile(config, profile, token, appliedAt) : profile.agent === "kimi" ? applyKimiProfile(config, profile, token, appliedAt) - : profile.agent === "opencode" - ? applyOpenCodeProfile(config, profile, token, appliedAt) - : profile.agent === "zcode" - ? applyZcodeProfile(config, profile, token, appliedAt) - : applyCodexProfile(config, profile, token, appliedAt); + : profile.agent === "pi" + ? applyPiProfile(config, profile, token, appliedAt) + : profile.agent === "opencode" + ? applyOpenCodeProfile(config, profile, token, appliedAt) + : profile.agent === "zcode" + ? applyZcodeProfile(config, profile, token, appliedAt) + : applyCodexProfile(config, profile, token, appliedAt); } function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { @@ -519,6 +529,36 @@ function applyKimiProfile(config: AppConfig, profile: ProfileConfig, token: stri } } +function applyPiProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { + const wrapperFile = piWrapperPath(profile); + if (!profile.enabled) { + return disabledStatus("pi", wrapperFile, "Pi profile is disabled."); + } + + try { + const model = normalizeClientModel(profile.model) || defaultClientModel(config); + const wrapperResult = writePiWrapper(config, profile, token, model); + return { + appliedAt, + client: "pi", + enabled: true, + message: wrapperResult.changed + ? `Pi is configured to use CCR (config ${wrapperResult.configFile}, wrapper ${wrapperResult.file}).` + : "Pi config already matches CCR.", + ok: true, + path: wrapperResult.configFile + }; + } catch (error) { + return { + client: "pi", + enabled: true, + message: formatError(error), + ok: false, + path: wrapperFile + }; + } +} + function applyOpenCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const configFile = resolveOpenCodeConfigFile(CONFIGDIR, profile); const providerId = openCodeProviderId(profile); @@ -685,9 +725,11 @@ function profilePath(profile: ProfileConfig): string { ? grokWrapperPath(profile) : profile.agent === "kimi" ? kimiWrapperPath(profile) - : profile.agent === "opencode" - ? resolveOpenCodeConfigFile(CONFIGDIR, profile) - : resolveCodexConfigFile(profile); + : profile.agent === "pi" + ? path.join(resolvePiAgentDir(CONFIGDIR, profile), "models.json") + : profile.agent === "opencode" + ? resolveOpenCodeConfigFile(CONFIGDIR, profile) + : resolveCodexConfigFile(profile); } function resolveClaudeCodeSettingsFile(profile: ProfileConfig): string { @@ -1278,6 +1320,90 @@ function isKimiManagedEnvKey(key: string): boolean { key === "CCR_PROFILE_SURFACE"; } +function writePiWrapper( + config: AppConfig, + profile: ProfileConfig, + token: string, + defaultModel: string +): { changed: boolean; configFile: string; file: string } { + const binDir = path.join(CONFIGDIR, "bin"); + mkdirSync(binDir, { mode: privateDirMode, recursive: true }); + const configResult = writePiGatewayConfig(CONFIGDIR, config, profile, token, defaultModel); + const file = piWrapperPath(profile); + const content = process.platform === "win32" + ? piWrapperCmdScript(config, profile, configResult) + : piWrapperShellScript(config, profile, configResult); + const writeResult = writeGeneratedFileIfChanged(file, content, { mode: privateExecutableMode }); + return { + changed: configResult.changed || writeResult.changed, + configFile: configResult.file, + file + }; +} + +function piWrapperPath(profile: ProfileConfig): string { + return path.join(CONFIGDIR, "bin", piWrapperFilename(profile)); +} + +function piWrapperShellScript( + config: AppConfig, + profile: ProfileConfig, + piConfig: { model: string; profileHome: string; providerId: string; sessionDir: string } +): string { + const realPi = profile.env?.CCR_PI_BIN?.trim() || profile.env?.PI_BIN?.trim() || "pi"; + const envExports = Object.entries(profileEnv(profile)) + .filter(([key]) => !isPiManagedEnvKey(key)) + .map(([key, value]) => `export ${key}=${shellQuote(value)}`); + const noProxyHosts = grokGatewayNoProxyHosts(config); + return [ + "#!/bin/sh", + ...envExports, + `if [ -n "\${NO_PROXY:-}" ]; then NO_PROXY="$NO_PROXY,${noProxyHosts}"; else NO_PROXY=${shellQuote(noProxyHosts)}; fi`, + `if [ -n "\${no_proxy:-}" ]; then no_proxy="$no_proxy,${noProxyHosts}"; else no_proxy=${shellQuote(noProxyHosts)}; fi`, + "export NO_PROXY no_proxy", + `export PI_CODING_AGENT_DIR=${shellQuote(piConfig.profileHome)}`, + `export PI_CODING_AGENT_SESSION_DIR=${shellQuote(piConfig.sessionDir)}`, + `export PI_SKIP_VERSION_CHECK=${shellQuote(profile.env?.PI_SKIP_VERSION_CHECK?.trim() || "1")}`, + "export CCR_PROFILE_SURFACE=cli", + `exec ${shellQuote(realPi)} --provider ${shellQuote(piConfig.providerId)} --model ${shellQuote(piConfig.model)} "$@"`, + "" + ].join("\n"); +} + +function piWrapperCmdScript( + config: AppConfig, + profile: ProfileConfig, + piConfig: { model: string; profileHome: string; providerId: string; sessionDir: string } +): string { + const realPi = profile.env?.CCR_PI_BIN?.trim() || profile.env?.PI_BIN?.trim() || "pi"; + const envExports = Object.entries(profileEnv(profile)) + .filter(([key]) => !isPiManagedEnvKey(key)) + .map(([key, value]) => cmdSetLine(key, value)); + const noProxyHosts = grokGatewayNoProxyHosts(config); + return [ + "@echo off", + ...envExports, + `set "NO_PROXY=%NO_PROXY%,${cmdValue(noProxyHosts)}"`, + `set "no_proxy=%no_proxy%,${cmdValue(noProxyHosts)}"`, + cmdSetLine("PI_CODING_AGENT_DIR", piConfig.profileHome), + cmdSetLine("PI_CODING_AGENT_SESSION_DIR", piConfig.sessionDir), + cmdSetLine("PI_SKIP_VERSION_CHECK", profile.env?.PI_SKIP_VERSION_CHECK?.trim() || "1"), + cmdSetLine("CCR_PROFILE_SURFACE", "cli"), + `${cmdQuote(realPi)} --provider ${cmdQuote(piConfig.providerId)} --model ${cmdQuote(piConfig.model)} %*`, + "exit /b %ERRORLEVEL%", + "" + ].join("\r\n"); +} + +function isPiManagedEnvKey(key: string): boolean { + return key === "CCR_PI_BIN" || + key === "PI_BIN" || + key === "PI_CODING_AGENT_DIR" || + key === "PI_CODING_AGENT_SESSION_DIR" || + key === "PI_SKIP_VERSION_CHECK" || + key === "CCR_PROFILE_SURFACE"; +} + function kimiProfileModels(config: AppConfig, profile: ProfileConfig): string[] { const configured = (profile.availableModels ?? []) .map((candidate) => normalizeClientModel(candidate)) @@ -2397,6 +2523,7 @@ function isManagedGeneratedBinFile(fileName: string): boolean { normalized.startsWith("ccr-claude-code-wrapper-") || normalized.startsWith("ccr-grok-cli-wrapper-") || normalized.startsWith("ccr-kimi-cli-wrapper-") || + normalized.startsWith("ccr-pi-wrapper-") || normalized.startsWith("ccr-opencode-wrapper-") || normalized.startsWith("ccr-codex-cli-stdio-"); } @@ -2436,6 +2563,9 @@ function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus if (profile.agent === "kimi") { return disabledStatus("kimi", kimiWrapperPath(profile), "Kimi CLI profile is disabled."); } + if (profile.agent === "pi") { + return disabledStatus("pi", piWrapperPath(profile), "Pi profile is disabled."); + } if (profile.agent === "opencode") { const providerId = openCodeProviderId(profile); return restoreDisabledGlobalProfile( @@ -2921,6 +3051,9 @@ function disabledProfileMessage(profile: ProfileConfig): string { if (profile.agent === "kimi") { return "Kimi CLI profile is disabled."; } + if (profile.agent === "pi") { + return "Pi profile is disabled."; + } if (profile.agent === "opencode") { return "OpenCode profile is disabled."; } @@ -3081,11 +3214,14 @@ function codexCompatibleClientName(agent: ProfileConfig["agent"]): string { if (agent === "opencode") { return "OpenCode"; } + if (agent === "pi") { + return "Pi"; + } return agent === "zcode" ? "ZCode" : "Codex"; } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "~/.zcode/cli/config.json" : "~/.codex/config.toml"; + return agent === "zcode" ? "~/.zcode/cli/config.json" : agent === "pi" ? "~/.pi/agent" : "~/.codex/config.toml"; } function codexConfigSubdir(agent: ProfileConfig["agent"]): string { diff --git a/packages/core/test/integration/profiles/profile-service.test.mjs b/packages/core/test/integration/profiles/profile-service.test.mjs index 4ff3e5e1..4bfcb05a 100644 --- a/packages/core/test/integration/profiles/profile-service.test.mjs +++ b/packages/core/test/integration/profiles/profile-service.test.mjs @@ -58,6 +58,7 @@ test("profile service cleans stale generated bin backups only", () => { "ccr-claude-code-wrapper-default.ccr-original", "ccr-codex-cli-stdio-default.ccr-original-missing", "ccr-codex-cli-middleware.js.ccr-backup-2026-01-01T00-00-00-000Z", + "ccr-pi-wrapper-default.ccr-original", "toolhub-mcp.js.ccr-backup-2026-01-01T00-00-00-000Z" ]; const keptFiles = [ @@ -1053,6 +1054,85 @@ test("profile service writes a multi-model Kimi CLI home that points inference t assert.match(legacyProfileConfigContent, /\[models\."Fusion\/catalog-context"\]\nprovider = "claude-code-router"\nmodel = "Fusion\/catalog-context"\nmax_context_size = 1050000\ncapabilities = \["tool_use", "image_in", "thinking"\]/); }); +test("profile service writes a Pi config and wrapper that points inference to CCR", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { + const profileId = "pi-gateway-test"; + const config = createDefaultAppConfig({ + generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") + }); + config.Providers = [ + { + api_base_url: "https://example.test/v1", + api_key: "provider-key", + models: ["model", "fast"], + name: "Provider", + type: "openai_responses" + } + ]; + config.preferredProvider = "Provider"; + config.APIKEY = "ccr-pi-profile-test"; + config.APIKEYS = [ + { + createdAt: "2026-01-01T00:00:00.000Z", + id: `profile:${profileId}`, + key: "ccr-pi-profile-test", + name: "Profile: Pi Gateway Test" + } + ]; + config.profile.profiles = [ + { + agent: "pi", + enabled: true, + env: { + CCR_PI_BIN: "/custom/bin/pi", + PI_CODING_AGENT_DIR: "/ignored/pi", + PI_CODING_AGENT_SESSION_DIR: "/ignored/pi/sessions", + PI_SKIP_VERSION_CHECK: "0", + USER_VALUE: "kept" + }, + id: profileId, + model: "Provider/model", + name: "Pi Gateway Test", + providerId: "ccr-pi", + scope: "ccr", + surface: "cli" + } + ]; + + const result = await applyProfileConfig(config); + assert.equal(result.clients.length, 1); + assert.equal(result.clients[0].client, "pi"); + assert.equal(result.clients[0].ok, true); + + const commandExtension = process.platform === "win32" ? ".cmd" : ""; + const wrapperFile = path.join(CONFIGDIR, "bin", `ccr-pi-wrapper-${profileId}${commandExtension}`); + const content = readFileSync(wrapperFile, "utf8"); + const profilePiHome = path.join(CONFIGDIR, "profiles", profileId, "pi"); + const profileConfigFile = path.join(profilePiHome, "models.json"); + const piConfig = JSON.parse(readFileSync(profileConfigFile, "utf8")); + const provider = piConfig.providers["ccr-pi"]; + + assert.match(content, new RegExp(`PI_CODING_AGENT_DIR.*profiles.*${profileId}.*pi`)); + assert.match(content, new RegExp(`PI_CODING_AGENT_SESSION_DIR.*profiles.*${profileId}.*pi.*sessions`)); + assert.match(content, /PI_SKIP_VERSION_CHECK.*0/); + assert.match(content, /USER_VALUE.*kept/); + assert.match(content, /NO_PROXY.*127\.0\.0\.1,localhost,::1/); + assert.match(content, /\/custom\/bin\/pi/); + assert.match(content, /--provider .*ccr-pi/); + assert.match(content, /--model .*Provider\/model/); + assert.equal(content.includes("/ignored/pi"), false); + assert.equal(provider.api, "openai-responses"); + assert.equal(provider.baseUrl, `http://127.0.0.1:${config.gateway.port}/v1`); + assert.equal(provider.apiKey, "ccr-pi-profile-test"); + assert.equal(provider.authHeader, true); + assert.deepEqual(provider.headers, { + "x-ccr-client": "pi", + "x-ccr-profile": profileId + }); + assert.ok(provider.models.some((model) => model.id === "Provider/model")); + assert.ok(provider.models.some((model) => model.id === "Provider/fast")); + assert.equal(existsSync(path.join(profilePiHome, "sessions")), true); +}); + test("profile service writes an OpenCode CLI wrapper and shared CLI/App config", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "opencode-gateway-test"; const config = createDefaultAppConfig({ diff --git a/packages/core/test/unit/agents/pi-profile-config.test.mjs b/packages/core/test/unit/agents/pi-profile-config.test.mjs new file mode 100644 index 00000000..5109f3ea --- /dev/null +++ b/packages/core/test/unit/agents/pi-profile-config.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { writePiGatewayConfig } from "@ccr/core/agents/pi/profile-config.ts"; +import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; + +test("Pi profile config writes a CCR OpenAI Responses provider", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-pi-profile-")); + try { + const config = createDefaultAppConfig({ generatedConfigFile: path.join(root, "gateway.config.json") }); + config.gateway.host = "0.0.0.0"; + config.gateway.port = 3459; + config.Providers = [ + { + api_key: "sk-test", + baseUrl: "https://api.example.test/v1", + models: ["alpha", "beta"], + name: "Example", + type: "openai_responses" + } + ]; + const profile = { + agent: "pi", + enabled: true, + id: "pi-main", + model: "Example/alpha", + name: "Pi Main", + providerId: "ccr-pi", + scope: "ccr", + surface: "cli" + }; + + const result = writePiGatewayConfig(root, config, profile, "ccr-profile-token", "Example/alpha"); + const payload = JSON.parse(readFileSync(result.file, "utf8")); + const provider = payload.providers["ccr-pi"]; + + assert.equal(result.changed, true); + assert.equal(result.model, "Example/alpha"); + assert.equal(result.providerId, "ccr-pi"); + assert.equal(result.file, path.join(root, "profiles", "pi-main", "pi", "models.json")); + assert.equal(result.profileHome, path.join(root, "profiles", "pi-main", "pi")); + assert.equal(result.sessionDir, path.join(root, "profiles", "pi-main", "pi", "sessions")); + assert.equal(provider.baseUrl, "http://127.0.0.1:3459/v1"); + assert.equal(provider.api, "openai-responses"); + assert.equal(provider.apiKey, "ccr-profile-token"); + assert.equal(provider.authHeader, true); + assert.deepEqual(provider.headers, { + "x-ccr-client": "pi", + "x-ccr-profile": "pi-main" + }); + assert.ok(provider.models.some((model) => model.id === "Example/alpha")); + assert.ok(provider.models.some((model) => model.id === "Example/beta")); + + const second = writePiGatewayConfig(root, config, profile, "ccr-profile-token", "Example/alpha"); + assert.equal(second.changed, false); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); diff --git a/packages/core/test/unit/profiles/profile-launch-core.test.mjs b/packages/core/test/unit/profiles/profile-launch-core.test.mjs index af78fc49..8c396b61 100644 --- a/packages/core/test/unit/profiles/profile-launch-core.test.mjs +++ b/packages/core/test/unit/profiles/profile-launch-core.test.mjs @@ -61,6 +61,16 @@ const kimiProfile = { surface: "cli" }; +const piProfile = { + agent: "pi", + enabled: true, + id: "pi-main", + model: "provider,model", + name: "Pi Main", + scope: "ccr", + surface: "cli" +}; + const openCodeProfile = { agent: "opencode", enabled: true, @@ -96,11 +106,13 @@ test("profile open surfaces enforce agent capabilities", () => { assert.deepEqual(profileOpenSurfaces({ ...codexProfile, agent: "zcode" }), ["app"]); assert.deepEqual(profileOpenSurfaces(grokProfile), ["cli"]); assert.deepEqual(profileOpenSurfaces(kimiProfile), ["cli"]); + assert.deepEqual(profileOpenSurfaces(piProfile), ["cli"]); assert.deepEqual(profileOpenSurfaces(openCodeProfile), ["cli", "app"]); assert.equal(resolveProfileOpenSurface(codexProfile, "app"), "app"); assert.throws(() => resolveProfileOpenSurface({ ...claudeProfile, surface: "cli" }, "app"), /does not support APP/); assert.throws(() => resolveProfileOpenSurface(grokProfile, "app"), /does not support APP/); assert.throws(() => resolveProfileOpenSurface(kimiProfile, "app"), /does not support APP/); + assert.throws(() => resolveProfileOpenSurface(piProfile, "app"), /does not support APP/); }); test("default profile command surface is CLI unless the agent is app-only", () => { @@ -113,6 +125,7 @@ test("default profile command surface is CLI unless the agent is app-only", () = test("Grok and Kimi CLI start a temporary CCR gateway when none is already running", () => { assert.equal(shouldAutoStartProfileGateway(grokProfile, "cli"), true); assert.equal(shouldAutoStartProfileGateway(kimiProfile, "cli"), true); + assert.equal(shouldAutoStartProfileGateway(piProfile, "cli"), true); assert.equal(shouldAutoStartProfileGateway(codexProfile, "cli"), false); assert.equal(shouldAutoStartProfileGateway(claudeProfile, "app"), false); }); @@ -123,6 +136,7 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { const claudePlan = buildProfileLaunchPlan(configDir, claudeProfile, "cli", ["--debug"]); const grokPlan = buildProfileLaunchPlan(configDir, grokProfile, "cli", ["--debug"]); const kimiPlan = buildProfileLaunchPlan(configDir, kimiProfile, "cli", ["--debug"]); + const piPlan = buildProfileLaunchPlan(configDir, piProfile, "cli", ["--debug"]); const openCodePlan = buildProfileLaunchPlan(configDir, openCodeProfile, "cli", ["--debug"]); assert.equal(codexPlan.surface, "app"); @@ -154,6 +168,13 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { assert.equal(path.basename(kimiPlan.command), process.platform === "win32" ? "ccr-kimi-cli-wrapper-kimi-main.cmd" : "ccr-kimi-cli-wrapper-kimi-main"); assert.equal(kimiPlan.env.CCR_PROFILE_SURFACE, "cli"); + assert.equal(piPlan.surface, "cli"); + assert.deepEqual(piPlan.args, ["--debug"]); + assert.equal(path.basename(piPlan.command), process.platform === "win32" ? "ccr-pi-wrapper-pi-main.cmd" : "ccr-pi-wrapper-pi-main"); + assert.equal(piPlan.env.CCR_PROFILE_SURFACE, "cli"); + assert.match(piPlan.env.PI_CODING_AGENT_DIR, /pi-main[\\/]pi$/); + assert.match(piPlan.env.PI_CODING_AGENT_SESSION_DIR, /pi-main[\\/]pi[\\/]sessions$/); + assert.equal(openCodePlan.surface, "cli"); assert.deepEqual(openCodePlan.args, ["--debug"]); assert.equal(path.basename(openCodePlan.command), process.platform === "win32" ? "ccr-opencode-wrapper-opencode-main.cmd" : "ccr-opencode-wrapper-opencode-main"); diff --git a/packages/ui/src/assets/agent-logos/pi.svg b/packages/ui/src/assets/agent-logos/pi.svg new file mode 100644 index 00000000..c28d6242 --- /dev/null +++ b/packages/ui/src/assets/agent-logos/pi.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 5dd1a215..7c3ba6f2 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -522,7 +522,7 @@ function ProfileAgentTabs({ return (

{profileAgentOptions.map((option) => { @@ -794,7 +794,7 @@ export function AddProfileForm({ > onChange(agent === "grok" || agent === "kimi" + onChange={(agent) => onChange(agent === "grok" || agent === "kimi" || agent === "pi" ? { agent, availableModels: [], @@ -819,7 +819,7 @@ export function AddProfileForm({ onChange({ scope: normalizeProfileScope(scope) })} options={translateOptions( - draft.agent === "grok" || draft.agent === "kimi" + draft.agent === "grok" || draft.agent === "kimi" || draft.agent === "pi" ? profileScopeOptions.filter((option) => option.value === "ccr") : profileScopeOptions, t @@ -843,7 +843,7 @@ export function AddProfileForm({ options={translateOptions( draft.agent === "zcode" ? profileSurfaceOptions.filter((option) => option.value === "app") - : draft.agent === "grok" || draft.agent === "kimi" + : draft.agent === "grok" || draft.agent === "kimi" || draft.agent === "pi" ? profileSurfaceOptions.filter((option) => option.value === "cli") : profileSurfaceOptions, t @@ -910,6 +910,16 @@ export function AddProfileForm({ onChange={(model) => onChange({ model })} /> + ) : draft.agent === "pi" ? ( + + onChange({ model })} + /> + ) : draft.agent === "kimi" ? ( <> @@ -994,7 +1004,7 @@ export function AddProfileForm({
) : null} - {draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" ? ( + {draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" && draft.agent !== "pi" ? ( <> onChange({ providerId: event.target.value })} /> @@ -1090,7 +1100,7 @@ function profileDraftValidation( issues.kimiAvailableModels = "Select at least one allowed model."; } } - if (draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi") { + if (draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" && draft.agent !== "pi") { if (!draft.providerId.trim()) { issues.providerId = "Provider ID is required."; } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 4be029d4..70a62e7a 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -320,6 +320,8 @@ export const appCopy: Record = { "Kimi CLI provider was detected, but no usable API key was found.": "Kimi CLI provider was detected, but no usable API key was found.", "Kimi model": "Kimi model", "Kimi model is required.": "Kimi model is required.", + "Pi": "Pi", + "Pi model": "Pi model", "OpenCode CLI credential was found, but no usable API key was detected.": "OpenCode CLI credential was found, but no usable API key was detected.", "OpenCode CLI login detected. Click Import to add it as a gateway provider.": "OpenCode CLI login detected. Click Import to add it as a gateway provider.", "OpenCode CLI public models detected. No login is required.": "OpenCode CLI public models detected. No login is required.", @@ -917,6 +919,8 @@ export const appCopy: Record = { "Kimi model": "Kimi 模型", "Kimi model is required.": "Kimi 模型不能为空。", "Allowed models": "允许模型", + "Pi": "Pi", + "Pi model": "Pi 模型", "OpenCode": "OpenCode", "OpenCode model": "OpenCode 模型", "CLI only": "仅 CLI", diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index 16d0a4d3..513a4d96 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -116,6 +116,7 @@ export const agentFilterOptions: Array<{ label: string; value: AgentFilterValue { label: "Grok CLI", value: "grok" }, { label: "Kimi CLI", value: "kimi" }, { label: "OpenCode", value: "opencode" }, + { label: "Pi", value: "pi" }, { label: "ZCode", value: "zcode" }, { label: "Claude Design", value: "claude-design" }, { label: "Unknown", value: "unknown" } @@ -127,6 +128,7 @@ export const profileAgentOptions: Array<{ label: string; value: ProfileConfig["a { label: "Grok CLI", value: "grok" }, { label: "Kimi CLI", value: "kimi" }, { label: "OpenCode", value: "opencode" }, + { label: "Pi", value: "pi" }, { label: "ZCode", value: "zcode" } ]; diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 4786bc0c..e8ce1b8f 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -104,6 +104,7 @@ import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; import codexLogoUrl from "@/assets/agent-logos/codex.png"; import grokLogoUrl from "@/assets/agent-logos/grok.ico"; import openCodeLogoUrl from "@/assets/agent-logos/opencode.ico"; +import piLogoUrl from "@/assets/agent-logos/pi.svg"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; @@ -808,7 +809,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs surface }; } - if (profile.agent === "grok" || profile.agent === "kimi") { + if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") { return { ...createProfileDraft(profile.agent, profile.name), availableModels: profile.agent === "kimi" @@ -859,6 +860,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { if (draft.agent === "grok") { return true; } + if (draft.agent === "pi") { + return true; + } if (draft.agent === "kimi") { return Boolean(draft.model.trim()) && draft.availableModels.length > 0; } @@ -1493,9 +1497,9 @@ export function profileSummaryItems( ]; } - if (profile.agent === "grok" || profile.agent === "kimi") { + if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") { return [ - { label: t(profile.agent === "kimi" ? "Kimi model" : "Model"), value: modelValue }, + { label: t(profile.agent === "kimi" ? "Kimi model" : profile.agent === "pi" ? "Pi model" : "Model"), value: modelValue }, ...(profile.agent === "kimi" ? [{ label: t("Allowed models"), @@ -1553,7 +1557,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro surface }; } - if (agent === "grok" || agent === "kimi") { + if (agent === "grok" || agent === "kimi" || agent === "pi") { return { agent, ...(agent === "kimi" ? { availableModels } : {}), @@ -1652,6 +1656,8 @@ export function normalizeUnknownProfileItem(value: Record, inde ? "kimi" : rawAgent === "opencode" || rawAgent === "open-code" || rawAgent === "open code" ? "opencode" + : rawAgent === "pi" || rawAgent === "pi-agent" || rawAgent === "pi agent" || rawAgent === "pi-coding-agent" || rawAgent === "pi coding agent" + ? "pi" : rawAgent === "zcode" || rawAgent === "z-code" || rawAgent === "z code" ? "zcode" : undefined; @@ -1785,6 +1791,9 @@ export function profileAgentLabel(agent: ProfileConfig["agent"]): string { if (agent === "kimi") { return "Kimi CLI"; } + if (agent === "pi") { + return "Pi"; + } if (agent === "opencode") { return "OpenCode"; } @@ -1815,7 +1824,7 @@ export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[ if (profile.agent === "zcode") { return ["app"]; } - if (profile.agent === "grok" || profile.agent === "kimi") { + if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") { return ["cli"]; } const surface = normalizeProfileSurface(profile.surface); @@ -1852,6 +1861,9 @@ export function profileAgentLogoUrl(agent: ProfileConfig["agent"]): string { if (agent === "kimi") { return moonshotProviderIconUrl; } + if (agent === "pi") { + return piLogoUrl; + } if (agent === "opencode") { return openCodeLogoUrl; } @@ -1863,11 +1875,11 @@ function normalizeCodexCompatibleAgent(agent: ProfileConfig["agent"]): "codex" | } function normalizeProfileAgent(agent: ProfileConfig["agent"]): ProfileConfig["agent"] { - return agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "grok" ? "grok" : agent === "kimi" ? "kimi" : agent === "codex" ? "codex" : "claude-code"; + return agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "pi" ? "pi" : agent === "grok" ? "grok" : agent === "kimi" ? "kimi" : agent === "codex" ? "codex" : "claude-code"; } function normalizeProfileSurfaceForAgent(agent: ProfileConfig["agent"], surface: unknown): ProfileSurface { - return agent === "zcode" ? "app" : agent === "grok" || agent === "kimi" ? "cli" : normalizeProfileSurface(surface); + return agent === "zcode" ? "app" : agent === "grok" || agent === "kimi" || agent === "pi" ? "cli" : normalizeProfileSurface(surface); } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { @@ -1875,6 +1887,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { ? "~/.zcode/cli/config.json" : agent === "opencode" ? "~/.config/opencode/opencode.jsonc" + : agent === "pi" + ? "~/.pi/agent" : "~/.codex/config.toml"; } diff --git a/packages/ui/src/pages/home/shared/usage.ts b/packages/ui/src/pages/home/shared/usage.ts index 6506ca11..9949494d 100644 --- a/packages/ui/src/pages/home/shared/usage.ts +++ b/packages/ui/src/pages/home/shared/usage.ts @@ -180,7 +180,7 @@ export function logSelectOptions(label: string, values: string[], selected: stri } export function normalizeAgentFilterValue(value: string): AgentFilterValue { - return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "opencode" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; } export function agentKindLabel(agent: AgentKind): string { @@ -202,6 +202,9 @@ export function agentKindLabel(agent: AgentKind): string { if (agent === "opencode") { return "OpenCode"; } + if (agent === "pi") { + return "Pi"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 81a932ed..4004eaf5 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -112,6 +112,28 @@ test("AddProfileForm labels Kimi CLI model fields with Kimi-specific copy", () = assert.doesNotMatch(html, /Available models/); }); +test("AddProfileForm treats Pi as a CCR-only CLI profile", () => { + const config = appConfigFixture(); + const draft = createProfileDraft("pi"); + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + + assert.equal(isProfileDraftSubmittable(draft), true); + assert.match(html, /Pi model/); + assert.doesNotMatch(html, /Provider ID/); + assert.doesNotMatch(html, /Provider name/); + assert.doesNotMatch(html, /Allowed models/); +}); + test("ProfileView renders agent profiles as compact cards with inline actions", () => { const config = appConfigFixture(); config.profile.profiles = [ @@ -183,6 +205,21 @@ test("profileSummaryItems uses Kimi-specific model labels", () => { assert.equal(items[1]?.value, "2"); }); +test("profileSummaryItems uses Pi-specific model labels", () => { + const config = appConfigFixture(); + const items = profileSummaryItems({ + agent: "pi", + enabled: true, + id: "pi-main", + model: "openai/gpt-5.2", + name: "Pi Main", + scope: "ccr", + surface: "cli" + }, config, (value) => value); + + assert.equal(items[0]?.label, "Pi model"); +}); + test("profileSummaryItems omits disabled profile properties from cards", () => { const config = appConfigFixture(); const disabledItems = profileSummaryItems({