diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 409bc7f19..d03984ec8 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -265,6 +265,21 @@ The user-level MCP paths follow the editor's VS Code layout. Trae's official documentation describes the raw JSON editor without specifying those paths; loading this registration in Trae remains unverified. +## WorkBuddy + +For [WorkBuddy](https://www.workbuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide), setup writes `mcpServers.rea` into the +global `~/.workbuddy/mcp.json` (a standard `mcpServers` JSON file at the user +home root on every platform). Entries carry `command`, `args`, and `env`; +stdio is the default transport. WorkBuddy discovers personal skills from +`~/.workbuddy/skills` (`%userprofile%\.workbuddy\skills` on Windows), so +setup installs its skill copy there. + +The global MCP path is a plain JSON file; setup preserves every other server +and top-level key when it upserts `rea`. WorkBuddy also reads a project-level +`/.workbuddy/mcp.json`, which setup does not touch: it registers the +user-level file only. Loading this registration and real REA tool calls +through WorkBuddy are verified. + ## ZCode For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the diff --git a/docs/installation.md b/docs/installation.md index 39584b51d..38c4d7966 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -288,11 +288,12 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | Qoder | `qoder` | | Factory Droid | `droid` | | Trae | `trae` | +| WorkBuddy | `workbuddy` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | -`dsh` and `kimi` require a release containing them. The registry check above does -not cover them. +`dsh`, `kimi`, and `workbuddy` require a release containing them. The registry +check above does not cover them. See [client configuration and troubleshooting](client-configuration.md) for configuration paths, profiles, trust policies, and schema-limit workarounds. diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 55a651a52..20ab3ed29 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -606,6 +606,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: traeUserDirectory, format: "json", }, + { + name: "workbuddy", + displayName: "WorkBuddy", + skillPath: [".workbuddy", "skills"], + configPath: [".workbuddy", "mcp.json"], + markerPath: [".workbuddy"], + format: "json", + }, { name: "grok_build", displayName: "Grok Build", diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 0e4054ae6..877ae92e8 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -67,6 +67,7 @@ describe("client configuration filesystem lifecycle", () => { "kimi", "vscode", "trae", + "workbuddy", "grok_build", "omp", "pi", diff --git a/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts b/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts new file mode 100644 index 000000000..b6aac6577 --- /dev/null +++ b/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts @@ -0,0 +1,159 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { skillDestinations } from "../../../src/application/SetupSkill.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const workbuddyPath = ( + home: string, + platform: NodeJS.Platform = process.platform, + env: Parameters[2] = {}, +) => + supportedClients(home, platform, env).find( + ({ name }) => name === "workbuddy", + ); + +describe("WorkBuddy configuration paths", () => { + it("resolves the global mcp.json under the home root", () => { + expect(workbuddyPath("/home/a")?.configPath).toBe( + join("/home/a", ".workbuddy", "mcp.json"), + ); + expect(workbuddyPath("/Users/a")?.markerPath).toBe( + join("/Users/a", ".workbuddy"), + ); + expect(workbuddyPath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".workbuddy", "mcp.json"), + ); + }); + + it("resolves the registration format as the plain mcpServers JSON dialect", () => { + expect(workbuddyPath("/home/a")?.format).toBe("json"); + }); + + it("resolves the personal skills directory under the home root", () => { + expect( + skillDestinations("/home/a", ["workbuddy"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".workbuddy", "skills", "reverse-engineer-anything"), + ]); + expect( + skillDestinations("C:\\Users\\a", ["workbuddy"], {}, "win32").map( + ({ path }) => path, + ), + ).toEqual([ + join("C:\\Users\\a", ".workbuddy", "skills", "reverse-engineer-anything"), + ]); + }); +}); + +describe("WorkBuddy JSON registration", () => { + const workbuddyClient = async () => { + const home = await createTestTempDirectory("rea-workbuddy-"); + const client = workbuddyPath(home, process.platform); + if (client?.markerPath === undefined) + throw new Error("missing WorkBuddy client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("upserts rea into mcpServers while preserving unrelated settings", async () => { + const { home, client } = await workbuddyClient(); + await writeFile( + client.configPath, + `{ + "mcpServers": { + "other": { "command": "other-server" } + } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "workbuddy"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcpServers table in an existing mcp.json without servers", async () => { + const { client } = await workbuddyClient(); + await writeFile( + client.configPath, + JSON.stringify({ unrelated: { setting: true } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.unrelated).toEqual({ setting: true }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty mcp.json", async () => { + const { client } = await workbuddyClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await workbuddyClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcpServers: { other: { command: "other-server" } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toBeUndefined(); + }); +});