feat(clients): register Factory Droid during setup (#2309)

This commit is contained in:
Danish Rafie Ekaputra 2026-10-13 20:34:38 +07:00 committed by GitHub
parent 117bd23dd5
commit 9e019ad27d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 191 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`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
`hermes`, `qoder`, `droid`, `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

@ -165,6 +165,14 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes
from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup
installs its skill copy there.
## Factory Droid
For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes
`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry
`command`, `args`, and `env`. Droid discovers personal skills from
`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so
setup installs its skill copy there.
## Trae
For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes

View File

@ -283,6 +283,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| Pi | `pi` |
| Hermes | `hermes` |
| Qoder | `qoder` |
| Factory Droid | `droid` |
| Trae | `trae` |
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |

View File

@ -135,6 +135,9 @@ const codexDirectory = ({ home, env }: ClientPathContext): string =>
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
env.QODER_CONFIG_DIR || join(home, ".qoder");
const factoryDirectory = ({ home }: ClientPathContext): string =>
join(home, ".factory");
/** Match Qwen Code's home override, including tilde and cwd-relative paths. */
const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
const override = env.QWEN_HOME;
@ -558,6 +561,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: qoderDirectory,
format: "json",
},
{
name: "droid",
displayName: "Factory Droid",
skillPath: (context: ClientPathContext) =>
join(factoryDirectory(context), "skills"),
configPath: (context: ClientPathContext) =>
join(factoryDirectory(context), "mcp.json"),
markerPath: factoryDirectory,
format: "json",
},
{
name: "zcode",
zcodeFallbackPath: ({ home }: ClientPathContext) =>

View File

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

View File

@ -0,0 +1,167 @@
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 droidPath = (
home: string,
platform: NodeJS.Platform = "linux",
env: Parameters<typeof supportedClients>[2] = {},
) => supportedClients(home, platform, env).find(({ name }) => name === "droid");
describe("Factory Droid configuration paths", () => {
it("resolves the user MCP config file and marker directory", () => {
expect(droidPath("/home/a")).toEqual({
name: "droid",
displayName: "Factory Droid",
configPath: join("/home/a", ".factory", "mcp.json"),
markerPath: join("/home/a", ".factory"),
format: "json",
});
expect(droidPath("/Users/a", "darwin")?.configPath).toBe(
join("/Users/a", ".factory", "mcp.json"),
);
expect(droidPath("C:\\Users\\a", "win32")?.configPath).toBe(
join("C:\\Users\\a", ".factory", "mcp.json"),
);
});
it("resolves the personal skills directory beside the config root", () => {
expect(
skillDestinations("/home/a", ["droid"]).map(({ path }) => path),
).toEqual([
join("/home/a", ".factory", "skills", "reverse-engineer-anything"),
]);
});
});
describe("Factory Droid JSON registration", () => {
const droidClient = async () => {
const home = await createTestTempDirectory("rea-droid-");
const client = droidPath(home, process.platform);
if (client?.markerPath === undefined)
throw new Error("missing Factory Droid client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
it("upserts rea into mcpServers while preserving unrelated servers and permissions", async () => {
const { home, client } = await droidClient();
await writeFile(
client.configPath,
`{
"mcpServers": {
"other": {
"type": "stdio",
"command": "other",
"args": [],
"disabled": false
}
},
"persistentPermissions": {
"servers": {
"other": { "approvedAt": "2026-01-01T00:00:00.000Z" }
}
}
}`,
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
expect(parsed.mcpServers.other).toEqual({
type: "stdio",
command: "other",
args: [],
disabled: false,
});
expect(parsed.persistentPermissions).toEqual({
servers: { other: { approvedAt: "2026-01-01T00:00:00.000Z" } },
});
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 === "droid"),
).toMatchObject({ state: "aligned", command });
});
it("creates the mcpServers table in an existing config file without servers", async () => {
const { client } = await droidClient();
await writeFile(
client.configPath,
JSON.stringify({ persistentPermissions: { servers: {} } }, null, 2),
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
expect(parsed.persistentPermissions).toEqual({ servers: {} });
expect(parsed.mcpServers.rea).toEqual({
command: command[0],
args: command.slice(1),
});
});
it("registers into an empty config file", async () => {
const { client } = await droidClient();
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 droidClient();
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();
});
});