From dae1551403b661cf1af079ea3f7b6e98b0f0e0a2 Mon Sep 17 00:00:00 2001 From: Luqman Bramantyo Rahmadi Date: Thu, 12 Nov 2026 05:42:53 +0700 Subject: [PATCH] feat(setup): support Cline as a client (#10128) * 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> --- docs/development/client-verification.md | 31 ++ docs/getting-started/client-configuration.md | 45 +++ docs/getting-started/installation.md | 1 + package.json | 1 + scripts/verify-cline-client.mjs | 155 +++++++++ src/application/ClientConfigurationFiles.ts | 4 +- src/application/ClientRegistration.test.ts | 22 ++ src/application/ClientRegistration.ts | 111 +++--- .../ClientRegistrationConnection.ts | 7 +- .../ClientRegistrationEnvironment.ts | 34 +- src/application/ClientRegistrationSource.ts | 24 ++ src/application/ClientRegistrationStatus.ts | 4 +- src/application/ClineCliLocation.ts | 73 ++++ src/application/McpRegistrationLocations.ts | 51 +-- src/application/SetupClientConfiguration.ts | 8 +- src/application/SupportedClients.ts | 25 +- src/doctorRuntime.ts | 2 +- .../acceptance/setup/clineClientSetup.test.ts | 323 ++++++++++++++++++ .../clientConfigurationEnvironment.test.ts | 62 +++- .../filesystem/clientLifecycle.test.ts | 1 + ...clientRegistrationProviderSettings.test.ts | 6 +- .../filesystem/clineClientPaths.test.ts | 61 ++++ .../process/clineClientVerifier.test.ts | 144 ++++++++ tests/fixtures/clientEnvironment.ts | 7 + 24 files changed, 1081 insertions(+), 121 deletions(-) create mode 100644 scripts/verify-cline-client.mjs create mode 100644 src/application/ClientRegistrationSource.ts create mode 100644 src/application/ClineCliLocation.ts create mode 100644 tests/acceptance/setup/clineClientSetup.test.ts create mode 100644 tests/boundary/filesystem/clineClientPaths.test.ts create mode 100644 tests/boundary/process/clineClientVerifier.test.ts 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",