diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index ae8cd4a98..ed8393bd9 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`, -`hermes`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a +`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. For unsupported clients, use version-pinned manual stdio registration or CLI. diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 01bde02a4..c0e629d66 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -145,6 +145,15 @@ REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion, and home expansion before resolving that profile. Setup previews the resolved configuration and skill destinations; doctor and uninstall use those same paths. +## Qoder CLI + +For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes +`mcpServers.rea` into the user-level `~/.qoder/settings.json` (the +`QODER_CONFIG_DIR` override is honored). Entries carry `command`, `args`, and +`env`; stdio is the default transport. Qoder CLI discovers personal skills +from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup +installs its skill copy there. + ## Trae For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes diff --git a/docs/installation.md b/docs/installation.md index 6b55e316a..e4a8b0e29 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -280,6 +280,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | OMP | `omp` | | Pi | `pi` | | Hermes | `hermes` | +| Qoder | `qoder` | | Trae | `trae` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 3969aa848..18c3480d9 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -128,6 +128,9 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => const codexDirectory = ({ home, env }: ClientPathContext): string => env.CODEX_HOME || join(home, ".codex"); +const qoderDirectory = ({ home, env }: ClientPathContext): string => + env.QODER_CONFIG_DIR || join(home, ".qoder"); + /** Match Qwen Code's home override, including tilde and cwd-relative paths. */ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { const override = env.QWEN_HOME; @@ -528,6 +531,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: hermesDirectory, format: "hermes", }, + { + name: "qoder", + displayName: "Qoder", + skillPath: (context: ClientPathContext) => + join(qoderDirectory(context), "skills"), + configPath: (context: ClientPathContext) => + join(qoderDirectory(context), "settings.json"), + markerPath: qoderDirectory, + format: "json", + }, { name: "zcode", zcodeFallbackPath: ({ home }: ClientPathContext) => diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 05efa0bfb..2d814a5f3 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -70,6 +70,7 @@ describe("client configuration filesystem lifecycle", () => { "omp", "pi", "hermes", + "qoder", "zcode", "dsh", "grok_bot", diff --git a/tests/boundary/filesystem/qoderClientConfiguration.test.ts b/tests/boundary/filesystem/qoderClientConfiguration.test.ts new file mode 100644 index 000000000..0621dfdc0 --- /dev/null +++ b/tests/boundary/filesystem/qoderClientConfiguration.test.ts @@ -0,0 +1,166 @@ +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 qoderPath = ( + home: string, + platform: NodeJS.Platform = "linux", + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "qoder"); + +describe("Qoder configuration paths", () => { + it("resolves the user settings file and marker directory", () => { + expect(qoderPath("/home/a")).toEqual({ + name: "qoder", + displayName: "Qoder", + configPath: join("/home/a", ".qoder", "settings.json"), + markerPath: join("/home/a", ".qoder"), + format: "json", + }); + expect(qoderPath("/Users/a", "darwin")?.configPath).toBe( + join("/Users/a", ".qoder", "settings.json"), + ); + expect(qoderPath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".qoder", "settings.json"), + ); + }); + + it("honors the QODER_CONFIG_DIR override", () => { + expect( + qoderPath("/home/a", "linux", { QODER_CONFIG_DIR: "/opt/qoder" }) + ?.configPath, + ).toBe(join("/opt/qoder", "settings.json")); + expect( + qoderPath("/home/a", "linux", { QODER_CONFIG_DIR: "" })?.configPath, + ).toBe(join("/home/a", ".qoder", "settings.json")); + }); + + it("resolves the personal skills directory beside the config root", () => { + expect( + skillDestinations("/home/a", ["qoder"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".qoder", "skills", "reverse-engineer-anything"), + ]); + expect( + skillDestinations("/home/a", ["qoder"], { + QODER_CONFIG_DIR: "/opt/qoder", + }).map(({ path }) => path), + ).toEqual([join("/opt/qoder", "skills", "reverse-engineer-anything")]); + }); +}); + +describe("Qoder JSON registration", () => { + const qoderClient = async () => { + const home = await createTestTempDirectory("rea-qoder-"); + const client = qoderPath(home, process.platform); + if (client?.markerPath === undefined) + throw new Error("missing Qoder client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("upserts rea into mcpServers while preserving unrelated settings", async () => { + const { home, client } = await qoderClient(); + await writeFile( + client.configPath, + `{ + "mcpServers": { + "other": { "command": "other-server" } + }, + "model": { "name": "qoder-model" } +}`, + ); + + 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.model).toEqual({ name: "qoder-model" }); + 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 === "qoder"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcpServers table in an existing settings file without servers", async () => { + const { client } = await qoderClient(); + await writeFile( + client.configPath, + JSON.stringify({ general: { vimMode: false } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.general).toEqual({ vimMode: false }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty settings file", async () => { + const { client } = await qoderClient(); + 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 qoderClient(); + 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(); + }); +}); diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 35fc6843c..9825caeec 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -34,6 +34,7 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "PI_PROFILE", + "QODER_CONFIG_DIR", "QWEN_HOME", "SAND_DATA_ROOT", "XDG_CONFIG_HOME",