From 109769319a4a3a5f85e8a43bb419d04aefff66a4 Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:55:55 +0800 Subject: [PATCH] feat(clients): register Qoder CLI during setup (#1572) * feat(clients): register Qoder CLI during setup - SupportedClients: qoder definition (~/.qoder/settings.json, format json, QODER_CONFIG_DIR override honored) - Qoder CLI documents mcpServers as the user-scope server table with command/args/env and stdio as the default transport; no personal skills directory is documented, so setup skips the skill copy - tests: qoderClientConfiguration.test.ts (path resolution and QODER_CONFIG_DIR override, upsert preserving unrelated settings, fresh/empty configs, uninstall) Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/qoderClientConfiguration.test.ts 6/6. * fix(clients): install Qoder CLI's personal REA skill Review follow-up (#1572): the Qoder CLI Skills documentation lists ~/.qoder/skills/{skill-name}/SKILL.md with automatic discovery, so the definition now carries skillPath following Qoder's configuration location (QODER_CONFIG_DIR honored) and setup installs the bundled skill copy there. The installation docs no longer claim Qoder has no personal skills directory, and the bundled REA skill's supported client list includes qoder. Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/qoderClientConfiguration.test.ts 7/7. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/connection-and-recovery.md | 2 +- docs/client-configuration.md | 9 + docs/installation.md | 1 + src/application/SupportedClients.ts | 13 ++ .../filesystem/clientLifecycle.test.ts | 1 + .../qoderClientConfiguration.test.ts | 166 ++++++++++++++++++ tests/fixtures/clientEnvironment.ts | 1 + 7 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/filesystem/qoderClientConfiguration.test.ts 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",