mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
* feat(setup): support Cline as a client * test(setup): cover Cline doctor and uninstall ownership * docs(setup): document Cline support * test(setup): verify Cline against an installed client * test(setup): include Cline in the exhaustive client detection list * docs(setup): note that a hand-written rea entry blocks Cline setup * fix(setup): isolate Cline verification and await MCP discovery * fix(verification): own and isolate Cline discovery * fix(setup): preserve native Cline transport settings --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
324 lines
9.9 KiB
TypeScript
324 lines
9.9 KiB
TypeScript
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import {
|
|
cliTest,
|
|
type CliResult,
|
|
type TestCli,
|
|
} from "../../support/cli/cliFixture.js";
|
|
|
|
cliTest(
|
|
"registers Cline at its documented path and preserves peers and backups",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-home-");
|
|
const config = join(
|
|
home,
|
|
".cline",
|
|
"data",
|
|
"settings",
|
|
"cline_mcp_settings.json",
|
|
);
|
|
const original = JSON.stringify({
|
|
mcpServers: { peer: { command: "peer" } },
|
|
});
|
|
await mkdir(join(home, ".cline", "data", "settings"), { recursive: true });
|
|
await writeFile(config, original);
|
|
const run = () =>
|
|
cli.run({
|
|
arguments: [
|
|
"setup",
|
|
"--client",
|
|
"cline",
|
|
"--skill=false",
|
|
"--yes",
|
|
"--json",
|
|
],
|
|
cwd: home,
|
|
environment: {
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
|
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
|
|
JAVA_HOME: join(home, "missing-java"),
|
|
},
|
|
});
|
|
const first = await run();
|
|
expect(first.exitCode, first.stdout).toBe(0);
|
|
const configured = await readFile(config, "utf8");
|
|
expect(JSON.parse(configured)).toMatchObject({
|
|
mcpServers: {
|
|
peer: { command: "peer" },
|
|
rea: { args: expect.arrayContaining(["mcp"]) },
|
|
},
|
|
});
|
|
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
|
|
const second = await run();
|
|
expect(second.exitCode, second.stdout).toBe(0);
|
|
expect(await readFile(config, "utf8")).toBe(configured);
|
|
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"honors CLINE_MCP_SETTINGS_PATH and leaves the default file absent",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-override-");
|
|
const override = join(home, "custom", "settings.json");
|
|
const defaultPath = join(
|
|
home,
|
|
".cline",
|
|
"data",
|
|
"settings",
|
|
"cline_mcp_settings.json",
|
|
);
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"setup",
|
|
"--client",
|
|
"cline",
|
|
"--skill=false",
|
|
"--yes",
|
|
"--json",
|
|
],
|
|
cwd: home,
|
|
environment: {
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
CLINE_MCP_SETTINGS_PATH: override,
|
|
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
|
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
|
|
JAVA_HOME: join(home, "missing-java"),
|
|
},
|
|
});
|
|
expect(result.exitCode, result.stdout).toBe(0);
|
|
expect(JSON.parse(await readFile(override, "utf8"))).toMatchObject({
|
|
mcpServers: { rea: { args: expect.arrayContaining(["mcp"]) } },
|
|
});
|
|
await expect(access(defaultPath)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"resolves the Cline skill client to the shared skills directory",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-skill-");
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"setup",
|
|
"--skill-client",
|
|
"cline",
|
|
"--skill",
|
|
"--dry-run",
|
|
"--json",
|
|
],
|
|
cwd: home,
|
|
environment: {
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
|
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
|
|
JAVA_HOME: join(home, "missing-java"),
|
|
},
|
|
});
|
|
expect(result.exitCode, result.stdout).toBe(0);
|
|
const plan = result.json as {
|
|
status: string;
|
|
plannedActions: { id: string; target: string }[];
|
|
};
|
|
const skill = plan.plannedActions.filter(
|
|
({ id }) => id === "install_skill",
|
|
);
|
|
expect(skill).toHaveLength(1);
|
|
expect(skill[0]!.target).toBe(
|
|
join(home, ".agents", "skills", "reverse-engineer-anything"),
|
|
);
|
|
},
|
|
);
|
|
|
|
const clineEnvironment = (home: string) => ({
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
|
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
|
|
JAVA_HOME: join(home, "missing-java"),
|
|
});
|
|
|
|
const setupCline = (cli: TestCli, home: string): Promise<CliResult> =>
|
|
cli.run({
|
|
arguments: [
|
|
"setup",
|
|
"--client",
|
|
"cline",
|
|
"--skill=false",
|
|
"--yes",
|
|
"--json",
|
|
],
|
|
cwd: home,
|
|
environment: clineEnvironment(home),
|
|
});
|
|
|
|
cliTest(
|
|
"reports the Cline registration as aligned and REA-owned",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-doctor-");
|
|
expect((await setupCline(cli, home)).exitCode).toBe(0);
|
|
const doctor = await cli.run({
|
|
arguments: ["doctor", "--client", "cline", "--json"],
|
|
cwd: home,
|
|
environment: clineEnvironment(home),
|
|
});
|
|
const report = doctor.json as {
|
|
identity: {
|
|
registrations: {
|
|
client: string;
|
|
state: string;
|
|
command_identity: string;
|
|
}[];
|
|
};
|
|
};
|
|
const registration = report.identity.registrations.find(
|
|
({ client }) => client === "cline",
|
|
);
|
|
expect(registration?.state).toBe("aligned");
|
|
expect(registration?.command_identity).toBe("owned");
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"refuses a foreign rea registration in the Cline file",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-foreign-");
|
|
const directory = join(home, ".cline", "data", "settings");
|
|
const config = join(directory, "cline_mcp_settings.json");
|
|
const foreign = JSON.stringify({
|
|
mcpServers: { rea: { command: "custom", args: ["keep"] } },
|
|
});
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(config, foreign);
|
|
const result = await setupCline(cli, home);
|
|
expect(result.exitCode).toBe(1);
|
|
const plan = result.json as { status: string; remediation: string | null };
|
|
expect(plan.status).toBe("needs_human");
|
|
expect(plan.remediation).toContain("foreign ownership");
|
|
expect(await readFile(config, "utf8")).toBe(foreign);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"uninstall removes only the REA entry from the Cline file",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-uninstall-");
|
|
const directory = join(home, ".cline", "data", "settings");
|
|
const config = join(directory, "cline_mcp_settings.json");
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(
|
|
config,
|
|
JSON.stringify({ mcpServers: { peer: { command: "peer" } } }),
|
|
);
|
|
expect((await setupCline(cli, home)).exitCode).toBe(0);
|
|
const removal = await cli.run({
|
|
arguments: ["uninstall", "--json"],
|
|
cwd: home,
|
|
environment: clineEnvironment(home),
|
|
});
|
|
expect(removal.exitCode, removal.stdout).toBe(0);
|
|
const report = removal.json as {
|
|
items: { name: string; status: string }[];
|
|
};
|
|
expect(report.items.find(({ name }) => name === "cline")?.status).toBe(
|
|
"removed",
|
|
);
|
|
const remaining = JSON.parse(await readFile(config, "utf8"));
|
|
expect(remaining.mcpServers).toEqual({ peer: { command: "peer" } });
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"refuses a malformed Cline configuration without rewriting it",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-malformed-");
|
|
const directory = join(home, ".cline", "data", "settings");
|
|
const config = join(directory, "cline_mcp_settings.json");
|
|
const malformed = '{"mcpServers": {';
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(config, malformed);
|
|
const result = await setupCline(cli, home);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(await readFile(config, "utf8")).toBe(malformed);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"refreshes an owned nested Cline transport without losing its settings",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-cline-nested-");
|
|
const config = join(
|
|
home,
|
|
".cline",
|
|
"data",
|
|
"settings",
|
|
"cline_mcp_settings.json",
|
|
);
|
|
expect((await setupCline(cli, home)).exitCode).toBe(0);
|
|
const configured = JSON.parse(await readFile(config, "utf8"));
|
|
const original = configured.mcpServers.rea;
|
|
const transport = {
|
|
type: "stdio",
|
|
command: original.command,
|
|
args: original.args,
|
|
env: { ...original.env, CLINE_NESTED_VALUE: "caller selected" },
|
|
cwd: home,
|
|
metadata: { selected: "nested transport" },
|
|
};
|
|
const nested = {
|
|
...configured,
|
|
mcpServers: {
|
|
...configured.mcpServers,
|
|
peer: { command: "recorded-peer" },
|
|
rea: {
|
|
transport,
|
|
env: { SHADOWED_OUTER_VALUE: "retained but not selected" },
|
|
metadata: { selected: "outer registration" },
|
|
},
|
|
},
|
|
};
|
|
await writeFile(config, JSON.stringify(nested));
|
|
const refreshed = await setupCline(cli, home);
|
|
expect(refreshed.exitCode, refreshed.stdout).toBe(0);
|
|
const after = await readFile(config, "utf8");
|
|
expect(JSON.parse(after)).toEqual(nested);
|
|
const doctor = await cli.run({
|
|
arguments: ["doctor", "--client", "cline", "--json"],
|
|
cwd: home,
|
|
environment: clineEnvironment(home),
|
|
});
|
|
expect(doctor.json).toMatchObject({
|
|
identity: {
|
|
registrations: [
|
|
expect.objectContaining({
|
|
client: "cline",
|
|
state: "aligned",
|
|
command_identity: "owned",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
expect((await setupCline(cli, home)).exitCode).toBe(0);
|
|
expect(await readFile(config, "utf8")).toBe(after);
|
|
const removal = await cli.run({
|
|
arguments: ["uninstall", "--json"],
|
|
cwd: home,
|
|
environment: clineEnvironment(home),
|
|
});
|
|
expect(removal.exitCode, removal.stdout).toBe(0);
|
|
expect(JSON.parse(await readFile(config, "utf8"))).toEqual({
|
|
...nested,
|
|
mcpServers: { peer: { command: "recorded-peer" } },
|
|
});
|
|
},
|
|
);
|