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>
This commit is contained in:
Iven Koo 2026-10-12 22:55:55 +08:00 committed by GitHub
parent bfa805decf
commit 109769319a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 192 additions and 1 deletions

View File

@ -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.

View File

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

View File

@ -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` |

View File

@ -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) =>

View File

@ -70,6 +70,7 @@ describe("client configuration filesystem lifecycle", () => {
"omp",
"pi",
"hermes",
"qoder",
"zcode",
"dsh",
"grok_bot",

View File

@ -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<typeof supportedClients>[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();
});
});

View File

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