feat(clients): register Factory Droid during setup (#2309)
This commit is contained in:
parent
117bd23dd5
commit
9e019ad27d
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ describe("client configuration filesystem lifecycle", () => {
|
|||
"pi",
|
||||
"hermes",
|
||||
"qoder",
|
||||
"droid",
|
||||
"zcode",
|
||||
"dsh",
|
||||
"grok_bot",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue