diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index f36446f143..a26c32c7aa 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -158,6 +158,13 @@ const parseDocument = ( // ZCode loads nonempty native files with strict JSON.parse. if (format === "zcode") return requireObject(JSON.parse(text), "The configuration root"); + // Claude Code loads `.claude.json` with strict JSON.parse after a UTF-8 BOM, + // so comments and trailing commas it rejects must not be admitted as JSONC. + if (format === "claude_code") + return requireObject( + JSON.parse(text.replace(/^\uFEFF/, "")), + "The configuration root", + ); const errors: ParseError[] = []; // Accept a UTF-8 BOM without shifting diagnostics or editing the original text. const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index a45599578a..fef2fd32d9 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -37,6 +37,7 @@ export interface SetupClient { readonly quitBeforeSetup?: boolean; readonly format?: | "json" + | "claude_code" | "toml" | "vscode" | "copilot_cli" @@ -491,7 +492,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ configPath: (context: ClientPathContext) => join(claudeCodeConfigDirectory(context), ".claude.json"), markerPath: claudeCodeMarkerDirectory, - format: "json", + format: "claude_code", }, { name: "claude_desktop", diff --git a/tests/acceptance/setup/claudeCodeStrictJsonSetup.test.ts b/tests/acceptance/setup/claudeCodeStrictJsonSetup.test.ts new file mode 100644 index 0000000000..730628a0d4 --- /dev/null +++ b/tests/acceptance/setup/claudeCodeStrictJsonSetup.test.ts @@ -0,0 +1,128 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +const peer = '"peer": { "command": "/usr/bin/true", "args": [] }'; + +const runIn = (cli: TestCli, home: string) => (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + }); + +const setupArguments = [ + "setup", + "--client", + "claude_code", + "--skill=false", + "--json", +] as const; +const doctorArguments = [ + "doctor", + "--client", + "claude_code", + "--skill=false", + "--json", +] as const; + +// Native Claude Code rejects these documents with its strict JSON reader. +for (const [syntax, original] of [ + [ + "a comment", + `{\n // preserve this unrelated setting\n "mcpServers": {\n ${peer}\n }\n}\n`, + ], + ["a trailing comma", `{\n "mcpServers": {\n ${peer},\n }\n}\n`], +] as const) + cliTest( + `Claude Code setup, doctor, and uninstall refuse JSON with ${syntax}`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-claude-strict-json-"); + const config = join(home, ".claude.json"); + await writeFile(config, original); + const run = runIn(cli, home); + + for (const mode of ["--dry-run", "--yes"]) { + const setup = await run([...setupArguments, mode]); + expect(setup.exitCode, setup.stdout).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("malformed"), + }); + } + const doctor = await run(doctorArguments); + expect(doctor.exitCode).toBe(1); + expect(doctor.json).toMatchObject({ + healthy: false, + identity: { + registrations: [ + expect.objectContaining({ + client: "claude_code", + state: "invalid", + }), + ], + }, + }); + const removed = await run(["uninstall", "--json"]); + expect(removed.exitCode).toBe(1); + expect(removed.json).toMatchObject({ + status: "failed", + items: expect.arrayContaining([ + expect.objectContaining({ name: "claude_code", status: "failed" }), + ]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(readFile(`${config}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + ); + +for (const prefix of ["", ""]) + cliTest( + `Claude Code setup configures strict JSON with prefix ${JSON.stringify(prefix)}`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-claude-strict-json-"); + const config = join(home, ".claude.json"); + const original = `${prefix}{\n "mcpServers": {\n ${peer}\n }\n}\n`; + await writeFile(config, original); + const run = runIn(cli, home); + + const setup = await run([...setupArguments, "--yes"]); + expect(setup.exitCode, setup.stdout).toBe(0); + expect(setup.json).toMatchObject({ + status: "ready", + clients: { claude_code: { status: "configured" } }, + }); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + const configured = await readFile(config, "utf8"); + expect(configured.startsWith(`${prefix}{`)).toBe(true); + expect(JSON.parse(configured.slice(prefix.length))).toMatchObject({ + mcpServers: { + peer: { command: "/usr/bin/true", args: [] }, + rea: { command: expect.any(String) }, + }, + }); + const doctor = await run(doctorArguments); + expect(doctor.json).toMatchObject({ + identity: { + registrations: [ + expect.objectContaining({ + client: "claude_code", + state: "aligned", + }), + ], + }, + }); + }, + ); diff --git a/tests/boundary/filesystem/clientConfigurationBom.test.ts b/tests/boundary/filesystem/clientConfigurationBom.test.ts index b742db420c..9eea4cb41e 100644 --- a/tests/boundary/filesystem/clientConfigurationBom.test.ts +++ b/tests/boundary/filesystem/clientConfigurationBom.test.ts @@ -19,86 +19,91 @@ import { supportedClients } from "../../../src/application/SupportedClients.js"; import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -describe.each([ - "claude_code", - "vscode", - "copilot_cli", - "opencode", - "commandcode", -])("client configuration for %s", (name) => { - it.each(["", "\uFEFF"])( - "preflights, configures, and reads back prefix %j in an isolated home", - async (bom) => { - const home = await createTestTempDirectory("rea-client-config-bom-"); - const client = supportedClients(home, "linux", {}).find( - (candidate) => candidate.name === name, - ); - if (client?.format === undefined) throw new Error(`missing ${name}`); - const serversKey = clientConfigurationServersKey(client.format); - const original = `${bom}// Keep the client preferences.\r\n${JSON.stringify( - { - label: "left\uFEFFright", - [serversKey]: { other: { command: "other" } }, - theme: "dark", - }, - null, - 2, - ).replaceAll("\n", "\r\n")}\r\n`; - await mkdir(dirname(client.configPath), { recursive: true }); - await writeFile(client.configPath, original); - const currentCommandPath = join(home, "rea"); - const command = [currentCommandPath, "mcp"]; - expect(await inspectClientConfiguration(client, {}, command)).toEqual({ - status: "update", - backupPath: `${client.configPath}.rea.backup`, - }); - expect(await clientConfigurationAligned(client, {}, command)).toBe(false); - expect(await configureClientConfiguration(client, {}, command)).toEqual({ - status: "configured", - backupPath: `${client.configPath}.rea.backup`, - }); - const updated = await readFile(client.configPath, "utf8"); - expect( - updated.startsWith(`${bom}// Keep the client preferences.\r\n`), - ).toBe(true); - expect(parseClientConfiguration(updated, client.format).document).toEqual( - { +describe.each(["vscode", "copilot_cli", "opencode", "commandcode"])( + "client configuration for %s", + (name) => { + it.each(["", "\uFEFF"])( + "preflights, configures, and reads back prefix %j in an isolated home", + async (bom) => { + const home = await createTestTempDirectory("rea-client-config-bom-"); + const client = supportedClients(home, "linux", {}).find( + (candidate) => candidate.name === name, + ); + if (client?.format === undefined) throw new Error(`missing ${name}`); + const serversKey = clientConfigurationServersKey(client.format); + const original = `${bom}// Keep the client preferences.\r\n${JSON.stringify( + { + label: "left\uFEFFright", + [serversKey]: { other: { command: "other" } }, + theme: "dark", + }, + null, + 2, + ).replaceAll("\n", "\r\n")}\r\n`; + await mkdir(dirname(client.configPath), { recursive: true }); + await writeFile(client.configPath, original); + const currentCommandPath = join(home, "rea"); + const command = [currentCommandPath, "mcp"]; + expect(await inspectClientConfiguration(client, {}, command)).toEqual({ + status: "update", + backupPath: `${client.configPath}.rea.backup`, + }); + expect(await clientConfigurationAligned(client, {}, command)).toBe( + false, + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual( + { + status: "configured", + backupPath: `${client.configPath}.rea.backup`, + }, + ); + const updated = await readFile(client.configPath, "utf8"); + expect( + updated.startsWith(`${bom}// Keep the client preferences.\r\n`), + ).toBe(true); + expect( + parseClientConfiguration(updated, client.format).document, + ).toEqual({ label: "left\uFEFFright", [serversKey]: { other: { command: "other" }, rea: clientRegistrationEntry(client.format, command, {}), }, theme: "dark", - }, - ); - expect(await clientConfigurationAligned(client, {}, command)).toBe(true); - expect(await inspectClientConfiguration(client, {}, command)).toEqual({ - status: "already_current", - }); - expect( - await readClientRegistrationStatuses(home, currentCommandPath, { - platform: "linux", - environment: {}, - }), - ).toEqual([ - { - client: name, - config_path: client.configPath, - state: "aligned", - command, - remediation: null, - }, - ]); - expect(await configureClientConfiguration(client, {}, command)).toEqual({ - status: "unchanged", - }); - expect(await readFile(client.configPath)).toEqual(Buffer.from(updated)); - expect(await readFile(`${client.configPath}.rea.backup`)).toEqual( - Buffer.from(original), - ); - }, - ); -}); + }); + expect(await clientConfigurationAligned(client, {}, command)).toBe( + true, + ); + expect(await inspectClientConfiguration(client, {}, command)).toEqual({ + status: "already_current", + }); + expect( + await readClientRegistrationStatuses(home, currentCommandPath, { + platform: "linux", + environment: {}, + }), + ).toEqual([ + { + client: name, + config_path: client.configPath, + state: "aligned", + command, + remediation: null, + }, + ]); + expect(await configureClientConfiguration(client, {}, command)).toEqual( + { + status: "unchanged", + }, + ); + expect(await readFile(client.configPath)).toEqual(Buffer.from(updated)); + expect(await readFile(`${client.configPath}.rea.backup`)).toEqual( + Buffer.from(original), + ); + }, + ); + }, +); it.each(['\uFEFF{"servers": {"rea": @}}', '\uFEFF{"setting":\uFEFFtrue}'])( "rejects malformed BOM placement without mutation: %j", diff --git a/tests/boundary/filesystem/windowsNpxRegistration.test.ts b/tests/boundary/filesystem/windowsNpxRegistration.test.ts index 916ce242bf..72e5e12a56 100644 --- a/tests/boundary/filesystem/windowsNpxRegistration.test.ts +++ b/tests/boundary/filesystem/windowsNpxRegistration.test.ts @@ -55,7 +55,11 @@ it.each([ ).toContainEqual( expect.objectContaining({ client: "claude_code", state: "stale" }), ); - const client = { name: "claude_code", configPath, format: "json" } as const; + const client = { + name: "claude_code", + configPath, + format: "claude_code", + } as const; const command = setupRegistrationCommand("win32", "npx", undefined); expect( await configureClientConfiguration(client, {}, command), @@ -119,7 +123,11 @@ it.each(["npx", "bunx"] as const)( it("retains rea-named custom shell registrations without writing a backup", async () => { const home = await createTestTempDirectory("rea-custom-shell-registration-"); const configPath = join(home, ".claude.json"); - const client = { name: "claude_code", configPath, format: "json" } as const; + const client = { + name: "claude_code", + configPath, + format: "claude_code", + } as const; for (const command of [ ["cmd.exe", "/d", "/c", "node", "custom.js", "mcp"], ["cmd.exe", "/k", "npx", "-y", "rea-agents@5.0.0", "mcp"], @@ -158,7 +166,7 @@ it.skipIf(process.platform !== "win32").each([ const setupClient = { name: "claude_code", configPath, - format: "json", + format: "claude_code", } as const; const command = setupRegistrationCommand("win32", runner, undefined); // The default writer and npm-run setup must produce the same native launcher.