Add WorkBuddy app integration

This commit is contained in:
musi 2026-08-11 09:25:53 +08:00
parent 0d178f3732
commit c8e5e2b53d
20 changed files with 866 additions and 82 deletions

View file

@ -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<ProfileConfig, "model" | "name" | "providerName">,
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<ProfileConfig, "model">,
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<ProfileConfig, "name" | "providerName">,
config?: AppConfig
): Record<string, unknown> {
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<string, unknown> = {
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<AppConfig, "gateway">): 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<ProfileConfig, "agent">): 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<string, string> = {
@ -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<string, string> {
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<string, string> {
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<string, string> {
@ -468,28 +698,136 @@ function codexAppAgentEnv(
launcher: string,
home: string,
userDataDir: string,
modelCatalogFile: string
modelCatalogFile: string,
workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult
): Record<string, string> {
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<ProfileConfig, "id" | "name">
): 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<ProfileConfig, "id" | "name">): Record<string, unknown> {
return {
avatarUrl: "",
lastLogin: true,
nickname: profile.name?.trim() || "Claude Code Router",
pluginEnabled: true,
type: "personal",
uid: workbuddyVirtualAuthUserId
};
}
function workbuddyVirtualAuthEnv(result: WorkbuddyVirtualAuthResult): Record<string, string> {
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 {

View file

@ -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<string, unknown>, 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

View file

@ -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";

View file

@ -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<string, string | string[]>, 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<string, string | string[]>, 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";
}

View file

@ -49,7 +49,7 @@ export function findProfileForOpen(config: Pick<AppConfig, "profile">, 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<ProfileConfig, "agent">): 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 {

View file

@ -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<typ
}
async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): Promise<ProfileOpenResult> {
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<typeof
}
const launch = profile.agent === "zcode"
? launchZcodeAppProfile(CONFIGDIR, profile, profileGatewayConfig)
: launchCodexAppProfile(CONFIGDIR, profile, profileGatewayConfig);
: profile.agent === "workbuddy"
? launchWorkbuddyAppProfile(CONFIGDIR, profile, profileGatewayConfig)
: launchCodexAppProfile(CONFIGDIR, profile, profileGatewayConfig);
const entry = registerProfileApp(profile, "app", launch);
const started = await waitForProfileAppStart(entry, 12000);
if (!started) {

View file

@ -424,7 +424,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
const modelCatalogFile = codexModelCatalogFile(configFile);
const modelCatalogResult = writeFileWithBackup(modelCatalogFile, codexModelCatalogJson(config, model));
const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, { ...profile, model }, config);
const showAllSessions = profile.agent === "zcode" ? false : Boolean(profile.showAllSessions);
const showAllSessions = profile.agent === "zcode" || profile.agent === "workbuddy" ? false : Boolean(profile.showAllSessions);
const toolHubMcpResult = writeCodexToolHubMcpRuntimeConfig(config, token);
const contextArchiveMcp = profile.agent === "codex"
? codexContextArchiveMcpConfig(config, profile, token)
@ -466,6 +466,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
const extras = [
modelCatalogFile ? `catalog ${modelCatalogFile}` : "",
appModelCatalogResult.file ? `app catalog ${appModelCatalogResult.file}` : "",
appModelCatalogResult.workbuddyModelsConfig?.file ? `workbuddy models ${appModelCatalogResult.workbuddyModelsConfig.file}` : "",
toolHubMcpResult.file ? `toolhub runtime ${toolHubMcpResult.file}` : "",
contextArchiveMcp ? "context archive MCP" : "",
separateProfileResult?.file ? `profile ${separateProfileResult.file}` : "",
@ -2051,7 +2052,7 @@ function codexMiddlewareShellScript(
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 envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `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 {

View file

@ -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 } : {})
};
}

View file

@ -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"),
"<plist><dict><key>CFBundleExecutable</key><string>Electron</string></dict></plist>"
);
} 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", {

View file

@ -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$/);
});

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

View file

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

View file

@ -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 : (
<>
<Field className="sm:col-span-2" label={t(draft.agent === "zcode" ? "ZCode model" : draft.agent === "opencode" ? "OpenCode model" : draft.agent === "kilo" ? "Kilo model" : "Codex model")} requirement="optional" requirementLabel={optionalFieldLabel}>
<Field className="sm:col-span-2" label={t(draft.agent === "zcode" ? "ZCode model" : draft.agent === "opencode" ? "OpenCode model" : draft.agent === "kilo" ? "Kilo model" : draft.agent === "workbuddy" ? "Workbuddy model" : "Codex model")} requirement="optional" requirementLabel={optionalFieldLabel}>
<ModelSelector
placeholder={modelPlaceholder}
providers={providers}
@ -1000,7 +1000,7 @@ export function AddProfileForm({
<Input value={draft.providerName} onChange={(event) => onChange({ providerName: event.target.value })} />
{validation.providerName ? <ProfileFieldHint>{t(validation.providerName)}</ProfileFieldHint> : null}
</Field>
{draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" ? (
{draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" && draft.agent !== "workbuddy" ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2">
<span className="text-[12px] font-medium">{t("Show all sessions")}</span>
<Toggle checked={draft.showAllSessions} onChange={(showAllSessions) => 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;
}

View file

@ -325,6 +325,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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.": "设置变量值时必须填写变量名。",

View file

@ -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" }
];

View file

@ -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<string, unknown>, 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<string, unknown>, 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"

View file

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

View file

@ -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(
<AddProfileForm
botConfigs={config.botConfigs ?? []}
draft={draft}
error=""
mode="edit"
onChange={() => 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"));
});

View file

@ -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<ElectronCdpRuntime> {
const cdpPort = await findAvailablePort();
const gatewayPort = await findAvailablePort();
@ -307,6 +415,16 @@ function readSqliteRows<Row>(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,