diff --git a/docs/development/client-verification.md b/docs/development/client-verification.md
index 24db5ce57e..a0934c1921 100644
--- a/docs/development/client-verification.md
+++ b/docs/development/client-verification.md
@@ -43,6 +43,37 @@ does not establish broader Pi compatibility. See the pinned upstream
[`declarations.ts`](https://github.com/earendil-works/pi/blob/b45e0ebcc5ae2fc42a6f2f23104d10e656e6bedb/packages/codemode/src/declarations.ts)
and [renderer usage notes](https://github.com/earendil-works/pi/blob/b45e0ebcc5ae2fc42a6f2f23104d10e656e6bedb/packages/codemode/README.md#declarations-for-the-model).
+## Cline
+
+The original contributor reported a run on 2026-11-11 on Linux (Fedora 45),
+using Cline CLI 3.0.74 and the integration based on REA `main` at `9924bf344`.
+That run checked client config listing and a direct registered-server MCP
+handshake (254 tools on that build). It predates the verifier lifecycle changes
+below and does not establish their real-client behavior.
+
+`npm run verify:cline-client` creates its sandbox home before launching commands
+and removes inherited `CLINE_MCP_SETTINGS_PATH`, `CLINE_DATA_DIR` and `CLINE_DIR`.
+Setup therefore exercises the default
+`~/.cline/data/settings/cline_mcp_settings.json` path under the sandboxed home.
+Cline's CLI must list that registration using the sandbox's explicit
+`--data-dir`; this checks the file parser, not independent default-home discovery.
+The exact registered launcher then
+completes SDK initialization and tool discovery through REA's owned stdio
+transport. Stdin stays open until discovery finishes. Command and transport
+cleanup completes before the verifier records lineage or reports success.
+
+The updated orchestration has recording-port coverage for delayed discovery,
+failed server identification and failed cleanup. These tests use the installed
+SDK and verifier lifecycle owner, but do not execute Cline or a real REA server.
+The updated real-client lane has not been rerun during this reconciliation.
+
+Not covered by the lane: a model-driven tool call inside Cline. Cline's provider
+configuration (`providers.json`) is versioned and auth-shaped, and the CLI ignores
+`OPENAI_BASE_URL`, so the loopback model fixture cannot drive it without depending
+on that private schema. The IDE extension is not covered either: its MCP settings are
+UI-managed and live under editor-specific `globalStorage` roots, which `rea setup`
+does not write in this release.
+
## llama.cpp
diff --git a/docs/getting-started/client-configuration.md b/docs/getting-started/client-configuration.md
index c8b8d937dd..77cd541f8b 100644
--- a/docs/getting-started/client-configuration.md
+++ b/docs/getting-started/client-configuration.md
@@ -734,6 +734,51 @@ The global MCP path is a plain JSON file; setup preserves every other server
and top-level key when it upserts `rea`. Loading this registration, the
bundled skill, and real REA tool calls through CodeBuddy are verified.
+## Cline
+
+MCP registration already supports both clients: `rea mcp add --agent cline`
+(or `cline-vscode`) configures the VS Code extension, while
+`rea mcp add --agent cline-cli` configures the CLI. The setup lifecycle uses
+`rea setup --client cline` and `rea doctor --client cline` for the CLI.
+`rea uninstall` also removes its REA-owned Cline CLI entry.
+
+For Cline's CLI, setup writes `mcpServers.rea` into
+`~/.cline/data/settings/cline_mcp_settings.json`; set `CLINE_MCP_SETTINGS_PATH`
+to select another file. Otherwise `CLINE_DATA_DIR` selects the data directory,
+then `CLINE_DIR` selects the root containing `data`; blank overrides are ignored.
+Setup and `mcp add --agent cline-cli` share this native path selection. Entries carry
+`command`, `args`, and `env`; stdio is the default transport. Setup preserves
+every other server and top-level key when it upserts `rea`, and refuses a
+`rea` entry it did not write rather than adopting or overwriting it.
+
+Existing nested `transport` registrations retain their representation during
+setup. In that shape, launch `env` and `cwd` belong inside `transport`;
+setup preserves those values and other nested metadata separately from outer
+registration policy.
+
+That refusal includes a hand-written `rea` entry: ownership is decided by
+comparing the entry's launcher with the running installation's entry point, so a
+registration that points at a different install path is reported as `foreign`
+and left unchanged. Rename or remove that entry, then rerun setup; doctor's
+remediation names the file and the entry to change.
+
+Cline discovers skills in the shared `~/.agents/skills` directory. Setup
+installs the bundled skill there,
+and `--skill-client cline` resolves to that shared directory; setup does not
+write a second copy under `~/.cline/skills`.
+
+The IDE extension keeps its own MCP settings, reached from the Cline panel's
+**Configure MCP Servers** action. Use `rea mcp add --agent cline` for that
+registration; `rea setup --client cline` configures the CLI only.
+
+Restart Cline after setup so the client reloads `mcpServers`. Cline's default
+MCP tool-call timeout is 120 seconds; native provider work that spends longer
+starting Hopper, Ghidra, or IDA can exceed it, so raise the timeout in Cline's
+MCP settings for long native investigations.
+
+Setup, doctor's alignment report, foreign-registration refusal, and uninstall
+removal are covered by `tests/acceptance/setup/clineClientSetup.test.ts`.
+
## WorkBuddy
For [WorkBuddy](https://www.workbuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide), setup writes `mcpServers.rea` into the
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index 54fdf3743d..7f38222537 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -683,6 +683,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| Codex | `codex` |
| Cursor | `cursor` |
| CodeBuddy | `codebuddy` |
+| Cline CLI | `cline` |
| Gemini CLI | `gemini_cli` |
| Windsurf | `windsurf` |
| Devin | `devin` |
diff --git a/package.json b/package.json
index f060dcf07d..bc5df267ea 100644
--- a/package.json
+++ b/package.json
@@ -279,6 +279,7 @@
"verify:opencode-client": "npm run build:cached && node scripts/verify-opencode-client.mjs",
"verify:grok-client": "npm run build:cached && node scripts/verify-grok-client.mjs",
"verify:commandcode-client": "npm run build:cached && node scripts/verify-commandcode-client.mjs",
+ "verify:cline-client": "npm run build:cached && node scripts/verify-cline-client.mjs",
"verify:omp-client": "npm run build:cached && node scripts/verify-omp-client.mjs",
"verify:copilot-client": "npm run build:cached && node scripts/verify-copilot-client.mjs",
"verify:claude-client": "npm run build:cached && node scripts/verify-claude-client.mjs",
diff --git a/scripts/verify-cline-client.mjs b/scripts/verify-cline-client.mjs
new file mode 100644
index 0000000000..a2e830d972
--- /dev/null
+++ b/scripts/verify-cline-client.mjs
@@ -0,0 +1,155 @@
+import assert from "node:assert/strict";
+import { Client } from "@modelcontextprotocol/client";
+import { mkdir, mkdtemp, readFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { createOwnedMcpStdioTransport } from "./lib/owned-mcp-stdio.mjs";
+import { createVerifierCommands } from "./lib/verify-commands.mjs";
+import { createVerifierMcpOwner } from "./lib/verifier-mcp-owner.mjs";
+import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
+import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
+
+/** Verify default-path CLI registration, client parsing and owned MCP discovery. */
+const repo = fileURLToPath(new URL("..", import.meta.url));
+const command = process.env.REA_VERIFY_CLINE_COMMAND ?? "cline";
+const reaEntry = join(repo, "scripts/rea.mjs");
+const run = createVerifierRun();
+const lab = await mkdtemp(join(tmpdir(), "rea-cline-client-"));
+const home = join(lab, "home");
+await mkdir(home, { recursive: true });
+const dataDirectory = join(home, ".cline", "data");
+const configPath = join(dataDirectory, "settings", "cline_mcp_settings.json");
+const locationOverrides = [
+ "CLINE_MCP_SETTINGS_PATH",
+ "CLINE_DATA_DIR",
+ "CLINE_DIR",
+];
+const isolatedOverrides = locationOverrides.filter(
+ (name) => process.env[name] !== undefined,
+);
+const environment = {
+ ...process.env,
+ HOME: home,
+ USERPROFILE: home,
+ HOPPER_LAUNCHER_PATH: join(lab, "missing-hopper"),
+ GHIDRA_INSTALL_DIR: join(lab, "missing-ghidra"),
+ JAVA_HOME: join(lab, "missing-java"),
+};
+// Exercise Cline's default path without letting inherited selectors escape HOME.
+for (const name of locationOverrides) delete environment[name];
+
+const commands = await createVerifierCommands();
+const report = await withVerifierCleanup(async () => {
+ let clineVersion;
+ try {
+ const { stdout } = await commands.exec(command, ["--version"], {
+ cwd: home,
+ env: environment,
+ timeout: 60_000,
+ });
+ clineVersion = stdout.trim();
+ } catch (cause) {
+ return {
+ status: "skipped",
+ reason: `Could not verify ${command} --version (${cause.code ?? cause.message}). Set REA_VERIFY_CLINE_COMMAND to an installed Cline CLI.`,
+ command,
+ };
+ }
+
+ await commands.exec(
+ process.execPath,
+ [
+ reaEntry,
+ "setup",
+ "--client",
+ "cline",
+ "--skill=false",
+ "--yes",
+ "--json",
+ ],
+ { cwd: home, env: environment, timeout: 180_000 },
+ );
+ const settings = JSON.parse(await readFile(configPath, "utf8"));
+ const registration = settings.mcpServers?.rea;
+ assert(
+ registration,
+ "setup must write mcpServers.rea into Cline's default settings file",
+ );
+ assert(Array.isArray(registration.args), "the registration must carry args");
+ assert.equal(
+ registration.args.at(-1),
+ "mcp",
+ "the registration must launch the MCP entry point",
+ );
+
+ const listed = await commands.exec(
+ command,
+ ["--data-dir", dataDirectory, "config", "mcp"],
+ { cwd: home, env: environment, timeout: 120_000 },
+ );
+ assert.match(
+ listed.stdout,
+ /rea \[stdio\]/,
+ "Cline must list the REA server REA wrote",
+ );
+
+ const mcp = createVerifierMcpOwner();
+ commands.adopt(mcp, "Cline MCP discovery");
+ const transport = await createOwnedMcpStdioTransport({
+ command: registration.command,
+ arguments: registration.args,
+ hostEnvironment: { ...environment, ...registration.env },
+ cwd: home,
+ expectedCommand: null,
+ sweepTokenOwnedProcesses: true,
+ });
+ const client = new Client({ name: "rea-cline-verifier", version: "1" });
+ // The SDK keeps stdin open through delayed initialization and tool discovery.
+ const connection = await mcp.open(client, transport);
+ assert.equal(
+ client.getServerVersion()?.name,
+ "rea",
+ "initialize must identify rea",
+ );
+ assert(
+ connection.definitions.size > 0,
+ "the registered server must expose tools",
+ );
+
+ return {
+ status: "verified",
+ client: "cline",
+ client_version: clineVersion,
+ configuration_path: configPath,
+ registration_command: registration.command,
+ registration_arguments: registration.args,
+ tool_count: connection.definitions.size,
+ covered: [
+ "rea setup writes the default Cline registration without path overrides",
+ "Cline lists the REA server from that file",
+ "the registered command completes owned MCP discovery and cleanup",
+ ],
+ unverified: [
+ "Cline's independent default-home discovery (listing selects --data-dir)",
+ "a model-driven tool call inside Cline (providers.json is versioned and auth-shaped)",
+ "the IDE extension, whose settings are UI-managed",
+ ],
+ isolated_overrides: isolatedOverrides,
+ };
+}, [() => commands.close()]);
+
+// Publish lineage only after every command and the MCP owner have finished cleanup.
+console.log(
+ JSON.stringify(
+ {
+ ...report,
+ process_run:
+ report.status === "verified" ? await completeVerifierRun(run) : run,
+ },
+ null,
+ 2,
+ ),
+);
+if (report.status === "skipped") process.exitCode = 2;
diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts
index a299236bea..d90351632e 100644
--- a/src/application/ClientConfigurationFiles.ts
+++ b/src/application/ClientConfigurationFiles.ts
@@ -129,7 +129,7 @@ export const readClientConfigurationFiles = async (
if (document.value !== undefined) {
const environment = clientRegistrationEnvironment(
document.value,
- client.name,
+ client,
path === client.configPath ? writeOverrides : undefined,
"fragment",
platform,
@@ -148,7 +148,7 @@ export const readClientConfigurationFiles = async (
}
const environment = clientRegistrationEnvironment(
effectiveClientConfiguration(documents, undefined, platform),
- client.name,
+ client,
writeOverrides,
"effective",
platform,
diff --git a/src/application/ClientRegistration.test.ts b/src/application/ClientRegistration.test.ts
index e5522ec019..21c95b72d0 100644
--- a/src/application/ClientRegistration.test.ts
+++ b/src/application/ClientRegistration.test.ts
@@ -262,3 +262,25 @@ it("retains malformed stored argv syntax for ownership while refusing launch rea
error: { kind: "invalid", cause: { _tag: "ConfigurationError" } },
});
});
+
+it("reads policy from the selected nested Cline launcher", () => {
+ const selected: SetupClient = {
+ name: "cline",
+ registrationProfile: supportedClientRegistrationProfile("cline"),
+ format: "json",
+ configPath: "/fixture/cline.json",
+ };
+ const parsed = parseClientRegistrationPolicy(
+ {
+ transport: { type: "stdio", command: "selected", args: ["mcp"] },
+ command: "shadowed",
+ args: ["ignored"],
+ disabled: true,
+ },
+ selected,
+ );
+ if (!parsed.ok) throw parsed.error;
+ expect(parsed.value.command).toBe("selected");
+ expect(parsed.value.args).toEqual(["mcp"]);
+ expect(parsed.value.disabled).toBe(true);
+});
diff --git a/src/application/ClientRegistration.ts b/src/application/ClientRegistration.ts
index d9c3120609..a0824ccc1f 100644
--- a/src/application/ClientRegistration.ts
+++ b/src/application/ClientRegistration.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { clientRegistrationSource } from "./ClientRegistrationSource.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { jsonRecordSchema } from "../domain/jsonRecord.js";
@@ -111,15 +112,7 @@ export const parseClientRegistrationLauncher = (
const { dialect, profile } = target;
if (profile.connection.kind === "unavailable")
return err(new ConfigurationError(profile.connection.reason));
- if (profile.connection.kind === "cline") {
- const row = clientConfigurationRecordSchema.safeParse(value);
- if (row.success && Object.hasOwn(row.data, "transport")) {
- const transport = clientConfigurationRecordSchema.safeParse(
- row.data.transport,
- );
- if (transport.success) value = transport.data;
- }
- }
+ value = clientRegistrationSource(value, profile).connection ?? value;
const parsed =
dialect === "opencode" ||
dialect === "opencode_v2" ||
@@ -209,6 +202,13 @@ export const parseClientRegistrationPolicy = (
if (!entry.success) return err(registrationError(entry.error));
value = entry.data;
}
+ const source = clientRegistrationSource(value, client.registrationProfile);
+ if (source.nested)
+ value = {
+ ...source.source,
+ command: source.connection?.command,
+ args: source.connection?.args,
+ };
const parsed = registrationSchema.safeParse(value);
if (!parsed.success) return err(registrationError(parsed.error));
const registration = parsed.data;
@@ -235,6 +235,8 @@ export const clientRegistrationSourceFacts = (
): Result<
{
readonly source: Record | undefined;
+ readonly connection: Record | undefined;
+ readonly nested: boolean;
readonly connectionFields: ReadonlySet;
},
ConfigurationError
@@ -243,8 +245,8 @@ export const clientRegistrationSourceFacts = (
const connection = profile.connection;
if (connection.kind === "unavailable")
return err(new ConfigurationError(connection.reason));
- const parsed = clientConfigurationRecordSchema.safeParse(previous);
- const source = parsed.success ? parsed.data : undefined;
+ const parsed = clientRegistrationSource(previous, profile);
+ const { source } = parsed;
const fields = new Set(
Object.keys(clientRegistrationLauncherEntry(target.dialect, command)),
);
@@ -282,7 +284,7 @@ export const clientRegistrationSourceFacts = (
: connection.fields)
fields.add(field);
}
- return ok({ source, connectionFields: fields });
+ return ok({ ...parsed, connectionFields: fields });
};
/** Explicit launcher replacement preserves policy and native environment, while
@@ -294,7 +296,7 @@ export const replaceClientRegistrationLauncher = (
previous: unknown,
inherited?: unknown,
): Result, ConfigurationError> => {
- const { dialect, profile } = target;
+ const { dialect } = target;
const facts = clientRegistrationSourceFacts(target, command, previous);
if (!facts.ok) return facts;
const { source, connectionFields: launcherConnectionKeys } = facts.value;
@@ -360,33 +362,37 @@ export const replaceClientRegistrationLauncher = (
)
delete defaults.enabled;
const launcher = clientRegistrationLauncherEntry(dialect, command);
- if (profile.connection.kind === "cline" && source !== undefined) {
- const nested = clientConfigurationRecordSchema.safeParse(source.transport);
- if (nested.success) {
- for (const key of Object.keys(launcher)) delete defaults[key];
- const retainedTransport = Object.fromEntries(
- Object.entries(nested.data).filter(
- ([key]) =>
- key !== "type" && key !== "url" && !Object.hasOwn(launcher, key),
- ),
- );
- return ok({
- ...defaults,
+ if (facts.value.nested)
+ for (const key of Object.keys(launcher)) delete defaults[key];
+ return ok(
+ writeRegistrationConnection(
+ facts.value,
+ { ...defaults, ...retained },
+ launcher,
+ ),
+ );
+};
+
+const writeRegistrationConnection = (
+ facts: ReturnType,
+ retained: Record,
+ fields: Record,
+): Record =>
+ facts.nested
+ ? {
...retained,
transport: {
- ...retainedTransport,
+ ...Object.fromEntries(
+ Object.entries(facts.connection ?? {}).filter(
+ ([key]) =>
+ key !== "type" && key !== "url" && !Object.hasOwn(fields, key),
+ ),
+ ),
type: "stdio",
- ...launcher,
+ ...fields,
},
- });
- }
- }
- return ok({
- ...defaults,
- ...retained,
- ...launcher,
- });
-};
+ }
+ : { ...retained, ...fields };
/** Whether a rea setup repair for this target preserves the row's
* `enable: false` instead of dropping it. Shared registration doubles as
@@ -424,15 +430,32 @@ export const refreshClientRegistration = (
equivalentClientRegistrationCommands(priorCommand, command)
? priorCommand
: command;
- const registration = {
- ...clientRegistrationEntry(dialect, launcher, environment),
- ...Object.fromEntries(
- Object.entries(existing).filter(
- ([key]) =>
- !facts.value.connectionFields.has(key) && !managedPolicyKeys.has(key),
+ const defaults = clientRegistrationEntry(dialect, launcher, environment);
+ const fields = facts.value.nested
+ ? {
+ ...clientRegistrationLauncherEntry(dialect, launcher),
+ ...(Object.keys(environment).length === 0
+ ? {}
+ : { env: { ...environment } }),
+ }
+ : clientRegistrationLauncherEntry(dialect, launcher);
+ if (facts.value.nested)
+ for (const key of Object.keys(fields)) delete defaults[key];
+ const registration = writeRegistrationConnection(
+ facts.value,
+ {
+ ...defaults,
+ ...Object.fromEntries(
+ Object.entries(existing).filter(
+ ([key]) =>
+ !facts.value.connectionFields.has(key) &&
+ (!managedPolicyKeys.has(key) ||
+ (facts.value.nested && key === "env")),
+ ),
),
- ),
- };
+ },
+ fields,
+ );
if (
setupPreservesDisabledActivation(client, dialect) &&
existing.enable === false
diff --git a/src/application/ClientRegistrationConnection.ts b/src/application/ClientRegistrationConnection.ts
index 37587a56db..88b33fbe82 100644
--- a/src/application/ClientRegistrationConnection.ts
+++ b/src/application/ClientRegistrationConnection.ts
@@ -1,3 +1,4 @@
+import { clientRegistrationSource } from "./ClientRegistrationSource.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { processArgumentSchema } from "../domain/process/processArgument.js";
@@ -122,9 +123,9 @@ export const admitClientStdioConnection = (
});
}
if (connection.kind === "cline") {
- const nested = clientConfigurationRecordSchema.safeParse(source.transport);
- const type = nested.success
- ? nested.data.type
+ const selected = clientRegistrationSource(source, target.profile);
+ const type = selected.nested
+ ? selected.connection?.type
: (source.type ?? source.transportType);
if (type !== undefined && type !== "stdio")
return err({
diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts
index 11734598e8..70dc16fe19 100644
--- a/src/application/ClientRegistrationEnvironment.ts
+++ b/src/application/ClientRegistrationEnvironment.ts
@@ -1,3 +1,6 @@
+import { clientRegistrationSource } from "./ClientRegistrationSource.js";
+import type { SetupClient } from "./SupportedClients.js";
+
import {
inspectAnalysisSettingSeeds,
launchAnalysisSetting,
@@ -98,14 +101,14 @@ const expressionSettings = (
/** Read explicit REA overrides without dropping peers when one value is invalid. */
export const clientRegistrationEnvironment = (
parsed: ClientConfigurationDocument | undefined,
- clientName: string | undefined,
+ client: Pick | undefined,
overrides: Readonly> = {},
validation: "fragment" | "effective" = "effective",
platform: NodeJS.Platform = process.platform,
): Result>, ConfigurationError> => {
const details = clientRegistrationEnvironmentDetails(
parsed,
- clientName,
+ client,
overrides,
validation,
platform,
@@ -116,7 +119,7 @@ export const clientRegistrationEnvironment = (
/** Retain original strings and the parser-owned timeout facts. */
export const clientRegistrationEnvironmentDetails = (
parsed: ClientConfigurationDocument | undefined,
- clientName: string | undefined,
+ client: Pick | undefined,
overrides: Readonly> = {},
validation: "fragment" | "effective" = "effective",
platform: NodeJS.Platform = process.platform,
@@ -141,16 +144,21 @@ export const clientRegistrationEnvironmentDetails = (
parsed === undefined
? undefined
: effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
- const raw =
- typeof registration === "object" && registration !== null
- ? Reflect.get(registration, key)
- : undefined;
+ const source = clientRegistrationSource(
+ registration,
+ client?.registrationProfile,
+ );
+ const raw = source.connection?.[key];
const existing = environmentSchema.safeParse(raw === undefined ? {} : raw);
if (!existing.success)
return err(
new ConfigurationError("Invalid REA server environment", {
settings: existing.error.issues.map((issue) => ({
- setting: [key, ...issue.path].join("."),
+ setting: [
+ ...(source.nested ? ["transport"] : []),
+ key,
+ ...issue.path,
+ ].join("."),
constraint:
"Server environment must be an object containing only string values",
})),
@@ -167,7 +175,7 @@ export const clientRegistrationEnvironmentDetails = (
environment,
validation,
platform,
- expressionSettings(clientName, environment),
+ expressionSettings(client?.name, environment),
);
};
@@ -197,7 +205,7 @@ export type ProviderSettingObserver = (name: string, value: string) => void;
*/
export const recordedProviderSettings = (
parsed: ClientConfigurationDocument,
- clientName: string | undefined,
+ client: Pick | undefined,
selected: Readonly>,
platform: NodeJS.Platform = process.platform,
owns?: OwnedProviderSetting,
@@ -210,7 +218,7 @@ export const recordedProviderSettings = (
if (names.length === 0) return undefined;
const details = clientRegistrationEnvironmentDetails(
parsed,
- clientName,
+ client,
{},
"fragment",
platform,
@@ -261,7 +269,7 @@ export const recordedProviderSettings = (
/** Resolve the async provider-specific budget through its retained reader owner. */
export const clientRegistrationEnvironmentDetailsWithBudget = async (
parsed: ClientConfigurationDocument | undefined,
- clientName: string | undefined,
+ client: Pick | undefined,
overrides: Readonly>,
validation: "fragment" | "effective",
resolveProviderRequestBudget: ProviderRequestBudgetResolver | undefined,
@@ -271,7 +279,7 @@ export const clientRegistrationEnvironmentDetailsWithBudget = async (
> => {
const details = clientRegistrationEnvironmentDetails(
parsed,
- clientName,
+ client,
overrides,
validation,
platform,
diff --git a/src/application/ClientRegistrationSource.ts b/src/application/ClientRegistrationSource.ts
new file mode 100644
index 0000000000..5cf3210141
--- /dev/null
+++ b/src/application/ClientRegistrationSource.ts
@@ -0,0 +1,24 @@
+import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js";
+import type { ClientRegistrationProfile } from "./ClientRegistrationProfile.js";
+
+/** Native source placement is selected by the registration's connection profile. */
+export const clientRegistrationSource = (
+ value: unknown,
+ profile: ClientRegistrationProfile | undefined,
+): {
+ readonly source: Record | undefined;
+ readonly connection: Record | undefined;
+ readonly nested: boolean;
+} => {
+ const parsed = clientConfigurationRecordSchema.safeParse(value);
+ if (!parsed.success)
+ return { source: undefined, connection: undefined, nested: false };
+ if (profile?.connection.kind === "cline") {
+ const transport = clientConfigurationRecordSchema.safeParse(
+ parsed.data.transport,
+ );
+ if (transport.success)
+ return { source: parsed.data, connection: transport.data, nested: true };
+ }
+ return { source: parsed.data, connection: parsed.data, nested: false };
+};
diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts
index 935657e74d..51a90e8ff3 100644
--- a/src/application/ClientRegistrationStatus.ts
+++ b/src/application/ClientRegistrationStatus.ts
@@ -340,7 +340,7 @@ export const readClientRegistrationInspections = async (
compactInputSchemaByDefault(client) || client.name === "codex"
? await clientRegistrationEnvironmentDetailsWithBudget(
parsed,
- client.name,
+ client,
{},
"effective",
client.name === "kimi" || client.name === "codex"
@@ -434,7 +434,7 @@ export const readClientRegistrationInspections = async (
? undefined
: recordedProviderSettings(
parsed,
- client.name,
+ client,
options.providerSettings,
options.platform,
options.ownedProviderSetting,
diff --git a/src/application/ClineCliLocation.ts b/src/application/ClineCliLocation.ts
new file mode 100644
index 0000000000..4d04ae2c74
--- /dev/null
+++ b/src/application/ClineCliLocation.ts
@@ -0,0 +1,73 @@
+import { posix, win32 } from "node:path";
+
+import { ConfigurationError } from "../domain/configurationErrors.js";
+import { err, ok, type Result } from "../domain/result.js";
+
+/** Cline CLI's settings selection, shared by setup and explicit MCP registration. */
+export const resolveClineCliSettingsPath = (context: {
+ readonly home: string;
+ readonly platform: NodeJS.Platform;
+ readonly environment: Readonly;
+}): Result => {
+ const { environment, home, platform } = context;
+ const pathApi = platform === "win32" ? win32 : posix;
+ const explicitPath = environment.CLINE_MCP_SETTINGS_PATH?.trim();
+ if (explicitPath) return ok(explicitPath);
+ const explicitData = environment.CLINE_DATA_DIR?.trim();
+ if (explicitData)
+ return ok(
+ pathApi.join(explicitData, "settings", "cline_mcp_settings.json"),
+ );
+ const explicitDirectory = environment.CLINE_DIR?.trim();
+ if (explicitDirectory)
+ return ok(
+ pathApi.join(
+ explicitDirectory,
+ "data",
+ "settings",
+ "cline_mcp_settings.json",
+ ),
+ );
+ const envHome = environment.HOME?.trim();
+ const profile = environment.USERPROFILE?.trim();
+ const drive = environment.HOMEDRIVE?.trim();
+ const path = environment.HOMEPATH?.trim();
+ // Cline falls back to os.homedir(), then to the literal "~". On POSIX,
+ // a present HOME is returned by os.homedir() even if empty or "~".
+ // Otherwise use the supplied account home only when it was not selected
+ // from a rejected environment value; that value cannot prove the OS home.
+ const rejectedHome = platform !== "win32" && environment.HOME !== undefined;
+ const fallbackUncertain =
+ (platform === "win32" &&
+ (environment.USERPROFILE !== undefined ||
+ Boolean(environment.HOMEDRIVE && environment.HOMEPATH) ||
+ Boolean(environment.HOME))) ||
+ (platform !== "win32" &&
+ environment.HOME === undefined &&
+ environment.USERPROFILE !== undefined);
+ const nativeHome = rejectedHome
+ ? "~"
+ : fallbackUncertain
+ ? undefined
+ : home.trim() || "~";
+ const clineHome =
+ (envHome && envHome !== "~" ? envHome : undefined) ||
+ profile ||
+ (drive && path ? `${drive}${path}` : undefined) ||
+ nativeHome;
+ if (!clineHome)
+ return err(
+ new ConfigurationError(
+ "Cannot determine Cline CLI's system home from the selected environment. Set CLINE_MCP_SETTINGS_PATH, CLINE_DATA_DIR, or CLINE_DIR to select its configuration location.",
+ ),
+ );
+ return ok(
+ pathApi.join(
+ clineHome,
+ ".cline",
+ "data",
+ "settings",
+ "cline_mcp_settings.json",
+ ),
+ );
+};
diff --git a/src/application/McpRegistrationLocations.ts b/src/application/McpRegistrationLocations.ts
index a2b5987f25..b5ff1dbabd 100644
--- a/src/application/McpRegistrationLocations.ts
+++ b/src/application/McpRegistrationLocations.ts
@@ -1,6 +1,7 @@
import { lstat } from "node:fs/promises";
import { dirname, join, posix, win32 } from "node:path";
+import { resolveClineCliSettingsPath } from "./ClineCliLocation.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { SetupClient, SupportedClientName } from "./SupportedClients.js";
@@ -122,52 +123,10 @@ export const clineLocation = async (
export const clineCliLocation = async (
context: McpRegistrationLocationContext,
): Promise => {
- const { environment, home } = context;
- const explicitPath = environment.CLINE_MCP_SETTINGS_PATH?.trim();
- if (explicitPath) return location(explicitPath);
- const explicitData = environment.CLINE_DATA_DIR?.trim();
- if (explicitData)
- return location(join(explicitData, "settings", "cline_mcp_settings.json"));
- const explicitDirectory = environment.CLINE_DIR?.trim();
- if (explicitDirectory)
- return location(
- join(explicitDirectory, "data", "settings", "cline_mcp_settings.json"),
- );
- const envHome = environment.HOME?.trim();
- const profile = environment.USERPROFILE?.trim();
- const drive = environment.HOMEDRIVE?.trim();
- const path = environment.HOMEPATH?.trim();
- // Cline falls back to os.homedir(), then to the literal "~". On POSIX,
- // a present HOME is returned by os.homedir() even if empty or "~".
- // Otherwise use the supplied account home only when it was not selected
- // from a rejected environment value; that value cannot prove the OS home.
- const rejectedHome =
- context.platform !== "win32" && environment.HOME !== undefined;
- const fallbackUncertain =
- (context.platform === "win32" &&
- (environment.USERPROFILE !== undefined ||
- Boolean(environment.HOMEDRIVE && environment.HOMEPATH) ||
- Boolean(environment.HOME))) ||
- (context.platform !== "win32" &&
- environment.HOME === undefined &&
- environment.USERPROFILE !== undefined);
- const nativeHome = rejectedHome
- ? "~"
- : fallbackUncertain
- ? undefined
- : home.trim() || "~";
- const clineHome =
- (envHome && envHome !== "~" ? envHome : undefined) ||
- profile ||
- (drive && path ? `${drive}${path}` : undefined) ||
- nativeHome;
- if (!clineHome)
- throw new ConfigurationError(
- "Cannot determine Cline CLI's system home from the selected environment. Set CLINE_MCP_SETTINGS_PATH, CLINE_DATA_DIR, or CLINE_DIR to select its configuration location.",
- );
- return location(
- join(clineHome, ".cline", "data", "settings", "cline_mcp_settings.json"),
- );
+ const selected = resolveClineCliSettingsPath(context);
+ if (!selected.ok) throw selected.error;
+ const pathApi = context.platform === "win32" ? win32 : posix;
+ return location(selected.value, pathApi.dirname(selected.value));
};
export const gooseLocation = async (
context: McpRegistrationLocationContext,
diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts
index d21551f4f4..7cddd994ba 100644
--- a/src/application/SetupClientConfiguration.ts
+++ b/src/application/SetupClientConfiguration.ts
@@ -787,7 +787,7 @@ const readZcodeFallbackServers = async (
path,
detail: ownershipBlocker,
});
- const environment = clientRegistrationEnvironment(parsed, client.name);
+ const environment = clientRegistrationEnvironment(parsed, client);
if (!environment.ok)
return err({
kind: "malformed",
@@ -908,7 +908,7 @@ const clientConfigurationDesired = async (
if (format === "dsh") {
const validated = clientRegistrationEnvironment(
effective,
- client.name,
+ client,
providerEnvironment,
);
if (!validated.ok) throw validated.error;
@@ -922,7 +922,7 @@ const clientConfigurationDesired = async (
}
const validated = await clientRegistrationEnvironmentDetailsWithBudget(
effective,
- client.name,
+ client,
providerEnvironment,
"effective",
client.name === "codex" || client.name === "kimi"
@@ -935,7 +935,7 @@ const clientConfigurationDesired = async (
// timeout facts; the target file's fragment remains the value being written.
const environment = clientRegistrationEnvironment(
parsed,
- client.name,
+ client,
providerEnvironment,
"fragment",
);
diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts
index 2ac9ee3e07..429dfe89d7 100644
--- a/src/application/SupportedClients.ts
+++ b/src/application/SupportedClients.ts
@@ -2,7 +2,7 @@ import type {
ClientRegistrationConnectionProfile,
ClientRegistrationProfile,
} from "./ClientRegistrationProfile.js";
-import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
+import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, lstatSync, readFileSync } from "node:fs";
import { err, ok, type Result } from "../domain/result.js";
@@ -13,6 +13,7 @@ import {
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { appendCallerPath } from "../filesystem/callerPath.js";
import { resolveWindowsClaudeDesktopLocation } from "./ClaudeDesktopConfigLocation.js";
+import { resolveClineCliSettingsPath } from "./ClineCliLocation.js";
import {
type MsixPackageView,
windowsAppDataRoots,
@@ -650,6 +651,28 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: [".codebuddy"],
format: "json",
}),
+ defineClient({
+ // Cline discovers the shared ~/.agents/skills directory. Keep REA's
+ // existing shared destination instead of managing another skill copy.
+ name: "cline",
+ registrationConnection: { kind: "cline" },
+ displayName: "Cline CLI",
+ location: ({ home, env, platform }: ClientPathContext) => {
+ const selected = resolveClineCliSettingsPath({
+ home,
+ platform,
+ environment: env,
+ });
+ const pathApi = platform === "win32" ? win32 : posix;
+ return selected.ok
+ ? ok({
+ configPath: selected.value,
+ markerPath: pathApi.dirname(selected.value),
+ })
+ : err({ path: "", message: selected.error.message });
+ },
+ format: "json",
+ }),
defineClient({
name: "gemini_cli",
registrationConnection: {
diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts
index 6533aaf7c8..aa36671a70 100644
--- a/src/doctorRuntime.ts
+++ b/src/doctorRuntime.ts
@@ -164,7 +164,7 @@ const readSelectedRegistration = async (
if (configuration.value.document === undefined) return undefined;
const details = clientRegistrationEnvironmentDetails(
configuration.value.document,
- client.name,
+ client,
{},
"effective",
platform,
diff --git a/tests/acceptance/setup/clineClientSetup.test.ts b/tests/acceptance/setup/clineClientSetup.test.ts
new file mode 100644
index 0000000000..e195f305b9
--- /dev/null
+++ b/tests/acceptance/setup/clineClientSetup.test.ts
@@ -0,0 +1,323 @@
+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 =>
+ 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" } },
+ });
+ },
+);
diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts
index 94bbfd436d..42d613f2b2 100644
--- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts
+++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts
@@ -1011,7 +1011,10 @@ it("treats a variable-expanding client's recorded expression as unresolved", ()
}),
"json",
);
- const qwen = clientRegistrationEnvironmentDetails(parsed, "qwen_code");
+ const qwen = clientRegistrationEnvironmentDetails(parsed, {
+ name: "qwen_code",
+ registrationProfile: supportedClientRegistrationProfile("qwen_code"),
+ });
if (!qwen.ok) throw qwen.error;
// Qwen expands the expression from its own environment, so REA records the
// name as unresolved rather than treating the literal text as the value.
@@ -1019,7 +1022,10 @@ it("treats a variable-expanding client's recorded expression as unresolved", ()
expect(qwen.value.values.PATH).toBe("${PATH}:/opt/jdk/bin");
// A client that does not expand expressions keeps the same text as a value.
- const cursor = clientRegistrationEnvironmentDetails(parsed, "cursor");
+ const cursor = clientRegistrationEnvironmentDetails(parsed, {
+ name: "cursor",
+ registrationProfile: supportedClientRegistrationProfile("cursor"),
+ });
if (!cursor.ok) throw cursor.error;
expect(cursor.value.unresolvedSettings).toEqual(new Set());
expect(cursor.value.values.PATH).toBe("${PATH}:/opt/jdk/bin");
@@ -1038,7 +1044,10 @@ it("keeps a typed Qwen setting unresolved without discarding its recorded expres
}),
"json",
);
- const details = clientRegistrationEnvironmentDetails(parsed, "qwen_code");
+ const details = clientRegistrationEnvironmentDetails(parsed, {
+ name: "qwen_code",
+ registrationProfile: supportedClientRegistrationProfile("qwen_code"),
+ });
expect(details.ok).toBe(true);
if (!details.ok) return;
expect(details.value.values.REA_GHIDRA_STARTUP_TIMEOUT_MS).toBe("${JOBS}");
@@ -1062,7 +1071,10 @@ it("keeps a differently cased Qwen expression unresolved on Windows", () => {
);
const details = clientRegistrationEnvironmentDetails(
parsed,
- "qwen_code",
+ {
+ name: "qwen_code",
+ registrationProfile: supportedClientRegistrationProfile("qwen_code"),
+ },
{},
"fragment",
"win32",
@@ -1073,3 +1085,45 @@ it("keeps a differently cased Qwen expression unresolved on Windows", () => {
[...details.value.unresolvedSettings].map((name) => name.toUpperCase()),
).toEqual(["REA_GHIDRA_STARTUP_TIMEOUT_MS"]);
});
+
+it.each([
+ ["cline", "selected"],
+ ["cline", 42],
+ ["cursor", "metadata"],
+ ["cursor", 42],
+] as const)(
+ "reads only the profile-selected %s environment with nested value %j",
+ (name, value) => {
+ const parsed = parseClientConfiguration(
+ JSON.stringify({
+ mcpServers: {
+ rea: {
+ command: "outer",
+ env: { SELECTED: "outer" },
+ transport: {
+ type: "stdio",
+ command: "nested",
+ env: { SELECTED: value },
+ },
+ },
+ },
+ }),
+ "json",
+ );
+ const details = clientRegistrationEnvironmentDetails(parsed, {
+ name,
+ registrationProfile: supportedClientRegistrationProfile(name),
+ });
+ if (name === "cline" && typeof value !== "string") {
+ expect(details).toMatchObject({
+ ok: false,
+ error: { settings: [{ setting: "transport.env.SELECTED" }] },
+ });
+ } else {
+ expect(details).toMatchObject({
+ ok: true,
+ value: { values: { SELECTED: name === "cline" ? value : "outer" } },
+ });
+ }
+ },
+);
diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts
index 143c3f5271..25b150612c 100644
--- a/tests/boundary/filesystem/clientLifecycle.test.ts
+++ b/tests/boundary/filesystem/clientLifecycle.test.ts
@@ -55,6 +55,7 @@ describe("client configuration filesystem lifecycle", () => {
"codex",
"cursor",
"codebuddy",
+ "cline",
"gemini_cli",
"windsurf",
"devin",
diff --git a/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts
index a9a15cf662..beffc6b1e8 100644
--- a/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts
+++ b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js";
import { recordedProviderSettings } from "../../../src/application/ClientRegistrationEnvironment.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
@@ -342,7 +343,10 @@ it("does not classify an unresolved Qwen expression as a differing literal", ()
expect(
recordedProviderSettings(
parsed,
- "qwen_code",
+ {
+ name: "qwen_code",
+ registrationProfile: supportedClientRegistrationProfile("qwen_code"),
+ },
{ REA_KUNA_COMMAND: "/opt/kuna" },
"linux",
),
diff --git a/tests/boundary/filesystem/clineClientPaths.test.ts b/tests/boundary/filesystem/clineClientPaths.test.ts
new file mode 100644
index 0000000000..5248a0a988
--- /dev/null
+++ b/tests/boundary/filesystem/clineClientPaths.test.ts
@@ -0,0 +1,61 @@
+import { expect, it } from "vitest";
+
+import { supportedClients } from "../../../src/application/SupportedClients.js";
+
+const cline = (home: string, platform: NodeJS.Platform, env = {}) => {
+ const client = supportedClients(home, platform, env).find(
+ ({ name }) => name === "cline",
+ );
+ expect(client, "cline must be a supported client").toBeDefined();
+ return client!;
+};
+
+it("resolves Cline's POSIX configuration and marker paths", () => {
+ const client = cline("/home/x", "linux");
+ expect(client.configPath).toBe(
+ "/home/x/.cline/data/settings/cline_mcp_settings.json",
+ );
+ expect(client.markerPath).toBe("/home/x/.cline/data/settings");
+});
+
+it("resolves Cline's Windows configuration path", () => {
+ const client = cline("C:\\Users\\x", "win32");
+ expect(client.configPath).toBe(
+ "C:\\Users\\x\\.cline\\data\\settings\\cline_mcp_settings.json",
+ );
+});
+
+it("prefers CLINE_MCP_SETTINGS_PATH over the default location", () => {
+ const client = cline("/home/x", "linux", {
+ CLINE_MCP_SETTINGS_PATH: "/custom/settings.json",
+ });
+ expect(client.configPath).toBe("/custom/settings.json");
+});
+
+it.each([
+ {
+ env: {
+ CLINE_MCP_SETTINGS_PATH: " ",
+ CLINE_DATA_DIR: " /selected/data ",
+ CLINE_DIR: "/ignored",
+ },
+ expected: "/selected/data/settings/cline_mcp_settings.json",
+ },
+ {
+ env: { CLINE_DATA_DIR: " ", CLINE_DIR: " /selected/root " },
+ expected: "/selected/root/data/settings/cline_mcp_settings.json",
+ },
+])("uses the native CLI override precedence for setup", ({ env, expected }) => {
+ const client = cline("/home/x", "linux", env);
+ expect(client.configPath).toBe(expected);
+ expect(client.markerPath).toBe(expected.slice(0, expected.lastIndexOf("/")));
+ expect(client.registrationProfile.connection).toEqual({ kind: "cline" });
+});
+
+it("keeps an unresolved native home unavailable to setup", () => {
+ const client = cline("C:\\fallback", "win32", { USERPROFILE: " " });
+ expect(client.configPathError).toContain(
+ "Cannot determine Cline CLI's system home",
+ );
+ expect(client.configPath).toBe("");
+});
diff --git a/tests/boundary/process/clineClientVerifier.test.ts b/tests/boundary/process/clineClientVerifier.test.ts
new file mode 100644
index 0000000000..9e35afc68a
--- /dev/null
+++ b/tests/boundary/process/clineClientVerifier.test.ts
@@ -0,0 +1,144 @@
+import { spawnSync } from "node:child_process";
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, it } from "vitest";
+
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+it.each(["success", "wrong-server", "cleanup-failure"] as const)(
+ "owns Cline verifier orchestration through %s",
+ async (mode) => {
+ const state = await createTestTempDirectory("rea-cline-verifier-");
+ const fixture = join(state, "owners.mjs");
+ const hook = join(state, "hook.mjs");
+ await writeFile(
+ fixture,
+ `import assert from 'node:assert/strict';
+import {appendFile, mkdir, stat, writeFile} from 'node:fs/promises';
+import {join} from 'node:path';
+const event = async (value) => appendFile(process.env.STATE+'/events',value+'\\n');
+export async function createVerifierCommands(){
+ const owners=[];
+ return {
+ adopt(owner){owners.push(owner);},
+ async exec(command,args,options={}){
+ const env=options.env;
+ assert(env,'commands must receive isolated environment');
+ assert.equal((await stat(env.HOME)).isDirectory(),true,'HOME must exist before any command');
+ assert.equal(env.USERPROFILE,env.HOME);
+ for(const key of ['CLINE_MCP_SETTINGS_PATH','CLINE_DATA_DIR','CLINE_DIR']) assert.equal(env[key],undefined,key+' must be absent');
+ if(args.includes('--version')){await event('version');return {stdout:'recorded Cline 1',stderr:''};}
+ if(args.includes('setup')){
+ assert(args.includes('cline'));assert.equal(options.cwd,env.HOME);
+ const path=join(env.HOME,'.cline','data','settings');await mkdir(path,{recursive:true});
+ await writeFile(join(path,'cline_mcp_settings.json'),JSON.stringify({mcpServers:{rea:{command:'recorded-server',args:['mcp'],env:{RECORDED:'yes'}}}}));
+ await event('setup');return {stdout:'{}',stderr:''};
+ }
+ assert.deepEqual(args,['--data-dir',join(env.HOME,'.cline','data'),'config','mcp']);
+ await event('client-list');return {stdout:'rea [stdio]',stderr:''};
+ },
+ async close(){await event('commands-close-start');for(const owner of owners)await owner.close();await event('commands-close-done');}
+ };
+}
+export async function createOwnedMcpStdioTransport(options){
+ assert.equal(options.command,'recorded-server');assert.deepEqual(options.arguments,['mcp']);
+ assert.equal(options.hostEnvironment.RECORDED,'yes');
+ let closing;
+ return {
+ async start(){await event('transport-start');},
+ async send(message){
+ if(message.method==='notifications/initialized'){await event('initialized');return;}
+ if(message.method==='initialize'||message.method==='tools/list'){
+ await event(message.method+'-request');
+ setTimeout(async()=>{
+ await event(message.method+'-reply');
+ this.onmessage?.({jsonrpc:'2.0',id:message.id,result:message.method==='initialize'?{protocolVersion:'2025-11-25',capabilities:{tools:{}},serverInfo:{name:process.env.MODE==='wrong-server'?'other':'rea',version:'1'}}:{tools:[{name:'recorded_tool',inputSchema:{type:'object'}}]}});
+ },15);
+ }
+ },
+ close(){return closing??= (async()=>{
+ await event('transport-close-start');await new Promise(resolve=>setTimeout(resolve,15));
+ if(process.env.MODE==='cleanup-failure'){await event('transport-close-failed');throw Error('recorded transport cleanup failure');}
+ await event('transport-close-done');this.onclose?.();
+ })();}
+ };
+}
+export function createVerifierRun(){return {run_id:'recorded-run'};}
+export async function completeVerifierRun(run){await event('lineage');return {...run,process_lineage:{status:'recorded',descendants:[]}};}
+export function execFile(){throw Error('unowned child_process execution is forbidden in this orchestration fixture');}
+export function spawn(){throw Error('unowned child_process execution is forbidden in this orchestration fixture');}
+`,
+ );
+ await writeFile(
+ hook,
+ `import {registerHooks} from 'node:module';import{pathToFileURL}from'node:url';
+registerHooks({resolve(specifier,context,next){
+ if(context.parentURL?.endsWith('/scripts/verify-cline-client.mjs')&&['./lib/verify-commands.mjs','./lib/owned-mcp-stdio.mjs','./lib/verifier-run.mjs','node:child_process'].includes(specifier))return{url:pathToFileURL(process.env.STATE+'/owners.mjs').href,shortCircuit:true};
+ return next(specifier,context);
+}});
+`,
+ );
+ const script = fileURLToPath(
+ new URL("../../../scripts/verify-cline-client.mjs", import.meta.url),
+ );
+ const result = spawnSync(process.execPath, ["--import", hook, script], {
+ encoding: "utf8",
+ timeout: 10_000,
+ env: {
+ ...process.env,
+ STATE: state,
+ MODE: mode,
+ REA_VERIFY_CLINE_COMMAND: "recorded-cline",
+ CLINE_MCP_SETTINGS_PATH: join(state, "outside-settings.json"),
+ CLINE_DATA_DIR: join(state, "outside-data"),
+ CLINE_DIR: join(state, "outside-root"),
+ TMPDIR: state,
+ TMP: state,
+ TEMP: state,
+ },
+ });
+ const events = await readFile(join(state, "events"), "utf8").catch(
+ () => "",
+ );
+ expect(result.error).toBeUndefined();
+ expect(events, result.stderr + result.stdout).toContain(
+ "version\nsetup\nclient-list\n",
+ );
+ expect(events).toContain("initialize-reply\n");
+ expect(events).toContain("tools/list-reply\n");
+ expect(events).toContain("commands-close-start\n");
+ expect(events.indexOf("tools/list-reply\n")).toBeLessThan(
+ events.indexOf("transport-close-start\n"),
+ );
+ if (mode === "cleanup-failure") {
+ expect(result.status).not.toBe(0);
+ expect(result.stderr).toContain("recorded transport cleanup failure");
+ expect(events).toContain("transport-close-failed\n");
+ expect(events).not.toContain("lineage\n");
+ expect(result.stdout).not.toContain('"status": "verified"');
+ } else {
+ expect(events).toContain("transport-close-done\n");
+ expect(events).toContain("commands-close-done\n");
+ if (mode === "wrong-server") {
+ expect(result.status).not.toBe(0);
+ expect(result.stderr).toMatch(/initialize|identify|server|rea/u);
+ expect(result.stdout).not.toContain('"status": "verified"');
+ } else {
+ expect(result.status, result.stderr).toBe(0);
+ const report = JSON.parse(result.stdout) as {
+ status: string;
+ tool_count: number;
+ };
+ expect(report.status).toBe("verified");
+ expect(report.tool_count).toBe(1);
+ expect(events.indexOf("transport-close-done\n")).toBeLessThan(
+ events.indexOf("lineage\n"),
+ );
+ expect(events.indexOf("commands-close-done\n")).toBeLessThan(
+ events.indexOf("lineage\n"),
+ );
+ }
+ }
+ },
+);
diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts
index 66a10c2ea7..78c9cca555 100644
--- a/tests/fixtures/clientEnvironment.ts
+++ b/tests/fixtures/clientEnvironment.ts
@@ -2,7 +2,14 @@ import { vi } from "vitest";
/** Environment variables that redirect where supported clients keep config. */
export const CLIENT_LOCATION_ENVIRONMENT = [
+ "HOME",
+ "USERPROFILE",
+ "HOMEDRIVE",
+ "HOMEPATH",
"APPDATA",
+ "CLINE_MCP_SETTINGS_PATH",
+ "CLINE_DATA_DIR",
+ "CLINE_DIR",
"CLAUDE_CONFIG_DIR",
"CODEX_HOME",
"DSH_HOME",