diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index 08b7d32b..c581481c 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -16,7 +16,7 @@ export type CodexAppLookupResult = { executable?: string; }; -type CodexCompatibleAppKind = "codex" | "zcode"; +type CodexCompatibleAppKind = "codex" | "workbuddy" | "zcode"; type CodexCompatibleAppSpec = { bundledCliNames: string[]; @@ -49,9 +49,31 @@ export type CodexCompatibleAppModelCatalogWriteResult = { changed: boolean; file: string; userDataDir: string; + workbuddyModelsConfig?: WorkbuddyModelsConfigWriteResult; + workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult; +}; + +export type WorkbuddyModelsConfigWriteResult = { + changed: boolean; + file: string; + model: string; +}; + +export type WorkbuddyVirtualAuthResult = { + authFile: string; + changed: boolean; + configDir: string; + homeDir: string; + userDataDir: string; }; export const codexDesktopAppName = "ChatGPT"; +export const workbuddyDesktopAppName = "WorkBuddy AI"; + +const workbuddyVirtualAuthId = "workbuddy-desktop-ai"; +const workbuddyVirtualAuthUserId = "ccr-local-profile"; +const workbuddyVirtualAuthToken = "ccr-local-profile"; +const workbuddyVirtualAuthExpiresAt = 1_999_999_999_999; const codexAppSpec: CodexCompatibleAppSpec = { bundledCliNames: ["codex", "Codex", "OpenAI Codex"], @@ -157,6 +179,56 @@ const zcodeAppSpec: CodexCompatibleAppSpec = { ] }; +const workbuddyAppSpec: CodexCompatibleAppSpec = { + bundledCliNames: [ + "app.asar.unpacked/cli/bin/codebuddy", + "app.asar.unpacked/cli/bin/cbc", + "app.asar.unpacked/cli/bin/cbc-prewarm", + "codebuddy", + "CodeBuddy", + "workbuddy", + "WorkBuddy" + ], + defaultCliCommand: "codebuddy", + displayName: workbuddyDesktopAppName, + envPathKeys: ["CCR_WORKBUDDY_APP_PATH", "WORKBUDDY_APP_PATH", "CODEXL_WORKBUDDY_PATH"], + kind: "workbuddy", + linuxCandidates: [ + "/opt/WorkBuddy AI/workbuddy-ai", + "/opt/WorkBuddy AI/WorkBuddyAI", + "/opt/WorkBuddy/workbuddy", + "/opt/WorkBuddy/WorkBuddy", + "/usr/local/bin/workbuddy-ai", + "/usr/bin/workbuddy-ai", + "/usr/local/bin/workbuddy", + "/usr/bin/workbuddy" + ], + macAppNames: ["WorkBuddy AI.app", "WorkBuddy.app", "WorkBuddyAI.app", "Workbuddy.app"], + modelCatalogFilename: "ccr-workbuddy-model-catalog.json", + userDataDirName: "workbuddy-app-user-data", + windowsAppDirs: ["WorkBuddy AI", "WorkBuddyAI", "WorkBuddy", "Workbuddy", "CodeBuddy"], + windowsExeNames: [ + "WorkBuddy AI.exe", + "WorkBuddyAI.exe", + "WorkBuddy.exe", + "workbuddy-ai.exe", + "workbuddy.exe", + "CodeBuddy.exe", + "codebuddy.exe" + ], + windowsPackageKeywords: ["workbuddy", "workbuddyai", "workbuddy-ai", "codebuddy"], + windowsVendorDirs: ["WorkBuddy", "WorkBuddy AI", "CodeBuddy"], + windowsWhereNames: [ + "WorkBuddy AI", + "WorkBuddyAI", + "WorkBuddy", + "workbuddy-ai", + "workbuddy", + "CodeBuddy", + "codebuddy" + ] +}; + export function launchCodexAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult { return launchCodexCompatibleAppProfile(configDir, profile, codexAppSpec, config); } @@ -173,12 +245,26 @@ export function launchZcodeAppProfile(configDir: string, profile: ProfileConfig, return launchCodexCompatibleAppProfile(configDir, profile, zcodeAppSpec, config); } +export function findInstalledWorkbuddyAppExecutable(profileAppPath?: string): CodexAppLookupResult { + return findInstalledCodexCompatibleAppExecutable(workbuddyAppSpec, profileAppPath); +} + +export function launchWorkbuddyAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult { + return launchCodexCompatibleAppProfile(configDir, profile, workbuddyAppSpec, config); +} + export function refreshCodexCompatibleAppProfileFiles( configDir: string, profile: ProfileConfig, config?: AppConfig -): { modelCatalogChanged: boolean; modelCatalogFile: string; userDataDir: string } { - const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec; +): { + modelCatalogChanged: boolean; + modelCatalogFile: string; + userDataDir: string; + workbuddyModelsConfig?: WorkbuddyModelsConfigWriteResult; + workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult; +} { + const spec = codexCompatibleAppSpecForProfile(profile); if (spec.kind === "zcode" && config?.APIKEY) { writeZcodeGatewayConfig(config, profile, config.APIKEY, { backup: false }); } @@ -186,7 +272,9 @@ export function refreshCodexCompatibleAppProfileFiles( return { modelCatalogChanged: modelCatalog.changed, modelCatalogFile: modelCatalog.file, - userDataDir: modelCatalog.userDataDir + userDataDir: modelCatalog.userDataDir, + workbuddyModelsConfig: modelCatalog.workbuddyModelsConfig, + workbuddyVirtualAuth: modelCatalog.workbuddyVirtualAuth }; } @@ -195,7 +283,7 @@ export function writeCodexCompatibleAppModelCatalog( profile: ProfileConfig, config?: AppConfig ): CodexCompatibleAppModelCatalogWriteResult { - const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec; + const spec = codexCompatibleAppSpecForProfile(profile); const configFile = resolveCodexConfigFile(configDir, profile); const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile); if (spec.kind === "codex") { @@ -209,7 +297,119 @@ export function writeCodexCompatibleAppModelCatalog( if (previous !== content) { writeFileSync(file, content, "utf8"); } - return { changed: previous !== content, file, userDataDir }; + const workbuddyModelsConfig = spec.kind === "workbuddy" + ? writeWorkbuddyModelsConfig(codexHome, profile, config) + : undefined; + const workbuddyVirtualAuth = spec.kind === "workbuddy" + ? prepareWorkbuddyAppVirtualAuth(codexHome, userDataDir, profile) + : undefined; + return { + changed: previous !== content || Boolean(workbuddyModelsConfig?.changed), + file, + userDataDir, + workbuddyModelsConfig, + workbuddyVirtualAuth + }; +} + +export function writeWorkbuddyModelsConfig( + workbuddyConfigDir: string, + profile: Pick, + config?: AppConfig +): WorkbuddyModelsConfigWriteResult { + const file = path.join(workbuddyConfigDir, "models.json"); + const model = workbuddySelectedModel(profile, config); + const content = `${JSON.stringify(workbuddyModelsConfig(model, profile, config), null, 2)}\n`; + mkdirSync(path.dirname(file), { recursive: true }); + const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined; + if (previous !== content) { + writeFileSync(file, content, "utf8"); + } + return { changed: previous !== content, file, model }; +} + +function workbuddySelectedModel( + profile: Pick, + config?: CodexCompatibleAppModelCatalogConfig +): string { + const configured = profile.model?.trim(); + if (configured) { + return configured; + } + return codexCompatibleAppModelCatalog(config, undefined, "workbuddy").models[0]?.slug || "gpt-5-codex"; +} + +function workbuddyModelsConfig( + model: string, + profile: Pick, + config?: AppConfig +): Record { + const catalogItem = codexCompatibleAppModelCatalog(config, model, "workbuddy") + .models.find((item) => item.slug === model || item.id === model || item.model === model); + const vendor = workbuddyModelVendor(model, profile.providerName); + const displayName = profile.name?.trim() + ? `${profile.name.trim()} / ${model}` + : `${vendor} / ${model}`; + const workbuddyModel: Record = { + apiKey: "${CCR_PROFILE_API_KEY}", + disabled: false, + id: model, + isDefault: true, + name: displayName, + supportsImages: Boolean(catalogItem?.supports_image_detail_original), + supportsReasoning: Boolean(catalogItem?.supports_reasoning_summaries), + supportsToolCall: true, + tags: ["chat", "custom"], + url: workbuddyGatewayBaseUrl(config), + vendor + }; + const maxInputTokens = catalogItem?.context_window; + if (typeof maxInputTokens === "number" && Number.isFinite(maxInputTokens) && maxInputTokens > 0) { + workbuddyModel.maxInputTokens = Math.trunc(maxInputTokens); + } + const reasoningEfforts = catalogItem?.supported_reasoning_efforts; + if (Array.isArray(reasoningEfforts) && reasoningEfforts.length > 0) { + workbuddyModel.reasoning = { + ...(catalogItem?.default_reasoning_effort ? { defaultEffort: catalogItem.default_reasoning_effort } : {}), + supportedEfforts: reasoningEfforts + }; + } + return { + availableModels: [model], + models: [workbuddyModel] + }; +} + +function workbuddyModelVendor(model: string, providerName?: string): string { + const slashIndex = model.indexOf("/"); + if (slashIndex > 0) { + const provider = model.slice(0, slashIndex).trim(); + if (provider) { + return provider; + } + } + return providerName?.trim() || "Claude Code Router"; +} + +function workbuddyGatewayBaseUrl(config?: Pick): string { + if (!config?.gateway) { + return "${CCR_WORKBUDDY_GATEWAY_BASE_URL}"; + } + const host = config.gateway.host === "0.0.0.0" || config.gateway.host === "::" + ? "127.0.0.1" + : config.gateway.host || "127.0.0.1"; + const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `http://${formattedHost}:${config.gateway.port}/v1`; +} + +function codexCompatibleAppSpecForProfile(profile: Pick): CodexCompatibleAppSpec { + if (profile.agent === "zcode") { + return zcodeAppSpec; + } + if (profile.agent === "workbuddy") { + return workbuddyAppSpec; + } + return codexAppSpec; } function codexCompatibleAppModelCatalogJson( @@ -366,7 +566,7 @@ function launchCodexCompatibleAppProfile( const configFile = resolveCodexConfigFile(configDir, profile); const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile); - const { modelCatalogFile, userDataDir } = refreshCodexCompatibleAppProfileFiles(configDir, profile, config); + const { modelCatalogFile, userDataDir, workbuddyVirtualAuth } = refreshCodexCompatibleAppProfileFiles(configDir, profile, config); if (spec.kind === "codex") prepareCodexAppCdpUserDataDir(userDataDir); const appEnv: Record = { @@ -375,7 +575,8 @@ function launchCodexCompatibleAppProfile( ...codexProfileEnv(profile, lookup.executable, spec), CODEXL_PROFILE_SURFACE: "app", CCR_PROFILE_SURFACE: "app", - ...codexAppAgentEnv(spec, plan.command, codexHome, userDataDir, modelCatalogFile), + ...codexAppAgentEnv(spec, plan.command, codexHome, userDataDir, modelCatalogFile, workbuddyVirtualAuth), + ...codexCompatibleAppGatewayEnv(spec, config), ELECTRON_ENABLE_LOGGING: "1" }; const env: NodeJS.ProcessEnv = { @@ -406,6 +607,19 @@ function launchCodexCompatibleAppProfile( }; } +function codexCompatibleAppGatewayEnv(spec: CodexCompatibleAppSpec, config?: AppConfig): Record { + if (spec.kind !== "workbuddy" || !config) { + return {}; + } + const baseUrl = workbuddyGatewayBaseUrl(config); + const apiKey = config.APIKEY?.trim() || ""; + return { + ...(apiKey ? { CCR_PROFILE_API_KEY: apiKey, CODEXL_PROFILE_API_KEY: apiKey } : {}), + CCR_WORKBUDDY_GATEWAY_BASE_URL: baseUrl, + CODEXL_WORKBUDDY_GATEWAY_BASE_URL: baseUrl + }; +} + function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: CodexCompatibleAppSpec): Record { const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; const realCliPath = profile.codexCliPath?.trim() || bundledCodexCliPath(appExecutable, spec) || spec.defaultCliCommand; @@ -424,7 +638,7 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co CODEXL_ZCODE_WORKSPACE_NAME: profile.name || providerId }; } - return { + const codexEnv = { ...(profile.model.trim() ? { CCR_CODEX_MODEL: profile.model.trim() } : {}), ...(process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG?.trim() ? { CCR_CODEX_CLI_MIDDLEWARE_LOG: process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG.trim() } @@ -442,6 +656,22 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co CODEXL_CODEX_WORKSPACE_NAME: profile.name || providerId, CODEXL_REAL_CODEX_CLI_PATH: realCliPath }; + if (spec.kind !== "workbuddy") { + return codexEnv; + } + return { + ...codexEnv, + ...(profile.model.trim() ? { CCR_WORKBUDDY_MODEL: profile.model.trim() } : {}), + CCR_REAL_WORKBUDDY_CLI_PATH: realCliPath, + CCR_WORKBUDDY_MODEL_PROVIDER: providerId, + CCR_WORKBUDDY_PROFILE: providerId, + CCR_WORKBUDDY_REMOTE_FRONTEND_MODE: remoteFrontendMode, + CODEXL_REAL_WORKBUDDY_CLI_PATH: realCliPath, + CODEXL_WORKBUDDY_CORE_MODE: remoteFrontendMode, + CODEXL_WORKBUDDY_MODEL_PROVIDER: providerId, + CODEXL_WORKBUDDY_PROFILE: providerId, + CODEXL_WORKBUDDY_WORKSPACE_NAME: profile.name || providerId + }; } function codexSharedChatGptAuthEnv(): Record { @@ -468,28 +698,136 @@ function codexAppAgentEnv( launcher: string, home: string, userDataDir: string, - modelCatalogFile: string + modelCatalogFile: string, + workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult ): Record { - return spec.kind === "zcode" - ? { + if (spec.kind === "zcode") { + return { CCR_ZCODE_MODEL_CATALOG_FILE: modelCatalogFile, CODEXL_ZCODE_MODEL_CATALOG_FILE: modelCatalogFile, ZCODE_CLI_PATH: launcher, ZCODE_ELECTRON_USER_DATA_PATH: userDataDir, ZCODE_HOME: home, ZCODE_STORAGE_DIR: home - } - : { - CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile, - CODEX_CLI_PATH: launcher, - CODEX_ELECTRON_USER_DATA_PATH: userDataDir, - CODEX_HOME: home, - CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile }; + } + const codexEnv = { + CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile, + CODEX_CLI_PATH: launcher, + CODEX_ELECTRON_USER_DATA_PATH: userDataDir, + CODEX_HOME: home, + CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile + }; + if (spec.kind !== "workbuddy") { + return codexEnv; + } + return { + ...codexEnv, + CCR_WORKBUDDY_MODEL_CATALOG_FILE: modelCatalogFile, + ...(workbuddyVirtualAuth ? workbuddyVirtualAuthEnv(workbuddyVirtualAuth) : {}), + CODEBUDDY_CLI_PATH: launcher, + CODEBUDDY_CONFIG_DIR: home, + CODEBUDDY_ELECTRON_USER_DATA_PATH: userDataDir, + CODEBUDDY_HOME: home, + CODEXL_WORKBUDDY_MODEL_CATALOG_FILE: modelCatalogFile, + WORKBUDDY_CLI_PATH: launcher, + WORKBUDDY_CONFIG_DIR: home, + WORKBUDDY_ELECTRON_USER_DATA_PATH: userDataDir, + WORKBUDDY_HOME: home + }; +} + +export function prepareWorkbuddyAppVirtualAuth( + configDir: string, + userDataDir: string, + profile: Pick +): WorkbuddyVirtualAuthResult { + const homeDir = workbuddyAppVirtualHomeDir(configDir); + const authFile = workbuddyAppVirtualAuthFile(homeDir); + const account = workbuddyVirtualAuthAccount(profile); + const session = { + account, + accounts: [account], + allAccounts: [account], + auth: { + accessToken: workbuddyVirtualAuthToken, + domain: "www.workbuddy.ai", + expiresAt: workbuddyVirtualAuthExpiresAt, + expiresIn: workbuddyVirtualAuthExpiresAt, + lastRefreshTime: Date.now(), + refreshExpiresAt: workbuddyVirtualAuthExpiresAt, + refreshExpiresIn: workbuddyVirtualAuthExpiresAt, + refreshToken: "", + scope: "", + tokenType: "Bearer" + } + }; + const content = `${JSON.stringify(session, null, 2)}\n`; + mkdirSync(path.dirname(authFile), { recursive: true }); + const logoutMarker = `${authFile}.logged-out`; + if (existsSync(logoutMarker)) { + unlinkSync(logoutMarker); + } + const previous = existsSync(authFile) ? readFileSync(authFile, "utf8") : undefined; + if (previous !== content) { + writeFileSync(authFile, content, "utf8"); + } + return { + authFile, + changed: previous !== content, + configDir, + homeDir, + userDataDir + }; +} + +function workbuddyVirtualAuthAccount(profile: Pick): Record { + return { + avatarUrl: "", + lastLogin: true, + nickname: profile.name?.trim() || "Claude Code Router", + pluginEnabled: true, + type: "personal", + uid: workbuddyVirtualAuthUserId + }; +} + +function workbuddyVirtualAuthEnv(result: WorkbuddyVirtualAuthResult): Record { + return { + APPDATA: path.join(result.homeDir, "AppData", "Roaming"), + CCR_WORKBUDDY_VIRTUAL_AUTH_FILE: result.authFile, + CODEXL_WORKBUDDY_VIRTUAL_AUTH_FILE: result.authFile, + HOME: result.homeDir, + LOCALAPPDATA: path.join(result.homeDir, "AppData", "Local"), + USERPROFILE: result.homeDir, + WORKBUDDY_USER_DATA_DIR: result.userDataDir + }; +} + +function workbuddyAppVirtualHomeDir(configDir: string): string { + return path.join(configDir, ".claude-code-router", "workbuddy-app-home"); +} + +function workbuddyAppVirtualAuthFile(homeDir: string): string { + return path.join(workbuddySharedAuthDir(homeDir), `${workbuddyVirtualAuthId}.info`); +} + +function workbuddySharedAuthDir(homeDir: string): string { + if (process.platform === "darwin") { + return path.join(homeDir, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth"); + } + if (process.platform === "win32") { + return path.join(homeDir, "AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth"); + } + return path.join(homeDir, ".local", "share", "CodeBuddyExtension", "Data", "Public", "auth"); } function sanitizeCodexCompatibleAppEnv(env: NodeJS.ProcessEnv, kind: CodexCompatibleAppKind): void { - const blockedPrefixes = kind === "zcode" ? ["CCR_CODEX_", "CODEXL_CODEX_"] : ["CCR_ZCODE_", "CODEXL_ZCODE_"]; + const blockedPrefixes = kind === "zcode" + ? ["CCR_CODEX_", "CODEXL_CODEX_", "CCR_WORKBUDDY_", "CODEXL_WORKBUDDY_"] + : kind === "workbuddy" + ? ["CCR_ZCODE_", "CODEXL_ZCODE_"] + : ["CCR_ZCODE_", "CODEXL_ZCODE_", "CCR_WORKBUDDY_", "CODEXL_WORKBUDDY_"]; for (const key of Object.keys(env)) { if (blockedPrefixes.some((prefix) => key.startsWith(prefix))) { delete env[key]; @@ -499,12 +837,28 @@ function sanitizeCodexCompatibleAppEnv(env: NodeJS.ProcessEnv, kind: CodexCompat delete env.CODEX_CLI_PATH; delete env.CODEX_ELECTRON_USER_DATA_PATH; delete env.CODEX_HOME; + delete env.WORKBUDDY_CLI_PATH; + delete env.WORKBUDDY_ELECTRON_USER_DATA_PATH; + delete env.WORKBUDDY_HOME; + delete env.CODEBUDDY_CLI_PATH; + delete env.CODEBUDDY_CONFIG_DIR; + delete env.CODEBUDDY_ELECTRON_USER_DATA_PATH; + delete env.CODEBUDDY_HOME; return; } delete env.ZCODE_CLI_PATH; delete env.ZCODE_ELECTRON_USER_DATA_PATH; delete env.ZCODE_HOME; delete env.ZCODE_STORAGE_DIR; + if (kind === "codex") { + delete env.WORKBUDDY_CLI_PATH; + delete env.WORKBUDDY_ELECTRON_USER_DATA_PATH; + delete env.WORKBUDDY_HOME; + delete env.CODEBUDDY_CLI_PATH; + delete env.CODEBUDDY_CONFIG_DIR; + delete env.CODEBUDDY_ELECTRON_USER_DATA_PATH; + delete env.CODEBUDDY_HOME; + } } function bundledCodexCliPath(appExecutable: string, spec: CodexCompatibleAppSpec): string | undefined { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 613521c4..b9acb290 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -740,7 +740,7 @@ function sanitizeProfileConfigForDisk(profile: AppConfig["profile"]): AppConfig[ ...synchronizedProfile, codex, profiles: synchronizedProfile.profiles.map((profileItem) => { - if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "kilo" && profileItem.agent !== "zcode") { + if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "kilo" && profileItem.agent !== "workbuddy" && profileItem.agent !== "zcode") { return profileItem; } const { @@ -3568,7 +3568,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" || agent === CLAUDE_DESIGN_PLUGIN_ID + const surface = agent === "workbuddy" || agent === "zcode" || agent === CLAUDE_DESIGN_PLUGIN_ID ? "app" : agent === "pi" || agent === "kilo" ? "cli" @@ -3636,7 +3636,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { } const appPath = readProfileAppPath(item, agent); - const showAllSessions = agent === "zcode" || agent === "opencode" || agent === "kilo" + const showAllSessions = agent === "zcode" || agent === "opencode" || agent === "kilo" || agent === "workbuddy" ? false : typeof item.showAllSessions === "boolean" ? item.showAllSessions @@ -3720,7 +3720,9 @@ function readProfileAppPath(item: Record, agent: ProfileConfig[ ? readString(item.chatgptAppPath) || readString(item.chatgpt_app_path) || readString(item.codexAppPath) || readString(item.codex_app_path) : agent === "opencode" ? readString(item.openCodeAppPath) || readString(item.opencodeAppPath) || readString(item.opencode_app_path) - : readString(item.zcodeAppPath) || readString(item.zcode_app_path)); + : agent === "workbuddy" + ? readString(item.workbuddyAppPath) || readString(item.workbuddy_app_path) || readString(item.workBuddyAppPath) || readString(item.work_buddy_app_path) + : readString(item.zcodeAppPath) || readString(item.zcode_app_path)); } function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined { @@ -3749,6 +3751,9 @@ function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined { if (normalized === "pi" || normalized === "pi-agent" || normalized === "pi agent" || normalized === "pi-coding-agent" || normalized === "pi coding agent") { return "pi"; } + if (normalized === "workbuddy" || normalized === "work-buddy" || normalized === "work buddy" || normalized === "workbuddy-agent" || normalized === "workbuddy agent") { + return "workbuddy"; + } if (normalized === "zcode" || normalized === "z-code" || normalized === "z code") { return "zcode"; } @@ -3790,6 +3795,9 @@ function defaultProfileAgentName(agent: ProfileConfig["agent"]): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === CLAUDE_DESIGN_PLUGIN_ID) { return "Claude Design"; } @@ -3803,6 +3811,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { ? "~/.config/opencode/opencode.jsonc" : agent === "kilo" ? "~/.config/kilo/kilo.jsonc" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === CLAUDE_DESIGN_PLUGIN_ID diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 2539d605..d01ff4ff 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -11,6 +11,7 @@ export type AppInfo = { platform: string; usageDbFile: string; version: string; + workbuddyAppPath?: string; }; export type AppDataExportResult = { @@ -1401,7 +1402,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" | "kilo" | "opencode" | "pi" | "zcode" | "claude-design"; +export type ProfileClientKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "workbuddy" | "zcode" | "claude-design"; export type CodexProfileConfigFormat = "legacy" | "separate_profile_files"; export type CodexRemoteFrontendMode = "app" | "cli" | "claude-code"; export type ProfileScope = "ccr" | "global" | "custom"; @@ -2190,7 +2191,7 @@ export type UsageStatsSnapshot = { totals: UsageTotals; }; -export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "zcode" | "claude-design" | "unknown"; +export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "workbuddy" | "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 052c21d9..8acbab38 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -1651,6 +1651,14 @@ function inferAgentFromText(value: string, options: AgentTextSignalOptions = {}) ) { return "kilo"; } + if ( + normalized.includes("workbuddy") || + normalized.includes("work-buddy") || + normalized.includes("work buddy") || + /(^|[^a-z0-9])workbuddy([/_\s-]|$)/.test(normalized) + ) { + return "workbuddy"; + } if ( normalized.includes("openai-codex") || normalized.includes("codex_cli") || @@ -1727,6 +1735,16 @@ function readAgentSessionHeader(headers: Record, agen "x-z-code-session-id", "z-code-session-id" ]; + const workbuddyHeaders = [ + "x-workbuddy-session-id", + "workbuddy-session-id", + "x-workbuddy-conversation-id", + "workbuddy-conversation-id", + "x-workbuddy-thread-id", + "workbuddy-thread-id", + "x-work-buddy-session-id", + "work-buddy-session-id" + ]; const piHeaders = [ "x-pi-session-id", "pi-session-id", @@ -1737,6 +1755,8 @@ function readAgentSessionHeader(headers: Record, agen ]; const orderedHeaders = agent === "zcode" ? [...zcodeHeaders, ...codexHeaders, ...commonHeaders, ...claudeCodeHeaders] + : agent === "workbuddy" + ? [...workbuddyHeaders, ...codexHeaders, ...commonHeaders, ...claudeCodeHeaders] : agent === "pi" ? [...piHeaders, ...commonHeaders, ...codexHeaders, ...claudeCodeHeaders] : agent === "codex" @@ -3150,11 +3170,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 === "kilo" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "workbuddy" || 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 === "kilo" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "workbuddy" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; } function agentDisplayName(agent: AgentKind): string { @@ -3182,6 +3202,9 @@ function agentDisplayName(agent: AgentKind): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/packages/core/src/profiles/launch-core.ts b/packages/core/src/profiles/launch-core.ts index 0035564b..1ac60d2a 100644 --- a/packages/core/src/profiles/launch-core.ts +++ b/packages/core/src/profiles/launch-core.ts @@ -49,7 +49,7 @@ export function findProfileForOpen(config: Pick, profileRe } export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[] { - if (profile.agent === "zcode" || profile.agent === "claude-design") { + if (profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design") { return ["app"]; } if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi" || profile.agent === "kilo") { @@ -77,7 +77,7 @@ export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: Prof } export function defaultProfileOpenSurface(profile: Pick): ProfileOpenSurface { - return profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"; + return profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"; } export function shouldAutoStartProfileGateway( @@ -334,12 +334,14 @@ function buildClaudeCodeLaunchPlan( } function isCodexCompatibleAgent(agent: ProfileConfig["agent"]): boolean { - return agent === "codex" || agent === "zcode"; + return agent === "codex" || agent === "workbuddy" || agent === "zcode"; } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { return agent === "zcode" ? "~/.zcode/cli/config.json" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === "claude-design" @@ -348,7 +350,7 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { } function codexConfigSubdir(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "zcode" : "codex"; + return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : "codex"; } function claudeCodeWrapperFilename(profile: ProfileConfig): string { diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index e1c1db2b..98956fec 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -7,7 +7,7 @@ import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "@ccr/core/agents/claude-app/gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch"; import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; -import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch"; +import { codexDesktopAppName, launchCodexAppProfile, launchWorkbuddyAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles, workbuddyDesktopAppName } from "@ccr/core/agents/codex/app-launch"; import { CodexAppMediaPreviewBridge, shouldEnableCodexMediaPreviewBridge } from "@ccr/core/agents/codex/media-preview-bridge"; import { findRunningOpenCodeAppPid, launchOpenCodeAppProfile, openCodeAppLaunchSignature } from "@ccr/core/agents/opencode/app-launch"; import { writeOpenCodeGatewayConfig } from "@ccr/core/agents/opencode/profile-config"; @@ -127,7 +127,7 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen if (profile.agent === "claude-code" && surface === "app") { return openClaudeAppProfile(config, profile); } - if ((profile.agent === "codex" || profile.agent === "zcode") && surface === "app") { + if ((profile.agent === "codex" || profile.agent === "workbuddy" || profile.agent === "zcode") && surface === "app") { return await openCodexAppProfile(config, profile); } if (profile.agent === "opencode" && surface === "app") { @@ -260,7 +260,11 @@ async function openOpenCodeAppProfile(config: AppConfig, profile: ReturnType): Promise { - const appName = profile.agent === "zcode" ? "ZCode App" : codexDesktopAppName; + const appName = profile.agent === "zcode" + ? "ZCode App" + : profile.agent === "workbuddy" + ? workbuddyDesktopAppName + : codexDesktopAppName; const profileGatewayConfig = await ensureProfileGateway(config, profile, appName); const existing = runningProfileApp(profile.id, "app"); if (existing) { @@ -297,7 +301,9 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType `export ${key}=${shellQuote(value)}`); const botEnvExports = shellBotGatewayEnvExports(config, profile); const agentEnvExports = profile.agent === "zcode" @@ -2082,6 +2083,14 @@ function codexMiddlewareShellScript( ] : [ `export CODEX_HOME=${shellQuote(resolvedCodexHome)}`, + ...(profile.agent === "workbuddy" + ? [ + `export WORKBUDDY_HOME=${shellQuote(resolvedCodexHome)}`, + `export WORKBUDDY_CONFIG_DIR=${shellQuote(resolvedCodexHome)}`, + `export CODEBUDDY_CONFIG_DIR=${shellQuote(resolvedCodexHome)}`, + `export CODEBUDDY_HOME=${shellQuote(resolvedCodexHome)}` + ] + : []), "if [ -z \"${CCR_REAL_CODEX_CLI_PATH:-}\" ]; then", ` CCR_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`, "fi", @@ -2190,7 +2199,7 @@ function codexMiddlewareCmdScript( const codexHome = profile.codexHome?.trim() || defaultCodexCompatibleHome(profile.agent, values.configFile); const resolvedCodexHome = resolveUserPath(codexHome); const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode); - const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface); + const surface = profile.agent === "workbuddy" || profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface); const workspaceName = profile.name || values.providerId; const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => cmdSetLine(key, value)); const botEnvExports = cmdBotGatewayEnvExports(config, profile); @@ -2216,6 +2225,14 @@ function codexMiddlewareCmdScript( ] : [ cmdSetLine("CODEX_HOME", resolvedCodexHome), + ...(profile.agent === "workbuddy" + ? [ + cmdSetLine("WORKBUDDY_HOME", resolvedCodexHome), + cmdSetLine("WORKBUDDY_CONFIG_DIR", resolvedCodexHome), + cmdSetLine("CODEBUDDY_CONFIG_DIR", resolvedCodexHome), + cmdSetLine("CODEBUDDY_HOME", resolvedCodexHome) + ] + : []), `if not defined CCR_REAL_CODEX_CLI_PATH ${cmdSetLine("CCR_REAL_CODEX_CLI_PATH", codexCli)}`, "if not defined CCR_BUNDLED_CODEX_CLI_PATH set \"CCR_BUNDLED_CODEX_CLI_PATH=%CCR_REAL_CODEX_CLI_PATH%\"", cmdSetLine("CCR_CODEX_PROFILE", values.providerId), @@ -2716,10 +2733,11 @@ function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus ); } const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + const clientName = codexCompatibleClientName(profile.agent); return restoreDisabledGlobalProfile( profile, resolveCodexConfigFile(profile), - "Codex profile is disabled.", + `${clientName} profile is disabled.`, (content) => isManagedCodexConfigContent(content, providerId) ); } @@ -2745,11 +2763,12 @@ export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]): } const codexProfiles = profiles.filter((profile) => profile.agent === "codex"); if (codexProfiles.length > 0 && !codexProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + const providerIds = codexCompatibleProviderIds(codexProfiles); for (const file of uniqueResolvedPaths([ ...codexProfiles.map(globalCodexConfigCandidate) ])) { const restoreResult = restoreGlobalConfigFile(file, { - isManagedContent: (content) => isManagedCodexConfigContent(content, "claude-code-router"), + isManagedContent: (content) => providerIds.some((providerId) => isManagedCodexConfigContent(content, providerId)), mode: privateFileMode }); if (restoreResult.changed || restoreResult.missingBackup) { @@ -2757,6 +2776,21 @@ export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]): } } } + const workbuddyProfiles = profiles.filter((profile) => profile.agent === "workbuddy"); + if (workbuddyProfiles.length > 0 && !workbuddyProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + const providerIds = codexCompatibleProviderIds(workbuddyProfiles); + for (const file of uniqueResolvedPaths([ + ...workbuddyProfiles.map(globalCodexConfigCandidate) + ])) { + const restoreResult = restoreGlobalConfigFile(file, { + isManagedContent: (content) => providerIds.some((providerId) => isManagedCodexConfigContent(content, providerId)), + mode: privateFileMode + }); + if (restoreResult.changed || restoreResult.missingBackup) { + statuses.push(inactiveGlobalCleanupStatus("workbuddy", file, restoreResult)); + } + } + } const openCodeProfiles = profiles.filter((profile) => profile.agent === "opencode"); if (openCodeProfiles.length > 0 && !openCodeProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { const providerIds = [...new Set([ @@ -2823,7 +2857,14 @@ function globalCodexConfigCandidate(profile: ProfileConfig): string { if (codexHome) { return path.join(resolveUserPath(codexHome), "config.toml"); } - return profile.configFile || "~/.codex/config.toml"; + return profile.configFile || defaultCodexConfigFile(profile.agent); +} + +function codexCompatibleProviderIds(profiles: ProfileConfig[]): string[] { + return [...new Set([ + "claude-code-router", + ...profiles.map((profile) => sanitizeCodexProviderId(profile.providerId || "")).filter(Boolean) + ])]; } function globalOpenCodeConfigCandidate(profile: ProfileConfig): string { @@ -2931,7 +2972,7 @@ function readGlobalProfileTakeoverMarker(): GlobalProfileTakeoverRecord[] { } return parsed.profiles.filter((value): value is GlobalProfileTakeoverRecord => isRecord(value) && - (value.agent === "claude-code" || value.agent === "codex" || value.agent === "opencode" || value.agent === "kilo" || value.agent === "zcode") && + (value.agent === "claude-code" || value.agent === "codex" || value.agent === "opencode" || value.agent === "kilo" || value.agent === "workbuddy" || value.agent === "zcode") && typeof value.id === "string" && typeof value.name === "string" ); @@ -3381,6 +3422,9 @@ function codexCompatibleClientName(agent: ProfileConfig["agent"]): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "claude-design") { return "Claude Design"; } @@ -3392,6 +3436,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { ? "~/.zcode/cli/config.json" : agent === "kilo" ? "~/.config/kilo/kilo.jsonc" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === "claude-design" @@ -3400,11 +3446,11 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { } function codexConfigSubdir(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "zcode" : "codex"; + return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : "codex"; } function defaultCodexCliCommand(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "zcode" : "codex"; + return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "codebuddy" : "codex"; } function defaultCodexCompatibleHome(agent: ProfileConfig["agent"], configFile: string): string { diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 5a590d0f..1d51496f 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -10,7 +10,7 @@ import { loadOnboardingFinished, markOnboardingFinished } from "@ccr/core/config import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; -import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; +import { findInstalledCodexAppExecutable, findInstalledWorkbuddyAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; import { @@ -516,6 +516,7 @@ function logProfileApplyResult(result: ProfileApplyResult): void { function getCliAppInfo(): AppInfo { const chatgptAppPath = findInstalledCodexAppExecutable().executable; const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable; + const workbuddyAppPath = findInstalledWorkbuddyAppExecutable().executable; return { ...(chatgptAppPath ? { chatgptAppPath } : {}), configDbFile: APP_CONFIG_DB_FILE, @@ -528,7 +529,8 @@ function getCliAppInfo(): AppInfo { platform: process.platform, requestLogsDbFile: REQUEST_LOGS_DB_FILE, usageDbFile: USAGE_DB_FILE, - version: packageJson.version + version: packageJson.version, + ...(workbuddyAppPath ? { workbuddyAppPath } : {}) }; } diff --git a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs index 0561a192..85b256f2 100644 --- a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs +++ b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs @@ -7,6 +7,7 @@ import { codexDesktopAppName, codexSharedChatGptAuthEnvForTest, findInstalledCodexAppExecutable, + findInstalledWorkbuddyAppExecutable, removeLegacyCodexVirtualAuthMarker, writeCodexCompatibleAppModelCatalog } from "@ccr/core/agents/codex/app-launch.ts"; @@ -288,6 +289,114 @@ test("ChatGPT profile appPath overrides process env discovery", () => { } }); +test("WorkBuddy AI app path override discovers the Electron executable", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-")); + try { + let configuredPath; + let expectedExecutable; + if (process.platform === "darwin") { + configuredPath = path.join(root, "WorkBuddy AI.app"); + const macosDir = path.join(configuredPath, "Contents", "MacOS"); + mkdirSync(macosDir, { recursive: true }); + expectedExecutable = path.join(macosDir, "Electron"); + writeFileSync(expectedExecutable, ""); + writeFileSync( + path.join(configuredPath, "Contents", "Info.plist"), + "CFBundleExecutableElectron" + ); + } else { + expectedExecutable = path.join(root, process.platform === "win32" ? "WorkBuddyAI.exe" : "workbuddy-ai"); + configuredPath = expectedExecutable; + writeFileSync(expectedExecutable, ""); + } + + const result = findInstalledWorkbuddyAppExecutable(configuredPath); + assert.equal(result.executable, expectedExecutable); + assert.equal(result.checked[0], configuredPath); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("WorkBuddy AI app profile writes the virtual desktop auth session", () => { + const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-auth-")); + try { + const profile = { + agent: "workbuddy", + enabled: true, + id: "workbuddy-main", + model: "Codex API/gpt-5-codex", + name: "WorkBuddy Main", + providerId: "claude-code-router", + scope: "ccr", + surface: "app" + }; + + const workbuddyConfig = { + gateway: { + enabled: true, + host: "0.0.0.0", + mode: "process", + port: 48765 + }, + Providers: [{ + modelMetadata: { + "gpt-5-codex": { + contextWindow: 272_000, + defaultReasoningLevel: "medium", + supportedReasoningLevels: [ + { description: "Medium", effort: "medium" }, + { description: "High", effort: "high" } + ], + supportsReasoningSummaries: true + } + }, + models: ["gpt-5-codex"], + name: "Codex API", + type: "openai_responses" + }] + }; + const result = writeCodexCompatibleAppModelCatalog(configDir, profile, workbuddyConfig); + + assert.equal(path.basename(result.file), "ccr-workbuddy-model-catalog.json"); + assert.ok(result.workbuddyModelsConfig); + assert.equal(path.basename(result.workbuddyModelsConfig.file), "models.json"); + assert.equal(result.workbuddyModelsConfig.model, "Codex API/gpt-5-codex"); + const modelsConfig = JSON.parse(readFileSync(result.workbuddyModelsConfig.file, "utf8")); + assert.deepEqual(modelsConfig.availableModels, ["Codex API/gpt-5-codex"]); + assert.equal(modelsConfig.models.length, 1); + assert.equal(modelsConfig.models[0].id, "Codex API/gpt-5-codex"); + assert.equal(modelsConfig.models[0].vendor, "Codex API"); + assert.equal(modelsConfig.models[0].url, "http://127.0.0.1:48765/v1"); + assert.equal(modelsConfig.models[0].apiKey, "${CCR_PROFILE_API_KEY}"); + assert.equal(modelsConfig.models[0].supportsToolCall, true); + assert.equal(modelsConfig.models[0].supportsReasoning, true); + assert.equal(modelsConfig.models[0].maxInputTokens, 272_000); + assert.deepEqual(modelsConfig.models[0].reasoning.supportedEfforts, ["medium", "high"]); + + assert.ok(result.workbuddyVirtualAuth); + assert.equal(path.basename(result.workbuddyVirtualAuth.authFile), "workbuddy-desktop-ai.info"); + assert.ok(result.workbuddyVirtualAuth.authFile.includes(path.join("CodeBuddyExtension", "Data", "Public", "auth"))); + assert.equal(existsSync(result.workbuddyVirtualAuth.authFile), true); + + const session = JSON.parse(readFileSync(result.workbuddyVirtualAuth.authFile, "utf8")); + assert.equal(session.auth.accessToken, "ccr-local-profile"); + assert.equal(session.auth.domain, "www.workbuddy.ai"); + assert.equal(session.auth.refreshToken, ""); + assert.ok(Date.now() - session.auth.lastRefreshTime < 5_000); + assert.equal(session.account.uid, "ccr-local-profile"); + assert.equal(session.account.nickname, "WorkBuddy Main"); + assert.equal(session.account.type, "personal"); + assert.deepEqual(session.accounts, [session.account]); + assert.deepEqual(session.allAccounts, [session.account]); + + const second = writeCodexCompatibleAppModelCatalog(configDir, profile, workbuddyConfig); + assert.equal(second.workbuddyModelsConfig.changed, false); + } finally { + rmSync(configDir, { force: true, recursive: true }); + } +}); + function withPlatform(platform, callback) { const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { 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 4eef4995..a98d69dd 100644 --- a/packages/core/test/unit/profiles/profile-launch-core.test.mjs +++ b/packages/core/test/unit/profiles/profile-launch-core.test.mjs @@ -94,6 +94,17 @@ const kiloProfile = { surface: "cli" }; +const workbuddyProfile = { + agent: "workbuddy", + enabled: true, + id: "workbuddy-main", + model: "provider,model", + name: "Workbuddy Main", + providerId: "claude-code-router", + scope: "ccr", + surface: "app" +}; + const claudeDesignProfile = { agent: "claude-design", enabled: true, @@ -130,6 +141,7 @@ test("profile open surfaces enforce agent capabilities", () => { assert.deepEqual(profileOpenSurfaces(kimiProfile), ["cli"]); assert.deepEqual(profileOpenSurfaces(piProfile), ["cli"]); assert.deepEqual(profileOpenSurfaces(kiloProfile), ["cli"]); + assert.deepEqual(profileOpenSurfaces(workbuddyProfile), ["app"]); assert.deepEqual(profileOpenSurfaces(openCodeProfile), ["cli", "app"]); assert.deepEqual(profileOpenSurfaces(claudeDesignProfile), ["app"]); assert.equal(resolveProfileOpenSurface(codexProfile, "app"), "app"); @@ -138,6 +150,7 @@ test("profile open surfaces enforce agent capabilities", () => { assert.throws(() => resolveProfileOpenSurface(kimiProfile, "app"), /does not support APP/); assert.throws(() => resolveProfileOpenSurface(piProfile, "app"), /does not support APP/); assert.throws(() => resolveProfileOpenSurface(kiloProfile, "app"), /does not support APP/); + assert.throws(() => resolveProfileOpenSurface(workbuddyProfile, "cli"), /does not support CLI/); assert.throws(() => resolveProfileOpenSurface(claudeDesignProfile, "cli"), /does not support CLI/); }); @@ -145,6 +158,7 @@ test("default profile command surface is CLI unless the agent is app-only", () = assert.equal(defaultProfileOpenSurface(claudeProfile), "cli"); assert.equal(defaultProfileOpenSurface(codexProfile), "cli"); assert.equal(defaultProfileOpenSurface({ ...codexProfile, surface: "app" }), "cli"); + assert.equal(defaultProfileOpenSurface(workbuddyProfile), "app"); assert.equal(defaultProfileOpenSurface({ ...codexProfile, agent: "zcode" }), "app"); assert.equal(defaultProfileOpenSurface(claudeDesignProfile), "app"); }); @@ -153,6 +167,7 @@ test("Grok and Kimi CLI start a temporary CCR gateway when none is already runni assert.equal(shouldAutoStartProfileGateway(grokProfile, "cli"), true); assert.equal(shouldAutoStartProfileGateway(kimiProfile, "cli"), true); assert.equal(shouldAutoStartProfileGateway(piProfile, "cli"), true); + assert.equal(shouldAutoStartProfileGateway(workbuddyProfile, "app"), false); assert.equal(shouldAutoStartProfileGateway(kiloProfile, "cli"), false); assert.equal(shouldAutoStartProfileGateway(codexProfile, "cli"), false); assert.equal(shouldAutoStartProfileGateway(claudeProfile, "app"), false); @@ -168,6 +183,7 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { const piPlan = buildProfileLaunchPlan(configDir, piProfile, "cli", ["--debug"]); const openCodePlan = buildProfileLaunchPlan(configDir, openCodeProfile, "cli", ["--debug"]); const kiloPlan = buildProfileLaunchPlan(configDir, kiloProfile, "cli", ["--debug"]); + const workbuddyPlan = buildProfileLaunchPlan(configDir, workbuddyProfile, "app"); assert.equal(codexPlan.surface, "app"); assert.deepEqual(codexPlan.args, ["app"]); @@ -218,6 +234,12 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { assert.match(kiloPlan.env.KILO_CONFIG, /kilo[\\/]kilo\.jsonc$/); assert.throws(() => buildProfileLaunchPlan(configDir, kiloProfile, "app"), /does not support APP/); + assert.equal(workbuddyPlan.surface, "app"); + assert.deepEqual(workbuddyPlan.args, ["app"]); + assert.equal(path.basename(workbuddyPlan.command), process.platform === "win32" ? "ccr-codex-cli-stdio-workbuddy-main.cmd" : "ccr-codex-cli-stdio-workbuddy-main"); + assert.equal(workbuddyPlan.env.CCR_PROFILE_SURFACE, "app"); + assert.throws(() => buildProfileLaunchPlan(configDir, workbuddyProfile, "cli"), /does not support CLI/); + assert.throws(() => buildProfileLaunchPlan(configDir, claudeProfile, "app"), /Claude App opening/); assert.throws(() => buildProfileLaunchPlan(configDir, claudeDesignProfile, "app"), /Claude Design profiles can only be opened from CCR Desktop/); }); @@ -239,6 +261,10 @@ test("profile config paths honor CCR, custom, and global scopes", () => { resolveCodexConfigFile(configDir, customProfile), path.join(configDir, "profiles", "custom-profile", "custom", "codex", "config.toml") ); + assert.equal( + resolveCodexConfigFile(configDir, workbuddyProfile), + path.join(configDir, "profiles", "workbuddy-main", "workbuddy", "config.toml") + ); assert.equal(resolveCodexConfigFile(configDir, globalCodex), path.join(process.env.HOME, "codex-home", "config.toml")); assert.equal( resolveOpenCodeConfigFile(configDir, openCodeProfile), @@ -253,9 +279,11 @@ test("profile config paths honor CCR, custom, and global scopes", () => { test("profileOpenCommand quotes profile references for shell usage", () => { const cliCommand = profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"); const appCommand = profileOpenCommand(codexProfile, "app", "ccr", "Codex Main"); + const workbuddyCommand = profileOpenCommand(workbuddyProfile, undefined, "ccr", "Workbuddy Main"); assert.match(cliCommand, /Claude/); assert.match(cliCommand, /Main/); assert.equal(cliCommand.endsWith(" cli"), false); assert.match(appCommand, / app$/); + assert.match(workbuddyCommand, / app$/); }); diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 1fb184ad..74e5f3ea 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -9,7 +9,7 @@ import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/ import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-qr-window-service"; import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; -import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; +import { findInstalledCodexAppExecutable, findInstalledWorkbuddyAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig, saveAppThemePreference, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config"; import { @@ -68,6 +68,7 @@ function applyAppThemePreference(theme: AppConfig["theme"]): void { ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { const chatgptAppPath = findInstalledCodexAppExecutable().executable; const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable; + const workbuddyAppPath = findInstalledWorkbuddyAppExecutable().executable; return { ...(chatgptAppPath ? { chatgptAppPath } : {}), configDbFile: APP_CONFIG_DB_FILE, @@ -80,7 +81,8 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { platform: process.platform, requestLogsDbFile: REQUEST_LOGS_DB_FILE, usageDbFile: USAGE_DB_FILE, - version: app.getVersion() + version: app.getVersion(), + ...(workbuddyAppPath ? { workbuddyAppPath } : {}) } satisfies AppInfo; }); diff --git a/packages/ui/src/assets/agent-logos/workbuddy.png b/packages/ui/src/assets/agent-logos/workbuddy.png new file mode 100644 index 00000000..b1081290 Binary files /dev/null and b/packages/ui/src/assets/agent-logos/workbuddy.png differ diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 2ed32c7a..971141f5 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -354,12 +354,12 @@ function App() { }, []); useEffect(() => { - if (!appInfo.chatgptAppPath && !appInfo.opencodeAppPath) { + if (!appInfo.chatgptAppPath && !appInfo.opencodeAppPath && !appInfo.workbuddyAppPath) { return; } - setProfileDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath)); - setProfileEditDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath)); - }, [appInfo.chatgptAppPath, appInfo.opencodeAppPath]); + setProfileDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath)); + setProfileEditDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath)); + }, [appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath]); useEffect(() => { if (!isProfileAgentAvailable(profileAgentTab)) { @@ -807,10 +807,11 @@ function App() { setProfileDraft(profileDraftWithDetectedAppPath( createProfileDraftFromProfile(profile, draftConfig.botConfigs), appInfo.chatgptAppPath, - appInfo.opencodeAppPath + appInfo.opencodeAppPath, + appInfo.workbuddyAppPath )); setProfileActionError(""); - }, [activeView, onboardingStep, onboardingProfileConfirmed, configLoaded, draftConfig.profile.profiles, draftConfig.botConfigs, profileDraft.agent, appInfo.chatgptAppPath, appInfo.opencodeAppPath]); + }, [activeView, onboardingStep, onboardingProfileConfirmed, configLoaded, draftConfig.profile.profiles, draftConfig.botConfigs, profileDraft.agent, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath]); useEffect(() => { if (activeView !== "onboarding" || !configLoaded || !onboardingStatusLoaded || !providerPresetsLoaded || providerAddOpen) { @@ -2523,7 +2524,7 @@ function App() { function openAddProfileDialog(agent: ProfileConfig["agent"] = profileAgentTab) { const resolvedAgent = isProfileAgentAvailable(agent) ? agent : defaultAvailableProfileAgent; setProfileAgentTab(resolvedAgent); - setProfileDraft(profileDraftWithDetectedAppPath(createProfileDraft(resolvedAgent), appInfo.chatgptAppPath, appInfo.opencodeAppPath)); + setProfileDraft(profileDraftWithDetectedAppPath(createProfileDraft(resolvedAgent), appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath)); setProfileActionError(""); setProfileAddOpen(true); } @@ -2537,7 +2538,8 @@ function App() { setProfileEditDraft(profileDraftWithDetectedAppPath( createProfileDraftFromProfile(profile, draftConfig.botConfigs), appInfo.chatgptAppPath, - appInfo.opencodeAppPath + appInfo.opencodeAppPath, + appInfo.workbuddyAppPath )); setProfileActionError(""); } @@ -2754,7 +2756,7 @@ function App() { return profileDraftWithDetectedAppPath({ ...createProfileDraft(patch.agent, name), envRows: profileEnvRowsForAgent(patch.agent, current.envRows) - }, appInfo.chatgptAppPath, appInfo.opencodeAppPath); + }, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath); } return next; }); @@ -2769,7 +2771,7 @@ function App() { return profileDraftWithDetectedAppPath({ ...createProfileDraft(patch.agent, name), envRows: profileEnvRowsForAgent(patch.agent, current.envRows) - }, appInfo.chatgptAppPath, appInfo.opencodeAppPath); + }, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath); } return next; }); diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 36eb03f5..c89c9b41 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -792,7 +792,7 @@ export function AddProfileForm({ scope: "ccr", surface: "app" } - : agent === "zcode" + : agent === "workbuddy" || agent === "zcode" ? { agent, surface: "app" } : { agent })} value={draft.agent} @@ -829,7 +829,7 @@ export function AddProfileForm({ }); }} options={translateOptions( - draft.agent === "zcode" || draft.agent === "claude-design" + draft.agent === "workbuddy" || draft.agent === "zcode" || draft.agent === "claude-design" ? profileSurfaceOptions.filter((option) => option.value === "app") : draft.agent === "grok" || draft.agent === "kimi" || draft.agent === "pi" || draft.agent === "kilo" ? profileSurfaceOptions.filter((option) => option.value === "cli") @@ -940,7 +940,7 @@ export function AddProfileForm({ ) : draft.agent === "claude-design" ? null : ( <> - + onChange({ providerName: event.target.value })} /> {validation.providerName ? {t(validation.providerName)} : null} - {draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" ? ( + {draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" && draft.agent !== "workbuddy" ? (
{t("Show all sessions")} onChange({ showAllSessions })} /> @@ -1315,7 +1315,7 @@ function profileNumberDraftValid(value: string, min: number, max: number): boole return Number.isFinite(numeric) && numeric >= min && numeric <= max; } -function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | "CHATGPT_APP_PATH" | "OPENCODE_APP_PATH" | undefined { +function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | "CHATGPT_APP_PATH" | "OPENCODE_APP_PATH" | "WORKBUDDY_APP_PATH" | undefined { if (agent === "claude-code") { return "CLAUDE_APP_PATH"; } @@ -1325,6 +1325,9 @@ function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | if (agent === "opencode") { return "OPENCODE_APP_PATH"; } + if (agent === "workbuddy") { + return "WORKBUDDY_APP_PATH"; + } return undefined; } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 9992c377..ab6f115f 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -325,6 +325,8 @@ export const appCopy: Record = { "Kimi model is required.": "Kimi model is required.", "Pi": "Pi", "Pi model": "Pi model", + "Workbuddy": "Workbuddy", + "Workbuddy model": "Workbuddy 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.", @@ -602,6 +604,7 @@ export const appCopy: Record = { "CLAUDE_APP_PATH": "CLAUDE_APP_PATH", "CHATGPT_APP_PATH": "CHATGPT_APP_PATH", "OPENCODE_APP_PATH": "OPENCODE_APP_PATH", + "WORKBUDDY_APP_PATH": "WORKBUDDY_APP_PATH", "Drop the app here or paste the executable path": "Drop the app here or paste the executable path", "Tool name": "Tool name", "Third-party tool environment": "Third-party tool environment", @@ -935,6 +938,8 @@ export const appCopy: Record = { "Allowed models": "允许模型", "Pi": "Pi", "Pi model": "Pi 模型", + "Workbuddy": "Workbuddy", + "Workbuddy model": "Workbuddy 模型", "OpenCode": "OpenCode", "OpenCode model": "OpenCode 模型", "CLI only": "仅 CLI", @@ -1741,6 +1746,7 @@ export const appCopy: Record = { "CLAUDE_APP_PATH": "CLAUDE_APP_PATH", "CHATGPT_APP_PATH": "CHATGPT_APP_PATH", "OPENCODE_APP_PATH": "OPENCODE_APP_PATH", + "WORKBUDDY_APP_PATH": "WORKBUDDY_APP_PATH", "Drop the app here or paste the executable path": "拖动应用到这里,或粘贴可执行文件路径", "Third-party tool environment": "三方工具环境变量", "Environment variable keys are required when values are set.": "设置变量值时必须填写变量名。", diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index 8190b445..7fbebf6c 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -121,6 +121,7 @@ export const agentFilterOptions: Array<{ label: string; value: AgentFilterValue { label: "Kilo CLI", value: "kilo" }, { label: "OpenCode", value: "opencode" }, { label: "Pi", value: "pi" }, + { label: "Workbuddy", value: "workbuddy" }, { label: "ZCode", value: "zcode" }, { label: "Claude Design", value: "claude-design" }, { label: "Unknown", value: "unknown" } @@ -136,6 +137,7 @@ export const profileAgentOptions: ProfileAgentOption[] = [ { label: "Kilo CLI", value: "kilo" }, { label: "OpenCode", value: "opencode" }, { label: "Pi", value: "pi" }, + { label: "Workbuddy", value: "workbuddy" }, { label: "ZCode", value: "zcode" }, { label: "Claude Design", value: "claude-design" } ]; diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 6a67894b..6ce9965c 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -4,6 +4,7 @@ import grokLogoUrl from "@/assets/agent-logos/grok.ico"; import kiloLogoUrl from "@/assets/agent-logos/kilo.svg"; import openCodeLogoUrl from "@/assets/agent-logos/opencode.ico"; import piLogoUrl from "@/assets/agent-logos/pi.svg"; +import workbuddyLogoUrl from "@/assets/agent-logos/workbuddy.png"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; import { @@ -459,7 +460,7 @@ function profileRoutingConfigFromDraft(draft: AddProfileDraft): ProfileRoutingCo } export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code", name?: string): AddProfileDraft { - const surface = agent === "zcode" || agent === "claude-design" ? "app" : "cli"; + const surface = agent === "workbuddy" || agent === "zcode" || agent === "claude-design" ? "app" : "cli"; return { agent, appPath: "", @@ -488,12 +489,15 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code" export function profileDraftWithDetectedAppPath( draft: AddProfileDraft, chatgptAppPath?: string, - opencodeAppPath?: string + opencodeAppPath?: string, + workbuddyAppPath?: string ): AddProfileDraft { const detectedPath = (draft.agent === "codex" ? chatgptAppPath : draft.agent === "opencode" ? opencodeAppPath + : draft.agent === "workbuddy" + ? workbuddyAppPath : "")?.trim() || ""; if (draft.appPath.trim() || !detectedPath) { return draft; @@ -550,7 +554,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs surface: "app" }; } - const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurfaceForForm(profile.surface); + const surface = profile.agent === "workbuddy" || profile.agent === "zcode" ? "app" : normalizeProfileSurfaceForForm(profile.surface); return { ...createProfileDraft(profile.agent, profile.name), ...createProfileRoutingDraft(profile.routing), @@ -565,7 +569,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs providerId: profile.providerId ?? "claude-code-router", providerName: profile.providerName ?? "Claude Code Router", scope: normalizeProfileFormScope(profile.scope), - showAllSessions: profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" ? false : Boolean(profile.showAllSessions), + showAllSessions: profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || profile.agent === "workbuddy" ? false : Boolean(profile.showAllSessions), surface }; } @@ -663,7 +667,7 @@ export function profileConfigFromDraft( ...(routing ? { routing } : {}), scope: draft.scope, settingsFile: draft.settingsFile, - showAllSessions: draft.agent === "zcode" || draft.agent === "opencode" || draft.agent === "kilo" || draft.agent === "claude-design" ? false : draft.showAllSessions, + showAllSessions: draft.agent === "zcode" || draft.agent === "opencode" || draft.agent === "kilo" || draft.agent === "workbuddy" || draft.agent === "claude-design" ? false : draft.showAllSessions, sonnetModel: draft.sonnetModel, smallFastModel: draft.haikuModel || draft.smallFastModel, surface: draft.surface @@ -1267,9 +1271,9 @@ export function profileSummaryItems( } return [ - { label: t(profile.agent === "kilo" ? "Kilo model" : "Model"), value: modelValue }, + { label: t(profile.agent === "kilo" ? "Kilo model" : profile.agent === "workbuddy" ? "Workbuddy model" : "Model"), value: modelValue }, { label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" }, - ...(profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]), + ...(profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || profile.agent === "workbuddy" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]), ...managedCompactItems, ...routingSummaryItems, ...appPathSummaryItems, @@ -1363,7 +1367,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro providerName: profile.providerName?.trim() || "Claude Code Router", ...(routing ? { routing } : {}), scope, - showAllSessions: agent === "zcode" || agent === "opencode" || agent === "kilo" ? false : Boolean(profile.showAllSessions), + showAllSessions: agent === "zcode" || agent === "opencode" || agent === "kilo" || agent === "workbuddy" ? false : Boolean(profile.showAllSessions), surface }; } @@ -1434,6 +1438,8 @@ export function normalizeUnknownProfileItem(value: Record, inde ? "kilo" : rawAgent === "pi" || rawAgent === "pi-agent" || rawAgent === "pi agent" || rawAgent === "pi-coding-agent" || rawAgent === "pi coding agent" ? "pi" + : rawAgent === "workbuddy" || rawAgent === "work-buddy" || rawAgent === "work buddy" || rawAgent === "workbuddy-agent" || rawAgent === "workbuddy agent" + ? "workbuddy" : rawAgent === "zcode" || rawAgent === "z-code" || rawAgent === "z code" ? "zcode" : rawAgent === "claude-design" || rawAgent === "claude design" || rawAgent === "design" @@ -1530,6 +1536,9 @@ function readUnknownProfileAppPath(value: Record, agent: Profil if (agent === "opencode") { return readUnknownString(value, "openCodeAppPath", "opencodeAppPath", "opencode_app_path"); } + if (agent === "workbuddy") { + return readUnknownString(value, "workbuddyAppPath", "workbuddy_app_path", "workBuddyAppPath", "work_buddy_app_path"); + } return undefined; } @@ -1577,6 +1586,9 @@ export function profileAgentLabel(agent: ProfileConfig["agent"]): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "opencode") { return "OpenCode"; } @@ -1607,7 +1619,7 @@ export function profileSurfaceLabel(surface: ProfileSurface): string { } export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[] { - if (profile.agent === "zcode" || profile.agent === "claude-design") { + if (profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design") { return ["app"]; } if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi" || profile.agent === "kilo") { @@ -1623,7 +1635,7 @@ export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[ return ["cli", "app"]; } -export function profileOpenCommandFallback(profile: ProfileConfig, surface: ProfileOpenSurface = profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"): string { +export function profileOpenCommandFallback(profile: ProfileConfig, surface: ProfileOpenSurface = profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"): string { const profileRef = profile.name.trim() || profile.id; return ["ccr", shellCommandQuote(profileRef), ...(surface === "app" ? ["app"] : [])].join(" "); } @@ -1659,19 +1671,22 @@ export function profileAgentLogoUrl(agent: ProfileConfig["agent"]): string { if (agent === "kilo") { return kiloLogoUrl; } + if (agent === "workbuddy") { + return workbuddyLogoUrl; + } return codexLogoUrl; } -function normalizeCodexCompatibleAgent(agent: ProfileConfig["agent"]): "codex" | "kilo" | "opencode" | "zcode" { - return agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : "codex"; +function normalizeCodexCompatibleAgent(agent: ProfileConfig["agent"]): "codex" | "kilo" | "opencode" | "workbuddy" | "zcode" { + return agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : agent === "workbuddy" ? "workbuddy" : "codex"; } function normalizeProfileAgent(agent: ProfileConfig["agent"]): ProfileConfig["agent"] { - return agent === "claude-design" ? "claude-design" : agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : agent === "pi" ? "pi" : agent === "grok" ? "grok" : agent === "kimi" ? "kimi" : agent === "codex" ? "codex" : "claude-code"; + return agent === "claude-design" ? "claude-design" : agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : 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" || agent === "claude-design" ? "app" : agent === "grok" || agent === "kimi" || agent === "pi" || agent === "kilo" ? "cli" : normalizeProfileSurface(surface); + return agent === "workbuddy" || agent === "zcode" || agent === "claude-design" ? "app" : agent === "grok" || agent === "kimi" || agent === "pi" || agent === "kilo" ? "cli" : normalizeProfileSurface(surface); } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { @@ -1680,7 +1695,9 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { : agent === "opencode" ? "~/.config/opencode/opencode.jsonc" : agent === "kilo" - ? "~/.config/kilo/kilo.jsonc" + ? "~/.config/kilo/kilo.jsonc" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === "claude-design" diff --git a/packages/ui/src/pages/home/shared/usage.ts b/packages/ui/src/pages/home/shared/usage.ts index c40e493d..6bd466be 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 === "kilo" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "workbuddy" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; } export function agentKindLabel(agent: AgentKind): string { @@ -208,6 +208,9 @@ export function agentKindLabel(agent: AgentKind): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 0c9c7f35..04bd3003 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, createProfileDraftFromProfile, isProfileDraftSubmittable, normalizeUnknownProfileItem, profileConfigFromDraft, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts"; +import { createProfileDraft, createProfileDraftFromProfile, isProfileDraftSubmittable, normalizeUnknownProfileItem, profileAgentLogoUrl, profileConfigFromDraft, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts"; import { appConfigFixture } from "../fixtures/index.ts"; const profile: ProfileConfig = { @@ -440,6 +440,13 @@ test("detected OPENCODE_APP_PATH is used as the OpenCode profile default", () => assert.equal(profileDraftWithDetectedAppPath({ ...draft, appPath: "/custom/opencode" }, undefined, detectedPath).appPath, "/custom/opencode"); }); +test("detected WORKBUDDY_APP_PATH is used as the Workbuddy profile default", () => { + const detectedPath = "/Applications/WorkBuddy AI.app/Contents/MacOS/Electron"; + const draft = profileDraftWithDetectedAppPath(createProfileDraft("workbuddy"), undefined, undefined, detectedPath); + assert.equal(draft.appPath, detectedPath); + assert.equal(profileDraftWithDetectedAppPath({ ...draft, appPath: "/custom/workbuddy" }, undefined, undefined, detectedPath).appPath, "/custom/workbuddy"); +}); + test("Grok CLI profile defaults to a CCR-scoped CLI entry", () => { const draft = createProfileDraft("grok"); @@ -575,3 +582,46 @@ test("Kilo CLI profiles support local CLI configuration", () => { assert.equal(profile?.showAllSessions, false); assert.equal(profile?.surface, "cli"); }); + +test("Workbuddy profiles support local App configuration", () => { + const config = appConfigFixture(); + const draft = createProfileDraft("workbuddy"); + assert.equal(draft.name, "Workbuddy"); + assert.equal(draft.configFile, "~/.workbuddy/config.toml"); + assert.equal(draft.surface, "app"); + assert.equal(isProfileDraftSubmittable(draft), true); + + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + assert.match(html, /App only/); + assert.doesNotMatch(html, /CLI only/); + assert.match(html, /WORKBUDDY_APP_PATH/); + + const profile = normalizeUnknownProfileItem({ + agent: "work-buddy", + enabled: true, + id: "workbuddy-work", + model: "Provider/model", + name: "Workbuddy Work", + providerId: "claude-code-router", + scope: "global", + showAllSessions: true, + surface: "app" + }, 0); + assert.equal(profile?.agent, "workbuddy"); + assert.equal(profile?.configFile, "~/.workbuddy/config.toml"); + assert.equal(profile?.showAllSessions, false); + assert.equal(profile?.surface, "app"); + assert.match(profileAgentLogoUrl("workbuddy"), /workbuddy/i); + assert.notEqual(profileAgentLogoUrl("workbuddy"), profileAgentLogoUrl("codex")); +}); diff --git a/tests/e2e/electron-cdp.spec.ts b/tests/e2e/electron-cdp.spec.ts index 309043c1..65923e7d 100644 --- a/tests/e2e/electron-cdp.spec.ts +++ b/tests/e2e/electron-cdp.spec.ts @@ -1,7 +1,7 @@ import { chromium, expect, test, type Browser, type Page } from "@playwright/test"; import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; import electronModule from "electron"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -143,6 +143,114 @@ test("starts the managed gateway from IPC without runtime config JSON", async () expect(readGatewayRuntimeMarker(path.join(configDir, "config.sqlite"))).toBeUndefined(); }); +test("applies Workbuddy profiles through the desktop bridge", async () => { + const current = requireRuntime(); + const profileId = "workbuddy-e2e"; + const result = await current.mainPage.evaluate(async (input) => { + const config = await window.ccr?.getConfig(); + if (!config) { + throw new Error("Config bridge is unavailable."); + } + config.APIKEYS = []; + config.Providers = [{ + api_base_url: "http://127.0.0.1:9/v1", + api_key: "e2e-provider-key", + id: "workbuddy-e2e-provider", + models: ["gpt-5-codex"], + name: "Workbuddy E2E Provider", + type: "openai_responses" + }]; + config.preferredProvider = "Workbuddy E2E Provider"; + config.gateway.enabled = false; + config.profile = { + ...config.profile, + enabled: true, + profiles: [{ + agent: "workbuddy", + cliMiddleware: true, + codexCliPath: "", + codexHome: "", + configFile: "~/.workbuddy/config.toml", + configFormat: "separate_profile_files", + enabled: true, + env: {}, + id: input.profileId, + managedCompact: false, + model: "Workbuddy E2E Provider/gpt-5-codex", + name: "Workbuddy E2E", + providerId: "claude-code-router", + providerName: "Claude Code Router", + showAllSessions: true, + scope: "ccr", + surface: "app" + }] + }; + + const saved = await window.ccr?.saveConfig(config, { applyProfile: false }); + const applyResult = await window.ccr?.applyProfile(); + return { applyResult, saved }; + }, { profileId }); + + expect(result.saved?.profile.profiles).toHaveLength(1); + expect(result.saved?.profile.profiles[0]?.agent).toBe("workbuddy"); + expect(result.saved?.profile.profiles[0]?.showAllSessions).toBe(false); + expect(result.saved?.profile.profiles[0]?.surface).toBe("app"); + + const workbuddyStatus = result.applyResult?.clients.find((client) => client.client === "workbuddy"); + expect(workbuddyStatus?.ok, workbuddyStatus?.message).toBe(true); + + const configDir = path.join(current.testHome, ".claude-code-router"); + const profileHome = path.join(configDir, "profiles", profileId, "workbuddy"); + const configFile = path.join(profileHome, "config.toml"); + const launcherFile = path.join( + configDir, + "bin", + process.platform === "win32" + ? `ccr-codex-cli-stdio-${profileId}.cmd` + : `ccr-codex-cli-stdio-${profileId}` + ); + expect(workbuddyStatus?.path).toBe(configFile); + + const toml = readFileSync(configFile, "utf8"); + expect(toml).toContain('model_provider = "claude-code-router"'); + expect(toml).toContain('model = "Workbuddy E2E Provider/gpt-5-codex"'); + expect(toml).toContain(`model_catalog_json = "${path.join(profileHome, "ccr-model-catalog.json")}"`); + expect(toml).toContain(`base_url = "http://${host}:${current.gatewayPort}/v1"`); + expect(toml).toContain('wire_api = "responses"'); + expect(toml).not.toContain("show_all_sessions = true"); + + const workbuddyModelsFile = path.join(profileHome, "models.json"); + expect(existsSync(workbuddyModelsFile)).toBe(true); + const workbuddyModels = JSON.parse(readFileSync(workbuddyModelsFile, "utf8")); + expect(workbuddyModels.availableModels).toEqual(["Workbuddy E2E Provider/gpt-5-codex"]); + expect(workbuddyModels.models).toHaveLength(1); + expect(workbuddyModels.models[0]).toMatchObject({ + apiKey: "${CCR_PROFILE_API_KEY}", + id: "Workbuddy E2E Provider/gpt-5-codex", + supportsToolCall: true, + tags: ["chat", "custom"], + url: `http://${host}:${current.gatewayPort}/v1`, + vendor: "Workbuddy E2E Provider" + }); + + const launcher = readFileSync(launcherFile, "utf8"); + expect(launcher).toContain(profileHome); + expect(launcher).toContain("WORKBUDDY_HOME"); + expect(launcher).toContain("WORKBUDDY_CONFIG_DIR"); + expect(launcher).toContain("CODEBUDDY_CONFIG_DIR"); + expect(launcher).toContain("CCR_REAL_CODEX_CLI_PATH"); + expect(launcher).toContain("codebuddy"); + + const virtualAuthFile = workbuddyVirtualAuthFile(profileHome); + expect(existsSync(virtualAuthFile)).toBe(true); + const virtualSession = JSON.parse(readFileSync(virtualAuthFile, "utf8")); + expect(virtualSession.auth.accessToken).toBe("ccr-local-profile"); + expect(virtualSession.auth.domain).toBe("www.workbuddy.ai"); + expect(virtualSession.account.uid).toBe("ccr-local-profile"); + expect(virtualSession.accounts).toHaveLength(1); + expect(virtualSession.allAccounts).toHaveLength(1); +}); + async function startElectronOverCdp(): Promise { const cdpPort = await findAvailablePort(); const gatewayPort = await findAvailablePort(); @@ -307,6 +415,16 @@ function readSqliteRows(file: string, query: string): Row[] { return JSON.parse(output || "[]") as Row[]; } +function workbuddyVirtualAuthFile(profileHome: string): string { + const virtualHome = path.join(profileHome, ".claude-code-router", "workbuddy-app-home"); + const sharedAuthSegments = process.platform === "darwin" + ? ["Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth"] + : process.platform === "win32" + ? ["AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth"] + : [".local", "share", "CodeBuddyExtension", "Data", "Public", "auth"]; + return path.join(virtualHome, ...sharedAuthSegments, "workbuddy-desktop-ai.info"); +} + function runElectronNode(script: string, file: string, query = ""): string { const result = spawnSync(electronExecutable, ["-e", script], { cwd: projectRoot,