diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 271908a7a..441ce207f 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -48,7 +48,10 @@ export interface SetupHost { hopperPath(): Promise; installHopper(): Promise; detectedClients(): Promise; - configureClient(client: SetupClient): Promise; + configureClient( + client: SetupClient, + hopperPath: string, + ): Promise; installSkill(): Promise<"installed" | "unchanged" | "failed">; doctor(): Promise>>; } @@ -97,16 +100,22 @@ export const runSetup = async ( ); actions.push("installed_homebrew"); } - if ((await host.hopperPath()) === undefined) { + let hopperPath = await host.hopperPath(); + if (hopperPath === undefined) { if (!yes) return fail("Re-run with --yes to install Hopper."); if (!(await host.installHopper())) return fail( "Hopper installation was interrupted; resolve the system prompt and re-run setup.", ); actions.push("installed_hopper"); + hopperPath = await host.hopperPath(); + if (hopperPath === undefined) + return fail( + "Hopper installation completed but its launcher was not found.", + ); } for (const client of await host.detectedClients()) { - const result = await host.configureClient(client); + const result = await host.configureClient(client, hopperPath); clients[client.name] = result; if (result.status === "failed") return fail( @@ -188,6 +197,7 @@ const detectJsonClients = async ( /** Back up, atomically update, and semantically read back one JSON MCP configuration. */ export const configureJsonClient = async ( client: SetupClient, + hopperPath?: string, ): Promise => { let document: Record = {}; let original: string | undefined; @@ -206,6 +216,9 @@ export const configureJsonClient = async ( const desired = { command: "npx", args: ["-y", PRODUCT_IDENTITY.packageName, "mcp"], + ...(hopperPath === undefined + ? {} + : { env: { HOPPER_LAUNCHER_PATH: hopperPath } }), }; if ( JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) === diff --git a/tests/setup.test.ts b/tests/setup.test.ts index da55b5a1a..04cfb9f74 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -22,6 +22,7 @@ class FakeSetupHost implements SetupHost { homebrewInstalls = 0; hopperInstalls = 0; configurations = 0; + configuredHopperPaths: string[] = []; macosVersion = (): Promise => Promise.resolve(this.version); @@ -41,8 +42,10 @@ class FakeSetupHost implements SetupHost { Promise.resolve(this.clients); configureClient = ( client: SetupClient, + hopperPath: string, ): Promise => { this.configurations += 1; + this.configuredHopperPaths.push(hopperPath); return Promise.resolve( this.clientResults.get(client.name) ?? { status: "configured", @@ -99,6 +102,7 @@ describe("setup workflow", () => { host.hopper = "/Applications/Manual Hopper"; expect((await runSetup(true, host)).actions).toEqual(["installed_skill"]); expect(host.hopperInstalls).toBe(0); + expect(host.configuredHopperPaths).toEqual([]); }); it.each(["homebrew", "hopper"] as const)( @@ -127,6 +131,7 @@ describe("setup workflow", () => { const result = await runSetup(true, host); expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]); expect(host.configurations).toBe(2); + expect(host.configuredHopperPaths).toEqual(["/Hopper", "/Hopper"]); }); it.each(["backup", "write", "readback"] as const)( diff --git a/tests/setupConfig.test.ts b/tests/setupConfig.test.ts index 048bd1449..57510adc7 100644 --- a/tests/setupConfig.test.ts +++ b/tests/setupConfig.test.ts @@ -49,6 +49,28 @@ describe("JSON client configuration transaction", () => { ).rejects.toThrow(); }); + it("persists a custom Hopper launcher and remains idempotent", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-setup-")); + const configPath = join(directory, "mcp.json"); + const hopperPath = "/Applications/Hopper v6.app/Contents/MacOS/hopper"; + const client = { name: "cursor", configPath }; + expect(await configureJsonClient(client, hopperPath)).toMatchObject({ + status: "configured", + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + mcpServers: { + rea: { + command: "npx", + args: ["-y", "@morluto/rea", "mcp"], + env: { HOPPER_LAUNCHER_PATH: hopperPath }, + }, + }, + }); + expect(await configureJsonClient(client, hopperPath)).toEqual({ + status: "unchanged", + }); + }); + it("refuses malformed existing JSON without overwriting it", async () => { directory = await mkdtemp(join(tmpdir(), "rea-setup-")); const configPath = join(directory, "mcp.json");