From 5dc5f47aa897c2033c8783668bedd2981282baf6 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:32:39 +0200 Subject: [PATCH 01/16] docs(spec): add claude-desktop launch design Captures the brainstormed design for `opper launch claude-desktop`: adapter that writes Claude Desktop's third-party-inference profile to point at the Opper compat gateway, plus a new `opper agents uninstall ` non-interactive uninstall surface to mirror `opper launch `. --- .../specs/2026-05-05-claude-desktop-launch.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-05-claude-desktop-launch.md diff --git a/docs/superpowers/specs/2026-05-05-claude-desktop-launch.md b/docs/superpowers/specs/2026-05-05-claude-desktop-launch.md new file mode 100644 index 0000000..e8e7d2b --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-claude-desktop-launch.md @@ -0,0 +1,199 @@ +# Claude Desktop launch support + +**Date:** 2026-05-05 +**Status:** Approved (brainstorm), pending plan + +## Goal + +Add `opper launch claude-desktop` so users can route Claude Desktop's inference through the Opper gateway, mirroring what `ollama launch claude-desktop` does for the Ollama Cloud gateway. Reuse the existing `AgentAdapter` framework — no contract changes. + +Add `opper agents uninstall ` as a non-interactive uninstall surface, since today only the interactive menu can call `adapter.unconfigure()`. Folded into this spec because the matching CLI surface for `opper launch ` is otherwise missing. + +## Background + +Claude Desktop ships with a built-in "third-party inference" mode (`deploymentMode: "3p"`). Ollama exploits this — they don't reverse-engineer the app, they write to its documented profile system. We can do the same. Opper's compat endpoint already speaks Anthropic Messages format at `/v1/messages` and `/v1/models`, which is exactly what Claude Desktop calls when in 3p mode. + +Reference: [`ollama/cmd/launch/claude_desktop.go`](https://github.com/ollama/ollama/blob/main/cmd/launch/claude_desktop.go). + +## Configuration mechanism + +Claude Desktop reads two profile trees: + +| Platform | Normal config root | Third-party config root | +|---|---|---| +| macOS | `~/Library/Application Support/Claude/` | `~/Library/Application Support/Claude-3p/` | +| Windows | `%LOCALAPPDATA%\Claude\` (also `Claude Nest\`) | `%LOCALAPPDATA%\Claude-3p\` (also `Claude Nest-3p\`) | + +Three files are written on `configure`: + +1. **`/claude_desktop_config.json`** — set `"deploymentMode": "3p"`. Preserves all other keys. +2. **`<3p>/claude_desktop_config.json`** — same, set `"deploymentMode": "3p"`. +3. **`<3p>/configLibrary/_meta.json`** — register the Opper profile entry: + ```json + { + "appliedId": "", + "entries": [ + { "id": "", "name": "Opper" } + ] + } + ``` + If the file already has other entries, they are preserved; the Opper entry is upserted. +4. **`<3p>/configLibrary/.json`** — the gateway profile: + ```json + { + "inferenceProvider": "gateway", + "inferenceGatewayBaseUrl": "https://api.opper.ai/v3/compat", + "inferenceGatewayApiKey": "", + "inferenceGatewayAuthScheme": "bearer", + "disableDeploymentModeChooser": true + } + ``` + +`OPPER_PROFILE_ID` is a hardcoded UUID v4 (constant), so re-running `configure` updates the same profile rather than spawning duplicates. Value: `727f05c8-a429-43cc-b1c6-36d8883d98b8`. + +`unconfigure` reverses all three files: +- Set `deploymentMode` back to `"1p"` in both config files. +- Remove the Opper entry from `_meta.json` `entries`; clear `appliedId` if it equals `OPPER_PROFILE_ID`. +- In the profile JSON, delete `inferenceProvider`, `inferenceGatewayBaseUrl`, `inferenceGatewayApiKey`, `inferenceGatewayAuthScheme`, and set `disableDeploymentModeChooser: false`. (The file is left in place so re-running `configure` is fast; the gateway fields are what matter.) + +## Architecture + +### New: `src/agents/claude-desktop.ts` + +Implements `AgentAdapter`: + +- `name: "claude-desktop"`, `displayName: "Claude Desktop"`, `docsUrl: "https://claude.ai/download"`. +- **`detect()`** + - macOS: stat `/Applications/Claude.app` and `~/Applications/Claude.app`. + - Windows: stat the candidates ollama enumerates — `%LOCALAPPDATA%\Programs\Claude\Claude.exe`, `%LOCALAPPDATA%\Programs\Claude Desktop\Claude.exe`, `%LOCALAPPDATA%\Claude\Claude.exe`, `%LOCALAPPDATA%\Claude Nest\Claude.exe`, `%LOCALAPPDATA%\Claude Desktop\Claude.exe`, `%LOCALAPPDATA%\AnthropicClaude\Claude.exe`, plus globs `%LOCALAPPDATA%\AnthropicClaude\app-*\Claude.exe`, `%LOCALAPPDATA%\Programs\Claude\app-*\Claude.exe`, `%LOCALAPPDATA%\Programs\Claude Desktop\app-*\Claude.exe`. + - Linux: returns `{ installed: false }`. + - Returns `{ installed: true, configPath: }` on hit. +- **`isConfigured()`** — `true` iff `deploymentMode == "3p"` in both normal and 3p `claude_desktop_config.json`, AND the profile JSON has `inferenceProvider == "gateway"` AND `inferenceGatewayBaseUrl` matches `OPPER_COMPAT_URL` AND `inferenceGatewayApiKey` is non-empty AND `_meta.json`'s `appliedId == OPPER_PROFILE_ID`. +- **`configure({ apiKey })`** — writes the three files described above. Throws `AUTH_REQUIRED` if `apiKey` is missing (matches `openclaw.configure`). +- **`unconfigure()`** — reverses. No-op when files are missing or already in 1p. +- **`install()`** — throws `OpperError("AGENT_NOT_FOUND", ...)` with a "download from claude.ai/download" hint. Same shape as `claudeCode.install`. +- **`spawn(args, routing)`** + - If `args.length > 0` → throw `OpperError` "claude-desktop does not accept passthrough arguments". + - Run `configure({ apiKey: routing.apiKey })`. + - Detect if the app is currently running, using existing `src/util/run.ts` (which wraps `spawnSync`, no shell): + - macOS: `pgrep -f Claude.app/Contents/MacOS/Claude` + - Windows: invoke `powershell.exe` with `-NoProfile -Command` and a fixed inline script (no user input is interpolated). Script: `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id`. + - If running: + - macOS: invoke `osascript` with `-e 'tell application "Claude" to quit'`. + - Windows: invoke `powershell.exe` with `-NoProfile -Command` and a fixed inline script: `Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }`. + - Poll every 200ms up to **5 seconds** for the process to disappear (ollama uses 30s; we use 5s because the existing session-summary printer kicks in at >1.5s and we'd rather error out than print a misleading summary). + - If still running after 5s: throw `OpperError` "Claude Desktop did not quit within 5s; quit it manually and re-run." + - Open the app: + - macOS: invoke `open` with args `["-a", "Claude"]`. + - Windows: invoke `powershell.exe` with `-NoProfile -Command "Start-Process -FilePath ''"` — the path is built from `%LOCALAPPDATA%` candidates we discovered in `detect`, not from user input, and is single-quote-escaped per the same helper ollama uses. + - Return `0`. + +All shell-outs go through `execFile`-shaped APIs (the existing `src/util/run.ts` `spawnSync` wrapper, which already takes `(command, args)` and never invokes `/bin/sh -c`). No string concatenation of arguments. + +### Modified: `src/agents/registry.ts` + +Append `claudeDesktop` to the `ADAPTERS` array, after `claudeCode`. + +### New: `opper agents uninstall ` + +A small, non-interactive companion to `opper launch `: + +- **`src/commands/agents.ts`** — add `agentsUninstallCommand(name: string)`: + - Resolve `getAdapter(name)`; throw `OpperError("AGENT_NOT_FOUND")` if unknown. + - Call `adapter.unconfigure()`. + - Print `${displayName} integration removed.` (matches the menu's success message at `src/commands/menu/agents.ts:122`). +- **`src/cli/agents.ts`** — register the subcommand: + ```ts + agentsCmd + .command("uninstall ") + .description("Remove the Opper integration from an agent's config (does not uninstall the agent itself)") + .action(async (name: string) => { await agentsUninstallCommand(name); }); + ``` + +This works for every adapter in the registry, not just `claude-desktop`, and parallels the existing menu uninstall. + +## Data flow + +``` +opper launch claude-desktop + └── launchCommand() + ├── adapter.detect() — is Claude.app present? + └── adapter.spawn([], routing) + ├── configure({ apiKey }) + │ ├── write Claude/claude_desktop_config.json (deploymentMode: 3p) + │ ├── write Claude-3p/claude_desktop_config.json (deploymentMode: 3p) + │ ├── write Claude-3p/configLibrary/_meta.json (entry + appliedId) + │ └── write Claude-3p/configLibrary/.json (gateway settings) + ├── if running: osascript quit + 5s poll + └── open -a Claude + +opper agents uninstall claude-desktop + └── agentsUninstallCommand("claude-desktop") + └── adapter.unconfigure() + ├── flip deploymentMode back to 1p in both config files + ├── remove Opper entry / appliedId from _meta.json + └── delete gateway fields from .json +``` + +## Error handling + +| Scenario | Behaviour | +|---|---| +| Claude Desktop not installed, no `--install` | `AGENT_NOT_FOUND` with `docsUrl` hint (existing `launchCommand` path) | +| `--install` flag passed | `install()` throws `AGENT_NOT_FOUND` "Claude Desktop must be installed manually from claude.ai/download" | +| Linux | `detect` returns `{ installed: false }`; surfaces as the standard not-installed error | +| Passthrough args (`opper launch claude-desktop -- foo`) | `OpperError` "claude-desktop does not accept passthrough arguments" | +| Quit times out after 5s | `OpperError` "Claude Desktop did not quit within 5s; quit it manually and re-run." | +| TCC denial (user declines "opper would like to control Claude") | `osascript` returns non-zero; wrap with hint: "macOS denied automation permission. Grant access in System Settings → Privacy & Security → Automation, then re-run." | +| Config write fails | Bubble the `fs` error with the path that was being written | +| Slot has no API key | Existing `launchCommand` path triggers `loginCommand`, same as for every other adapter | +| `opper agents uninstall ` | `AGENT_NOT_FOUND` with the same hint as `launchCommand` | + +## File atomicity + +Skip ollama's `WriteWithBackup` mechanism — node's `fs.promises.writeFile` is atomic-on-same-fs replacement on macOS and Windows. Same approach as the openclaw adapter, which writes its own `models.json` with a plain `writeFile`. The config is fully reconstructable from `unconfigure` + `configure` if it ever ends up corrupt. + +## Testing + +Following the patterns in `test/agents/claude-code.test.ts` and `test/agents/opencode.test.ts`: + +- **`test/agents/claude-desktop.test.ts`** (new): + - `detect` returns `installed: false` when no app candidate stats successfully (mocked `fs.existsSync` + `os.platform`). + - `detect` returns `installed: true` when `/Applications/Claude.app` stats successfully on darwin. + - `detect` returns `installed: false` on linux regardless of fs state. + - `configure` writes all three JSON files with the expected shape and merges into existing keys (test against pre-populated config files in a tmp dir). + - `configure` throws `AUTH_REQUIRED` when called without an `apiKey`. + - `configure` is idempotent — calling it twice produces the same final state and doesn't duplicate `_meta.json` entries. + - `isConfigured` returns `true` after `configure`, `false` after `unconfigure`, `false` on a fresh tree. + - `unconfigure` after `configure` flips `deploymentMode` to `"1p"`, removes the Opper entry, blanks the gateway fields, and **leaves user-owned siblings intact** (third-party `_meta.json` entries from other tools are preserved). + - `unconfigure` on an unconfigured tree is a no-op (no errors, no writes). + - `spawn` with `args.length > 0` throws. + - `spawn` calls `configure`, detects "not running", opens the app once. (Mock `child_process.spawnSync` and the `run` helper.) + - `spawn` calls `configure`, detects "running", sends quit, polls until exit, opens the app. + - `spawn` errors when the app fails to quit within 5s. + - `install` throws `AGENT_NOT_FOUND` with the manual-install hint. + +- **`test/agents/registry.test.ts`** (modified): assert `claude-desktop` is registered and `isLaunchable(getAdapter("claude-desktop")) === true`. + +- **`test/commands/agents.test.ts`** (modified, already exists for `agentsListCommand`): add assertions that `agentsUninstallCommand` resolves the adapter and calls `unconfigure`, and that an unknown name throws `AGENT_NOT_FOUND`. + +## Constants & filesystem helpers + +A small block of constants at the top of `claude-desktop.ts`: + +```ts +const OPPER_PROFILE_ID = "727f05c8-a429-43cc-b1c6-36d8883d98b8"; +const OPPER_PROFILE_NAME = "Opper"; +const QUIT_TIMEOUT_MS = 5_000; +const QUIT_POLL_INTERVAL_MS = 200; +``` + +Path resolution lives in module-private helpers (`darwinProfileRoots`, `windowsProfileRoots`, `targetPaths`) — same shape as ollama, ported to TS. No new shared util — the helpers stay local to the adapter because they're not reused. + +## Out of scope + +- `--config` / `--restore` / `--yes` flags. We use the existing menu and the new `opper agents uninstall` command instead. +- Pre-flight API key validation against `/v1/models`. Trust the slot like every other `opper launch` adapter. +- Linux support. Anthropic doesn't ship a Linux build of Claude Desktop. +- Backup files (`.bak`). Reconstructable via `unconfigure` + `configure`. +- Session-summary threshold tuning. The brief "0 cost / 0 tokens" summary that may print on a quit-and-reopen run is acceptable — the command clearly returns directly, so users won't read it as a real session. From 26faf42f56a6837a08865ad00cd6ea79ef6ab65f Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:38:20 +0200 Subject: [PATCH 02/16] docs(plan): claude-desktop launch implementation plan Step-by-step TDD plan for the spec at docs/superpowers/specs/2026-05-05-claude-desktop-launch.md. --- .../plans/2026-05-05-claude-desktop-launch.md | 1521 +++++++++++++++++ 1 file changed, 1521 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-05-claude-desktop-launch.md diff --git a/docs/superpowers/plans/2026-05-05-claude-desktop-launch.md b/docs/superpowers/plans/2026-05-05-claude-desktop-launch.md new file mode 100644 index 0000000..06bc063 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-claude-desktop-launch.md @@ -0,0 +1,1521 @@ +# Claude Desktop launch — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `opper launch claude-desktop` and `opper agents uninstall `, routing Claude Desktop's third-party-inference profile through Opper's compat gateway. + +**Architecture:** New `claudeDesktop` adapter in `src/agents/claude-desktop.ts` implementing the existing `AgentAdapter` contract. Writes Claude Desktop's documented `deploymentMode: "3p"` profile (a `_meta.json` registry + a UUID-keyed gateway profile JSON) pointing at `OPPER_COMPAT_URL`. `spawn` writes the config, sends a quit via `osascript`/`powershell` if Claude is already running (5s poll timeout), then `open -a Claude` / `Start-Process`. macOS + Windows; Linux returns "not installed". A small new `agentsUninstallCommand` registers `opper agents uninstall ` so non-interactive removal exists for every adapter. + +**Tech Stack:** TypeScript, Node 20, Vitest, Commander.js. All shell-outs go through the existing `src/util/run.ts` (`spawnSync` with fixed argv, no shell). + +**Spec:** [`docs/superpowers/specs/2026-05-05-claude-desktop-launch.md`](../specs/2026-05-05-claude-desktop-launch.md) + +--- + +## File map + +**Create** +- `src/agents/claude-desktop.ts` — the adapter +- `test/agents/claude-desktop.test.ts` — adapter unit tests + +**Modify** +- `src/agents/registry.ts` — append `claudeDesktop` to `ADAPTERS` +- `src/commands/agents.ts` — add `agentsUninstallCommand` +- `src/cli/agents.ts` — register `uninstall ` subcommand +- `test/agents/registry.test.ts` — assert `claude-desktop` is registered +- `test/commands/agents.test.ts` — assert `agentsUninstallCommand` calls `unconfigure` and rejects unknown names + +--- + +## Task 1: Scaffold the adapter and register it + +Creates a stub adapter that compiles, returns `installed: false` everywhere, and shows up in `opper agents list`. Subsequent tasks fill it in. + +**Files:** +- Create: `src/agents/claude-desktop.ts` +- Modify: `src/agents/registry.ts` +- Modify: `test/agents/registry.test.ts` + +- [ ] **Step 1: Write the failing registry test** + +Append to `test/agents/registry.test.ts` after line 17 (before the closing brace): + +```ts + it("registers claude-desktop as a launchable adapter", async () => { + const adapter = getAdapter("claude-desktop"); + expect(adapter).not.toBeNull(); + expect(adapter?.displayName).toBe("Claude Desktop"); + expect(typeof adapter?.spawn).toBe("function"); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- test/agents/registry.test.ts` +Expected: FAIL with `expected null not to be null` (or similar) on the new test; existing tests still pass. + +- [ ] **Step 3: Create the adapter stub** + +Write `src/agents/claude-desktop.ts`: + +```ts +import { OpperError } from "../errors.js"; +import type { + AgentAdapter, + ConfigureOptions, + DetectResult, + OpperRouting, +} from "./types.js"; + +async function detect(): Promise { + return { installed: false }; +} + +async function isConfigured(): Promise { + return false; +} + +async function configure(_opts: ConfigureOptions): Promise { + throw new OpperError("AGENT_NOT_FOUND", "claude-desktop adapter not yet implemented"); +} + +async function unconfigure(): Promise { + // Filled in by Task 6. +} + +async function install(): Promise { + throw new OpperError( + "AGENT_NOT_FOUND", + "Claude Desktop must be installed manually.", + "Download Claude Desktop from https://claude.ai/download.", + ); +} + +async function spawn(_args: string[], _routing: OpperRouting): Promise { + throw new OpperError("AGENT_NOT_FOUND", "claude-desktop adapter not yet implemented"); +} + +export const claudeDesktop: AgentAdapter = { + name: "claude-desktop", + displayName: "Claude Desktop", + docsUrl: "https://claude.ai/download", + detect, + isConfigured, + configure, + unconfigure, + install, + spawn, +}; +``` + +- [ ] **Step 4: Register the adapter** + +Modify `src/agents/registry.ts`: + +```ts +import type { AgentAdapter } from "./types.js"; +import { opencode } from "./opencode.js"; +import { claudeCode } from "./claude-code.js"; +import { claudeDesktop } from "./claude-desktop.js"; +import { codex } from "./codex.js"; +import { hermes } from "./hermes.js"; +import { pi } from "./pi.js"; +import { openclaw } from "./openclaw.js"; + +const ADAPTERS: ReadonlyArray = [ + opencode, + claudeCode, + claudeDesktop, + codex, + hermes, + pi, + openclaw, +]; + +export function listAdapters(): ReadonlyArray { + return ADAPTERS; +} + +export function getAdapter(name: string): AgentAdapter | null { + return ADAPTERS.find((a) => a.name === name) ?? null; +} +``` + +- [ ] **Step 5: Run tests and typecheck** + +Run: `npm test -- test/agents/registry.test.ts && npm run typecheck` +Expected: all tests pass; typecheck clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/agents/claude-desktop.ts src/agents/registry.ts test/agents/registry.test.ts +git commit -m "feat(claude-desktop): scaffold adapter and register" +``` + +--- + +## Task 2: Implement `detect()` for macOS, Windows, and Linux + +**Files:** +- Modify: `src/agents/claude-desktop.ts` +- Create: `test/agents/claude-desktop.test.ts` + +- [ ] **Step 1: Write the failing detect tests** + +Create `test/agents/claude-desktop.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const platformMock = vi.fn<[], NodeJS.Platform>(() => "darwin"); +const homedirMock = vi.fn<[], string>(() => "/nonexistent"); + +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, platform: platformMock, homedir: homedirMock }; +}); + +const { claudeDesktop } = await import("../../src/agents/claude-desktop.js"); + +function makeTempHome(): string { + return mkdtempSync(join(tmpdir(), "opper-claude-desktop-")); +} + +describe("claude-desktop adapter — detect", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + it("returns installed=false on linux regardless of fs state", async () => { + platformMock.mockReturnValue("linux"); + expect((await claudeDesktop.detect()).installed).toBe(false); + }); + + it("darwin: returns installed=false when no Claude.app candidate exists", async () => { + expect((await claudeDesktop.detect()).installed).toBe(false); + }); + + it("darwin: returns installed=true when /Applications/Claude.app exists", async () => { + // The adapter checks /Applications/Claude.app first; we can't write + // to it in CI, so verify the user-Applications fallback instead. + mkdirSync(join(home, "Applications", "Claude.app"), { recursive: true }); + const result = await claudeDesktop.detect(); + expect(result.installed).toBe(true); + }); + + it("windows: returns installed=true when a known candidate exists", async () => { + platformMock.mockReturnValue("win32"); + const local = join(home, "AppData", "Local"); + process.env.LOCALAPPDATA = local; + mkdirSync(join(local, "AnthropicClaude"), { recursive: true }); + writeFileSync(join(local, "AnthropicClaude", "Claude.exe"), ""); + const result = await claudeDesktop.detect(); + expect(result.installed).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- test/agents/claude-desktop.test.ts` +Expected: FAIL — current `detect` always returns `{ installed: false }`, so the two `installed=true` tests fail. + +- [ ] **Step 3: Implement detect()** + +Replace the `detect` function in `src/agents/claude-desktop.ts` and add the imports / helpers above it. Full new content for the imports + helpers + `detect` block: + +```ts +import { existsSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { OpperError } from "../errors.js"; +import type { + AgentAdapter, + ConfigureOptions, + DetectResult, + OpperRouting, +} from "./types.js"; + +function darwinAppCandidates(): string[] { + return [ + "/Applications/Claude.app", + join(homedir(), "Applications", "Claude.app"), + ]; +} + +function windowsLocalAppData(): string | null { + const local = (process.env.LOCALAPPDATA ?? "").trim(); + if (local) return local; + const profile = (process.env.USERPROFILE ?? "").trim(); + if (profile) return join(profile, "AppData", "Local"); + try { + return join(homedir(), "AppData", "Local"); + } catch { + return null; + } +} + +function windowsAppCandidates(): string[] { + const local = windowsLocalAppData(); + if (!local) return []; + return [ + join(local, "Programs", "Claude", "Claude.exe"), + join(local, "Programs", "Claude Desktop", "Claude.exe"), + join(local, "Claude", "Claude.exe"), + join(local, "Claude Nest", "Claude.exe"), + join(local, "Claude Desktop", "Claude.exe"), + join(local, "AnthropicClaude", "Claude.exe"), + ]; +} + +function appCandidates(): string[] { + switch (platform()) { + case "darwin": + return darwinAppCandidates(); + case "win32": + return windowsAppCandidates(); + default: + return []; + } +} + +async function detect(): Promise { + for (const candidate of appCandidates()) { + if (existsSync(candidate)) return { installed: true }; + } + return { installed: false }; +} +``` + +Leave the rest of the file (configure / unconfigure / install / spawn / export) as in Task 1. + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- test/agents/claude-desktop.test.ts && npm run typecheck` +Expected: all four detect tests pass; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/claude-desktop.ts test/agents/claude-desktop.test.ts +git commit -m "feat(claude-desktop): implement detect for macOS, Windows, Linux" +``` + +--- + +## Task 3: Profile path helpers + +Pure functions that resolve the macOS / Windows config paths. Unit-tested without filesystem effects. + +**Files:** +- Modify: `src/agents/claude-desktop.ts` +- Modify: `test/agents/claude-desktop.test.ts` + +- [ ] **Step 1: Write the failing path tests** + +Append to `test/agents/claude-desktop.test.ts` inside the existing top-level imports section, expose the helpers via the adapter export. We'll test indirectly through `configure` / `isConfigured` rather than exporting helpers — keeps the public surface clean. + +Append a new `describe` block to `test/agents/claude-desktop.test.ts`: + +```ts +describe("claude-desktop adapter — paths (via isConfigured)", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + it("returns false when no config files exist (fresh tree)", async () => { + expect(await claudeDesktop.isConfigured()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `npm test -- test/agents/claude-desktop.test.ts` +Expected: PASS — current stub returns `false`. The test exists to lock in the contract before we touch `isConfigured`. + +- [ ] **Step 3: Add path helpers to the adapter** + +In `src/agents/claude-desktop.ts`, append below the `appCandidates` function (and before `detect`): + +```ts +const OPPER_PROFILE_ID = "727f05c8-a429-43cc-b1c6-36d8883d98b8"; +const OPPER_PROFILE_NAME = "Opper"; + +interface ThirdPartyPaths { + desktopConfig: string; + meta: string; + profile: string; +} + +interface ConfigTargets { + normalConfigs: string[]; + thirdPartyProfiles: ThirdPartyPaths[]; +} + +function darwinProfileRoots(): { normal: string[]; thirdParty: string[] } { + const base = join(homedir(), "Library", "Application Support"); + return { + normal: [join(base, "Claude")], + thirdParty: [join(base, "Claude-3p")], + }; +} + +function windowsProfileRoots(): { normal: string[]; thirdParty: string[] } { + const local = windowsLocalAppData(); + if (!local) return { normal: [], thirdParty: [] }; + return { + normal: [join(local, "Claude"), join(local, "Claude Nest")], + thirdParty: [join(local, "Claude-3p"), join(local, "Claude Nest-3p")], + }; +} + +function profileRoots(): { normal: string[]; thirdParty: string[] } { + switch (platform()) { + case "darwin": + return darwinProfileRoots(); + case "win32": + return windowsProfileRoots(); + default: + return { normal: [], thirdParty: [] }; + } +} + +function dedupePaths(paths: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const p of paths) { + if (!p) continue; + const key = p.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(p); + } + return out; +} + +function targetPaths(): ConfigTargets { + const { normal, thirdParty } = profileRoots(); + return { + normalConfigs: dedupePaths(normal).map((root) => + join(root, "claude_desktop_config.json"), + ), + thirdPartyProfiles: dedupePaths(thirdParty).map((root) => ({ + desktopConfig: join(root, "claude_desktop_config.json"), + meta: join(root, "configLibrary", "_meta.json"), + profile: join(root, "configLibrary", `${OPPER_PROFILE_ID}.json`), + })), + }; +} +``` + +- [ ] **Step 4: Run tests + typecheck to verify nothing regressed** + +Run: `npm test -- test/agents/claude-desktop.test.ts && npm run typecheck` +Expected: all tests pass; typecheck clean. The new helpers are unused so far — no behaviour change. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/claude-desktop.ts test/agents/claude-desktop.test.ts +git commit -m "feat(claude-desktop): add profile path helpers" +``` + +--- + +## Task 4: Implement `configure()` — write the three JSON files + +**Files:** +- Modify: `src/agents/claude-desktop.ts` +- Modify: `test/agents/claude-desktop.test.ts` + +- [ ] **Step 1: Write the failing configure tests** + +Append to `test/agents/claude-desktop.test.ts`: + +```ts +import { readFileSync as readFileSyncReal } from "node:fs"; + +describe("claude-desktop adapter — configure", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + function readJSON(path: string): any { + return JSON.parse(readFileSyncReal(path, "utf8")); + } + + it("throws AUTH_REQUIRED when called without an apiKey", async () => { + await expect(claudeDesktop.configure({})).rejects.toMatchObject({ + code: "AUTH_REQUIRED", + }); + }); + + it("writes deploymentMode=3p to both normal and 3p config files", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const base = join(home, "Library", "Application Support"); + expect(readJSON(join(base, "Claude", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "3p", + }); + expect(readJSON(join(base, "Claude-3p", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "3p", + }); + }); + + it("writes the Opper entry into _meta.json and sets appliedId", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const meta = readJSON( + join(home, "Library", "Application Support", "Claude-3p", "configLibrary", "_meta.json"), + ); + expect(meta.appliedId).toBe("727f05c8-a429-43cc-b1c6-36d8883d98b8"); + expect(meta.entries).toContainEqual({ + id: "727f05c8-a429-43cc-b1c6-36d8883d98b8", + name: "Opper", + }); + }); + + it("writes the gateway profile JSON with Opper's compat URL and the api key", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const profile = readJSON( + join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ), + ); + expect(profile).toMatchObject({ + inferenceProvider: "gateway", + inferenceGatewayBaseUrl: "https://api.opper.ai/v3/compat", + inferenceGatewayApiKey: "op_test_key", + inferenceGatewayAuthScheme: "bearer", + disableDeploymentModeChooser: true, + }); + }); + + it("preserves user-owned siblings in the normal config and _meta.json", async () => { + const base = join(home, "Library", "Application Support"); + const normalCfg = join(base, "Claude", "claude_desktop_config.json"); + mkdirSync(join(base, "Claude"), { recursive: true }); + writeFileSync( + normalCfg, + JSON.stringify({ mcpServers: { fs: { command: "fs" } } }, null, 2), + ); + const metaPath = join(base, "Claude-3p", "configLibrary", "_meta.json"); + mkdirSync(join(base, "Claude-3p", "configLibrary"), { recursive: true }); + writeFileSync( + metaPath, + JSON.stringify({ entries: [{ id: "user-other", name: "Other" }] }, null, 2), + ); + + await claudeDesktop.configure({ apiKey: "op_test_key" }); + + expect(readJSON(normalCfg)).toMatchObject({ + mcpServers: { fs: { command: "fs" } }, + deploymentMode: "3p", + }); + const meta = readJSON(metaPath); + expect(meta.entries).toContainEqual({ id: "user-other", name: "Other" }); + expect(meta.entries).toContainEqual({ + id: "727f05c8-a429-43cc-b1c6-36d8883d98b8", + name: "Opper", + }); + }); + + it("is idempotent — running twice does not duplicate the Opper entry", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const meta = readJSON( + join(home, "Library", "Application Support", "Claude-3p", "configLibrary", "_meta.json"), + ); + const opperEntries = (meta.entries as Array<{ id: string }>).filter( + (e) => e.id === "727f05c8-a429-43cc-b1c6-36d8883d98b8", + ); + expect(opperEntries).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- test/agents/claude-desktop.test.ts` +Expected: FAIL — `configure` currently throws `AGENT_NOT_FOUND`; we want `AUTH_REQUIRED` for the first test, and the rest expect file writes that don't happen. + +- [ ] **Step 3: Implement configure()** + +Add these imports at the top of `src/agents/claude-desktop.ts`: + +```ts +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { OPPER_COMPAT_URL } from "../config/endpoints.js"; +``` + +Add these helpers (above the existing `detect` function): + +```ts +type JsonObject = Record; + +async function readJsonAllowMissing(path: string): Promise { + try { + const data = await readFile(path, "utf8"); + const parsed = JSON.parse(data) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as JsonObject; + } + return {}; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } +} + +async function writeJson(path: string, data: JsonObject): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf8"); +} + +async function writeDeploymentMode(path: string, mode: "1p" | "3p"): Promise { + const cfg = await readJsonAllowMissing(path); + cfg.deploymentMode = mode; + await writeJson(path, cfg); +} + +async function writeMetaWithOpperEntry(path: string): Promise { + const meta = await readJsonAllowMissing(path); + meta.appliedId = OPPER_PROFILE_ID; + const entries = Array.isArray(meta.entries) ? meta.entries : []; + const filtered = entries.filter((e: unknown) => { + const obj = e as { id?: unknown } | null; + return !(obj && typeof obj === "object" && obj.id === OPPER_PROFILE_ID); + }); + filtered.push({ id: OPPER_PROFILE_ID, name: OPPER_PROFILE_NAME }); + meta.entries = filtered; + await writeJson(path, meta); +} + +async function writeGatewayProfile(path: string, apiKey: string): Promise { + const cfg = await readJsonAllowMissing(path); + cfg.inferenceProvider = "gateway"; + cfg.inferenceGatewayBaseUrl = OPPER_COMPAT_URL; + cfg.inferenceGatewayApiKey = apiKey; + cfg.inferenceGatewayAuthScheme = "bearer"; + cfg.disableDeploymentModeChooser = true; + delete cfg.inferenceModels; + await writeJson(path, cfg); +} +``` + +Replace the `configure` function with: + +```ts +async function configure(opts: ConfigureOptions): Promise { + if (!opts.apiKey) { + throw new OpperError( + "AUTH_REQUIRED", + "Claude Desktop configuration needs an Opper API key.", + "Run `opper login` first, or set OPPER_API_KEY.", + ); + } + const targets = targetPaths(); + for (const path of targets.normalConfigs) { + await writeDeploymentMode(path, "3p"); + } + for (const target of targets.thirdPartyProfiles) { + await writeDeploymentMode(target.desktopConfig, "3p"); + await writeMetaWithOpperEntry(target.meta); + await writeGatewayProfile(target.profile, opts.apiKey); + } +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- test/agents/claude-desktop.test.ts && npm run typecheck` +Expected: all configure tests pass; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/claude-desktop.ts test/agents/claude-desktop.test.ts +git commit -m "feat(claude-desktop): implement configure (write 3p profile)" +``` + +--- + +## Task 5: Implement `isConfigured()` + +**Files:** +- Modify: `src/agents/claude-desktop.ts` +- Modify: `test/agents/claude-desktop.test.ts` + +- [ ] **Step 1: Write the failing isConfigured tests** + +Append to `test/agents/claude-desktop.test.ts`: + +```ts +describe("claude-desktop adapter — isConfigured", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + it("returns false on a fresh tree", async () => { + expect(await claudeDesktop.isConfigured()).toBe(false); + }); + + it("returns true after configure()", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + expect(await claudeDesktop.isConfigured()).toBe(true); + }); + + it("returns false when only the normal config is in 3p mode (incomplete)", async () => { + const base = join(home, "Library", "Application Support"); + mkdirSync(join(base, "Claude"), { recursive: true }); + writeFileSync( + join(base, "Claude", "claude_desktop_config.json"), + JSON.stringify({ deploymentMode: "3p" }), + ); + expect(await claudeDesktop.isConfigured()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- test/agents/claude-desktop.test.ts` +Expected: FAIL — current `isConfigured` always returns `false`, so the post-configure case fails. + +- [ ] **Step 3: Implement isConfigured()** + +Replace the `isConfigured` function in `src/agents/claude-desktop.ts`: + +```ts +async function readJsonOrNull(path: string): Promise { + try { + const data = await readFile(path, "utf8"); + const parsed = JSON.parse(data) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as JsonObject; + } + return null; + } catch { + return null; + } +} + +async function deploymentModeIsThirdParty(path: string): Promise { + const cfg = await readJsonOrNull(path); + return cfg?.deploymentMode === "3p"; +} + +async function profileIsOpperGateway(target: ThirdPartyPaths): Promise { + const meta = await readJsonOrNull(target.meta); + if (meta?.appliedId !== OPPER_PROFILE_ID) return false; + const profile = await readJsonOrNull(target.profile); + if (!profile) return false; + if (profile.inferenceProvider !== "gateway") return false; + const url = typeof profile.inferenceGatewayBaseUrl === "string" + ? profile.inferenceGatewayBaseUrl.replace(/\/+$/, "") + : ""; + if (url !== OPPER_COMPAT_URL.replace(/\/+$/, "")) return false; + const key = typeof profile.inferenceGatewayApiKey === "string" + ? profile.inferenceGatewayApiKey.trim() + : ""; + return key.length > 0; +} + +async function isConfigured(): Promise { + const targets = targetPaths(); + if (targets.normalConfigs.length === 0 || targets.thirdPartyProfiles.length === 0) { + return false; + } + for (const path of targets.normalConfigs) { + if (!(await deploymentModeIsThirdParty(path))) return false; + } + for (const target of targets.thirdPartyProfiles) { + if (!(await deploymentModeIsThirdParty(target.desktopConfig))) return false; + if (!(await profileIsOpperGateway(target))) return false; + } + return true; +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- test/agents/claude-desktop.test.ts && npm run typecheck` +Expected: all tests pass; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/claude-desktop.ts test/agents/claude-desktop.test.ts +git commit -m "feat(claude-desktop): implement isConfigured" +``` + +--- + +## Task 6: Implement `unconfigure()` + +**Files:** +- Modify: `src/agents/claude-desktop.ts` +- Modify: `test/agents/claude-desktop.test.ts` + +- [ ] **Step 1: Write the failing unconfigure tests** + +Append to `test/agents/claude-desktop.test.ts`: + +```ts +describe("claude-desktop adapter — unconfigure", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + function readJSON(path: string): any { + return JSON.parse(readFileSyncReal(path, "utf8")); + } + + it("is a no-op on a fresh tree (no errors, no writes)", async () => { + await expect(claudeDesktop.unconfigure()).resolves.toBeUndefined(); + }); + + it("flips deploymentMode back to 1p in both config files", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const base = join(home, "Library", "Application Support"); + expect(readJSON(join(base, "Claude", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "1p", + }); + expect(readJSON(join(base, "Claude-3p", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "1p", + }); + }); + + it("removes the Opper entry from _meta.json and clears appliedId", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const meta = readJSON( + join(home, "Library", "Application Support", "Claude-3p", "configLibrary", "_meta.json"), + ); + expect(meta.appliedId).toBeUndefined(); + const opperEntries = (meta.entries as Array<{ id: string }>).filter( + (e) => e.id === "727f05c8-a429-43cc-b1c6-36d8883d98b8", + ); + expect(opperEntries).toHaveLength(0); + }); + + it("preserves user-owned _meta.json entries", async () => { + const base = join(home, "Library", "Application Support"); + mkdirSync(join(base, "Claude-3p", "configLibrary"), { recursive: true }); + writeFileSync( + join(base, "Claude-3p", "configLibrary", "_meta.json"), + JSON.stringify({ entries: [{ id: "user-other", name: "Other" }] }), + ); + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const meta = readJSON( + join(base, "Claude-3p", "configLibrary", "_meta.json"), + ); + expect(meta.entries).toContainEqual({ id: "user-other", name: "Other" }); + }); + + it("blanks the gateway fields in the profile JSON", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const profile = readJSON( + join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ), + ); + expect(profile.inferenceProvider).toBeUndefined(); + expect(profile.inferenceGatewayBaseUrl).toBeUndefined(); + expect(profile.inferenceGatewayApiKey).toBeUndefined(); + expect(profile.inferenceGatewayAuthScheme).toBeUndefined(); + expect(profile.disableDeploymentModeChooser).toBe(false); + }); + + it("isConfigured returns false after unconfigure", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + expect(await claudeDesktop.isConfigured()).toBe(true); + await claudeDesktop.unconfigure(); + expect(await claudeDesktop.isConfigured()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- test/agents/claude-desktop.test.ts` +Expected: FAIL — `unconfigure` is a no-op stub. + +- [ ] **Step 3: Implement unconfigure()** + +Add helpers to `src/agents/claude-desktop.ts` (above the existing `unconfigure`): + +```ts +async function clearOpperEntryFromMeta(path: string): Promise { + const meta = await readJsonOrNull(path); + if (!meta) return; + let changed = false; + if (meta.appliedId === OPPER_PROFILE_ID) { + delete meta.appliedId; + changed = true; + } + if (Array.isArray(meta.entries)) { + const filtered = meta.entries.filter((e: unknown) => { + const obj = e as { id?: unknown } | null; + return !(obj && typeof obj === "object" && obj.id === OPPER_PROFILE_ID); + }); + if (filtered.length !== meta.entries.length) { + meta.entries = filtered; + changed = true; + } + } + if (changed) await writeJson(path, meta); +} + +async function blankGatewayProfile(path: string): Promise { + const cfg = await readJsonOrNull(path); + if (!cfg) return; + delete cfg.inferenceProvider; + delete cfg.inferenceGatewayBaseUrl; + delete cfg.inferenceGatewayApiKey; + delete cfg.inferenceGatewayAuthScheme; + delete cfg.inferenceModels; + cfg.disableDeploymentModeChooser = false; + await writeJson(path, cfg); +} + +async function maybeFlipToFirstParty(path: string): Promise { + const cfg = await readJsonOrNull(path); + if (!cfg) return; + cfg.deploymentMode = "1p"; + await writeJson(path, cfg); +} +``` + +Replace the `unconfigure` function: + +```ts +async function unconfigure(): Promise { + const targets = targetPaths(); + for (const path of targets.normalConfigs) { + await maybeFlipToFirstParty(path); + } + for (const target of targets.thirdPartyProfiles) { + await maybeFlipToFirstParty(target.desktopConfig); + await clearOpperEntryFromMeta(target.meta); + await blankGatewayProfile(target.profile); + } +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- test/agents/claude-desktop.test.ts && npm run typecheck` +Expected: all tests pass; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/claude-desktop.ts test/agents/claude-desktop.test.ts +git commit -m "feat(claude-desktop): implement unconfigure" +``` + +--- + +## Task 7: `install()` and `spawn()` argument validation + +**Files:** +- Modify: `src/agents/claude-desktop.ts` +- Modify: `test/agents/claude-desktop.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `test/agents/claude-desktop.test.ts`: + +```ts +describe("claude-desktop adapter — install / spawn arg guards", () => { + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + homedirMock.mockReturnValue(makeTempHome()); + }); + + it("install throws AGENT_NOT_FOUND with the manual-install hint", async () => { + await expect(claudeDesktop.install!()).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + hint: expect.stringContaining("claude.ai/download"), + }); + }); + + it("spawn rejects passthrough arguments", async () => { + const ROUTING = { + baseUrl: "https://api.opper.ai/v3/compat", + apiKey: "op_test_key", + model: "claude-opus-4-7", + compatShape: "openai" as const, + }; + await expect(claudeDesktop.spawn!(["foo"], ROUTING)).rejects.toMatchObject({ + message: expect.stringContaining("does not accept"), + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- test/agents/claude-desktop.test.ts` +Expected: FAIL — `install` is already implemented as expected (Task 1), but `spawn` still throws "not yet implemented" rather than "does not accept passthrough". + +- [ ] **Step 3: Implement spawn arg guard (placeholder rest)** + +Replace the `spawn` function in `src/agents/claude-desktop.ts`: + +```ts +async function spawn(args: string[], routing: OpperRouting): Promise { + if (args.length > 0) { + throw new OpperError( + "AGENT_NOT_FOUND", + "claude-desktop does not accept passthrough arguments.", + ); + } + await configure({ apiKey: routing.apiKey }); + // Quit + reopen logic added in Task 8. + return 0; +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- test/agents/claude-desktop.test.ts && npm run typecheck` +Expected: all tests pass; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/claude-desktop.ts test/agents/claude-desktop.test.ts +git commit -m "feat(claude-desktop): install hint + spawn arg guard" +``` + +--- + +## Task 8: `spawn()` — quit-and-reopen flow + +Wire up the actual app launch. We use `src/util/run.ts` (`spawnSync`-based, no shell, fixed argv) for every subprocess. + +**Files:** +- Modify: `src/agents/claude-desktop.ts` +- Modify: `test/agents/claude-desktop.test.ts` + +- [ ] **Step 1: Write the failing spawn tests** + +At the **top** of `test/agents/claude-desktop.test.ts`, add a `run` mock alongside the existing `node:os` mock (insert after the `vi.mock("node:os", ...)` block): + +```ts +const runMock = vi.fn< + Parameters, + ReturnType +>(); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); +``` + +Append a new `describe` block: + +```ts +import type { RunResult } from "../../src/util/run.js"; + +function ok(stdout = ""): RunResult { + return { code: 0, stdout, stderr: "" }; +} + +const ROUTING = { + baseUrl: "https://api.opper.ai/v3/compat", + apiKey: "op_test_key", + model: "claude-opus-4-7", + compatShape: "openai" as const, +}; + +describe("claude-desktop adapter — spawn (macOS)", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + runMock.mockReset(); + }); + + it("opens Claude when not already running", async () => { + runMock.mockImplementation((cmd) => { + if (cmd === "pgrep") return ok(""); // not running -> empty stdout + return ok(); + }); + const code = await claudeDesktop.spawn!([], ROUTING); + expect(code).toBe(0); + + // First call: detect running. Second call: open -a Claude. + expect(runMock).toHaveBeenCalledWith( + "pgrep", + ["-f", "Claude.app/Contents/MacOS/Claude"], + ); + expect(runMock).toHaveBeenCalledWith("open", ["-a", "Claude"]); + // Should not have called osascript when not running. + const osascriptCalls = runMock.mock.calls.filter((c) => c[0] === "osascript"); + expect(osascriptCalls).toHaveLength(0); + }); + + it("quits then reopens Claude when running, polling until exit", async () => { + let pgrepCalls = 0; + runMock.mockImplementation((cmd) => { + if (cmd === "pgrep") { + pgrepCalls += 1; + // First two pgrep calls: running. Third: gone. + return pgrepCalls < 3 ? ok("12345\n") : ok(""); + } + return ok(); + }); + const code = await claudeDesktop.spawn!([], ROUTING); + expect(code).toBe(0); + + expect(runMock).toHaveBeenCalledWith("osascript", [ + "-e", + 'tell application "Claude" to quit', + ]); + expect(runMock).toHaveBeenCalledWith("open", ["-a", "Claude"]); + expect(pgrepCalls).toBeGreaterThanOrEqual(3); + }); + + it("errors when Claude fails to quit within the timeout", async () => { + runMock.mockImplementation((cmd) => { + if (cmd === "pgrep") return ok("12345\n"); // always running + return ok(); + }); + await expect(claudeDesktop.spawn!([], ROUTING)).rejects.toMatchObject({ + message: expect.stringContaining("did not quit"), + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- test/agents/claude-desktop.test.ts` +Expected: FAIL — `spawn` is currently a no-op after `configure`. + +- [ ] **Step 3: Implement quit-and-reopen** + +Add the `run` import at the top of `src/agents/claude-desktop.ts`: + +```ts +import { run } from "../util/run.js"; +``` + +Add these constants near the existing `OPPER_PROFILE_ID`: + +```ts +const QUIT_TIMEOUT_MS = 5_000; +const QUIT_POLL_INTERVAL_MS = 200; +``` + +Add the helpers (above `spawn`): + +```ts +function isClaudeRunning(): boolean { + switch (platform()) { + case "darwin": { + const result = run("pgrep", ["-f", "Claude.app/Contents/MacOS/Claude"]); + return result.code === 0 && result.stdout.trim().length > 0; + } + case "win32": { + const result = run("powershell.exe", [ + "-NoProfile", + "-Command", + "(Get-Process claude -ErrorAction SilentlyContinue | " + + "Where-Object { $_.MainWindowHandle -ne 0 } | " + + "Select-Object -First 1).Id", + ]); + return result.code === 0 && result.stdout.trim().length > 0; + } + default: + return false; + } +} + +function quitClaude(): void { + switch (platform()) { + case "darwin": + run("osascript", ["-e", 'tell application "Claude" to quit']); + return; + case "win32": + run("powershell.exe", [ + "-NoProfile", + "-Command", + "Get-Process claude -ErrorAction SilentlyContinue | " + + "Where-Object { $_.MainWindowHandle -ne 0 } | " + + "ForEach-Object { [void]$_.CloseMainWindow() }", + ]); + return; + } +} + +async function waitForClaudeExit(): Promise { + const deadline = Date.now() + QUIT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!isClaudeRunning()) return true; + await new Promise((r) => setTimeout(r, QUIT_POLL_INTERVAL_MS)); + } + return !isClaudeRunning(); +} + +function openClaude(): void { + switch (platform()) { + case "darwin": + run("open", ["-a", "Claude"]); + return; + case "win32": { + const exe = appCandidates().find((p) => existsSync(p)); + if (!exe) { + throw new OpperError( + "AGENT_NOT_FOUND", + "Claude Desktop executable was not found.", + "Open Claude Desktop manually once and re-run.", + ); + } + run("powershell.exe", [ + "-NoProfile", + "-Command", + `Start-Process -FilePath '${exe.replace(/'/g, "''")}'`, + ]); + return; + } + } +} +``` + +Replace the `spawn` function: + +```ts +async function spawn(args: string[], routing: OpperRouting): Promise { + if (args.length > 0) { + throw new OpperError( + "AGENT_NOT_FOUND", + "claude-desktop does not accept passthrough arguments.", + ); + } + await configure({ apiKey: routing.apiKey }); + + if (isClaudeRunning()) { + quitClaude(); + const exited = await waitForClaudeExit(); + if (!exited) { + throw new OpperError( + "AGENT_RESTORE_FAILED", + "Claude Desktop did not quit within 5s.", + "Quit Claude Desktop manually and re-run `opper launch claude-desktop`.", + ); + } + } + openClaude(); + return 0; +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- test/agents/claude-desktop.test.ts && npm run typecheck` +Expected: all spawn tests pass; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/claude-desktop.ts test/agents/claude-desktop.test.ts +git commit -m "feat(claude-desktop): spawn opens or restarts Claude" +``` + +--- + +## Task 9: `opper agents uninstall ` CLI + +Non-interactive companion to `opper launch `. + +**Files:** +- Modify: `src/commands/agents.ts` +- Modify: `src/cli/agents.ts` +- Modify: `test/commands/agents.test.ts` + +- [ ] **Step 1: Write the failing command tests** + +Append to `test/commands/agents.test.ts` (after the existing `agentsListCommand` describe): + +```ts +const hermesUnconfigure = vi.fn(); +const getAdapterMock = vi.mocked( + (await import("../../src/agents/registry.js")).getAdapter, +); + +describe("agentsUninstallCommand", () => { + beforeEach(() => { + hermesUnconfigure.mockReset(); + getAdapterMock.mockReset(); + }); + + it("calls unconfigure on the resolved adapter", async () => { + getAdapterMock.mockReturnValue({ + name: "hermes", + displayName: "Hermes Agent", + docsUrl: "https://example.com", + detect: vi.fn(), + isConfigured: vi.fn(), + configure: vi.fn(), + unconfigure: hermesUnconfigure, + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { agentsUninstallCommand } = await import( + "../../src/commands/agents.js" + ); + await agentsUninstallCommand("hermes"); + expect(hermesUnconfigure).toHaveBeenCalled(); + const out = log.mock.calls.map((c) => String(c[0])).join("\n"); + expect(out).toContain("Hermes Agent"); + expect(out).toContain("removed"); + } finally { + log.mockRestore(); + } + }); + + it("throws AGENT_NOT_FOUND for unknown adapter names", async () => { + getAdapterMock.mockReturnValue(null); + const { agentsUninstallCommand } = await import( + "../../src/commands/agents.js" + ); + await expect(agentsUninstallCommand("nope")).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- test/commands/agents.test.ts` +Expected: FAIL — `agentsUninstallCommand` doesn't exist yet (import error or runtime error). + +- [ ] **Step 3: Implement agentsUninstallCommand** + +Append to `src/commands/agents.ts` (below the existing `agentsListCommand`): + +```ts +import { getAdapter } from "../agents/registry.js"; +import { OpperError } from "../errors.js"; + +export async function agentsUninstallCommand(name: string): Promise { + const adapter = getAdapter(name); + if (!adapter) { + throw new OpperError( + "AGENT_NOT_FOUND", + `Unknown agent "${name}"`, + "Run `opper agents list` to see supported agents.", + ); + } + await adapter.unconfigure(); + console.log(`${adapter.displayName} integration removed.`); +} +``` + +Note: there is already a `listAdapters` import at the top of `src/commands/agents.ts`. If `getAdapter` is not yet imported there, add it to the existing import line: + +```ts +import { listAdapters, getAdapter } from "../agents/registry.js"; +``` + +- [ ] **Step 4: Wire up the CLI subcommand** + +Modify `src/cli/agents.ts` — replace the contents of the `register` function (after the `list` subcommand registration, before the `launch` registration): + +```ts +import { agentsListCommand, agentsUninstallCommand } from "../commands/agents.js"; +import { launchCommand } from "../commands/launch.js"; +import type { RegisterFn } from "./types.js"; + +const register: RegisterFn = (program, ctx) => { + const agentsCmd = program + .command("agents") + .description("Manage supported AI agents"); + + agentsCmd + .command("list") + .description("List supported agents and whether each is installed") + .action(agentsListCommand); + + agentsCmd + .command("uninstall ") + .description( + "Remove the Opper integration from an agent's config (does not uninstall the agent itself)", + ) + .action(async (name: string) => { + await agentsUninstallCommand(name); + }); + + // ... rest of the existing launch registration unchanged +``` + +Keep the existing `program.command("launch")` block exactly as it is — only add the new `agentsCmd.command("uninstall ")` block above it. + +- [ ] **Step 5: Run tests + typecheck** + +Run: `npm test && npm run typecheck` +Expected: all tests pass; typecheck clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/agents.ts src/cli/agents.ts test/commands/agents.test.ts +git commit -m "feat(agents): add 'opper agents uninstall ' subcommand" +``` + +--- + +## Task 10: End-to-end smoke test on the local machine + +This is a **manual** verification step before opening a PR. It exercises the real macOS code paths (file writes, `osascript`, `open -a`) which the unit tests mock. + +**Pre-conditions:** You're on macOS with Claude Desktop installed and an Opper API key already logged in (`opper whoami` succeeds). + +- [ ] **Step 1: Build and link the dev binary** + +```bash +npm run build +node dist/index.js agents list +``` + +Expected: `claude-desktop` shows up in the table with `installed` status reflecting reality. + +- [ ] **Step 2: Run launch (Claude Desktop closed)** + +```bash +node dist/index.js launch claude-desktop +``` + +Expected: +- Claude Desktop launches. +- `~/Library/Application Support/Claude/claude_desktop_config.json` has `"deploymentMode": "3p"`. +- `~/Library/Application Support/Claude-3p/configLibrary/_meta.json` has `"appliedId": "727f05c8-..."`. +- `~/Library/Application Support/Claude-3p/configLibrary/727f05c8-....json` has `inferenceGatewayBaseUrl: "https://api.opper.ai/v3/compat"`. +- Claude Desktop's chat shows Opper-routed models in the picker. + +Verify by sending a short message in Claude Desktop and watching `opper traces list` / `opper usage list` for activity. + +- [ ] **Step 3: Run launch a second time (Claude Desktop open)** + +```bash +node dist/index.js launch claude-desktop +``` + +Expected: +- macOS may prompt "opper would like to control Claude" the first time — accept. +- Claude Desktop quits and reopens within ~5 seconds. +- The second run is fully idempotent — no duplicate entries in `_meta.json`. + +- [ ] **Step 4: Run uninstall** + +```bash +node dist/index.js agents uninstall claude-desktop +``` + +Expected: +- "Claude Desktop integration removed." printed. +- `~/Library/Application Support/Claude/claude_desktop_config.json` now has `"deploymentMode": "1p"`. +- `_meta.json` no longer contains the Opper entry; `appliedId` is unset. +- Restart Claude Desktop manually — it's back on Anthropic inference. + +- [ ] **Step 5: Re-run launch to confirm round-trip** + +```bash +node dist/index.js launch claude-desktop +``` + +Expected: same outcome as Step 2 — clean re-configuration. + +- [ ] **Step 6: Final lint, test, and typecheck** + +```bash +npm run lint && npm test && npm run typecheck +``` + +Expected: all green. + +- [ ] **Step 7: Open a PR** + +Branch is already on `claude-desktop-support`. Push and open a PR: + +```bash +git push -u origin claude-desktop-support +gh pr create --title "feat: add opper launch claude-desktop" --body "$(cat <<'EOF' +## Summary +- Adds `opper launch claude-desktop` — writes Claude Desktop's third-party-inference profile so its chat and Code surfaces both run through Opper's compat gateway. Mirrors `ollama launch claude-desktop`. +- Adds `opper agents uninstall ` — non-interactive companion to `opper launch ` so users can remove the integration without going through the menu. +- macOS + Windows. Linux returns "not installed" (Claude Desktop has no Linux build). + +## Test plan +- [ ] `npm test` passes +- [ ] `npm run typecheck` passes +- [ ] Manual: `opper launch claude-desktop` configures and launches Claude Desktop on macOS +- [ ] Manual: re-running `opper launch claude-desktop` while it's open quits + reopens cleanly +- [ ] Manual: `opper agents uninstall claude-desktop` reverts deploymentMode back to "1p" +EOF +)" +``` + +Wait for codex to react with a thumbs-up on the PR description before merging (per project convention). + +--- + +## Self-review checklist (already done) + +- **Spec coverage:** every section of the spec maps to a task. + - "Configuration mechanism" → Tasks 4 + 6. + - "Architecture: detect" → Task 2. + - "Architecture: isConfigured" → Task 5. + - "Architecture: configure" → Task 4. + - "Architecture: unconfigure" → Task 6. + - "Architecture: install" → Task 7. + - "Architecture: spawn" → Tasks 7 + 8. + - "Modified registry.ts" → Task 1. + - "New `opper agents uninstall `" → Task 9. + - "Error handling" → covered by argument-guard tests (Task 7), quit-timeout test (Task 8), and unknown-adapter test (Task 9). + - "File atomicity" → not separately tested; node's `writeFile` is atomic-on-same-fs by contract. + - "Constants & filesystem helpers" → constants live where Task 3 places them, helpers in Tasks 4–8. +- **Placeholder scan:** no TBD/TODO/"add error handling"/"similar to" placeholders. +- **Type consistency:** `OpperRouting`, `ConfigureOptions`, `DetectResult`, `AgentAdapter` come from `src/agents/types.ts`; `OPPER_PROFILE_ID` and `QUIT_TIMEOUT_MS` are referenced consistently across tasks. +- **Out of scope (per spec):** `--config`/`--restore`/`--yes` flags, pre-flight key validation, Linux support, backup files, session-summary tuning. None of these have plan tasks. Correct. From 4e51f9f1f8bd02d8dd82022994ff4f568f57ddc5 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:40:16 +0200 Subject: [PATCH 03/16] feat(claude-desktop): scaffold adapter and register --- src/agents/claude-desktop.ts | 47 ++++++++++++++++++++++++++++++++++++ src/agents/registry.ts | 2 ++ test/agents/registry.test.ts | 7 ++++++ 3 files changed, 56 insertions(+) create mode 100644 src/agents/claude-desktop.ts diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts new file mode 100644 index 0000000..0749b7d --- /dev/null +++ b/src/agents/claude-desktop.ts @@ -0,0 +1,47 @@ +import { OpperError } from "../errors.js"; +import type { + AgentAdapter, + ConfigureOptions, + DetectResult, + OpperRouting, +} from "./types.js"; + +async function detect(): Promise { + return { installed: false }; +} + +async function isConfigured(): Promise { + return false; +} + +async function configure(_opts: ConfigureOptions): Promise { + throw new OpperError("AGENT_NOT_FOUND", "claude-desktop adapter not yet implemented"); +} + +async function unconfigure(): Promise { + // Filled in by Task 6. +} + +async function install(): Promise { + throw new OpperError( + "AGENT_NOT_FOUND", + "Claude Desktop must be installed manually.", + "Download Claude Desktop from https://claude.ai/download.", + ); +} + +async function spawn(_args: string[], _routing: OpperRouting): Promise { + throw new OpperError("AGENT_NOT_FOUND", "claude-desktop adapter not yet implemented"); +} + +export const claudeDesktop: AgentAdapter = { + name: "claude-desktop", + displayName: "Claude Desktop", + docsUrl: "https://claude.ai/download", + detect, + isConfigured, + configure, + unconfigure, + install, + spawn, +}; diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 57e2fa4..e7ad3d8 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -1,6 +1,7 @@ import type { AgentAdapter } from "./types.js"; import { opencode } from "./opencode.js"; import { claudeCode } from "./claude-code.js"; +import { claudeDesktop } from "./claude-desktop.js"; import { codex } from "./codex.js"; import { hermes } from "./hermes.js"; import { pi } from "./pi.js"; @@ -9,6 +10,7 @@ import { openclaw } from "./openclaw.js"; const ADAPTERS: ReadonlyArray = [ opencode, claudeCode, + claudeDesktop, codex, hermes, pi, diff --git a/test/agents/registry.test.ts b/test/agents/registry.test.ts index 3c4a346..c3ef84b 100644 --- a/test/agents/registry.test.ts +++ b/test/agents/registry.test.ts @@ -15,4 +15,11 @@ describe("adapter registry", () => { it("returns null for unknown names", () => { expect(getAdapter("nonexistent")).toBeNull(); }); + + it("registers claude-desktop as a launchable adapter", async () => { + const adapter = getAdapter("claude-desktop"); + expect(adapter).not.toBeNull(); + expect(adapter?.displayName).toBe("Claude Desktop"); + expect(typeof adapter?.spawn).toBe("function"); + }); }); From 7010c093ac6309c843fb6b34f501c188789173e9 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:44:38 +0200 Subject: [PATCH 04/16] feat(claude-desktop): implement detect for macOS, Windows, Linux --- src/agents/claude-desktop.ts | 49 +++++++++++++++++++ test/agents/claude-desktop.test.ts | 78 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 test/agents/claude-desktop.test.ts diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index 0749b7d..6e78514 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; import { OpperError } from "../errors.js"; import type { AgentAdapter, @@ -6,7 +9,53 @@ import type { OpperRouting, } from "./types.js"; +function darwinAppCandidates(): string[] { + return [ + "/Applications/Claude.app", + join(homedir(), "Applications", "Claude.app"), + ]; +} + +function windowsLocalAppData(): string | null { + const local = (process.env.LOCALAPPDATA ?? "").trim(); + if (local) return local; + const profile = (process.env.USERPROFILE ?? "").trim(); + if (profile) return join(profile, "AppData", "Local"); + try { + return join(homedir(), "AppData", "Local"); + } catch { + return null; + } +} + +function windowsAppCandidates(): string[] { + const local = windowsLocalAppData(); + if (!local) return []; + return [ + join(local, "Programs", "Claude", "Claude.exe"), + join(local, "Programs", "Claude Desktop", "Claude.exe"), + join(local, "Claude", "Claude.exe"), + join(local, "Claude Nest", "Claude.exe"), + join(local, "Claude Desktop", "Claude.exe"), + join(local, "AnthropicClaude", "Claude.exe"), + ]; +} + +function appCandidates(): string[] { + switch (platform()) { + case "darwin": + return darwinAppCandidates(); + case "win32": + return windowsAppCandidates(); + default: + return []; + } +} + async function detect(): Promise { + for (const candidate of appCandidates()) { + if (existsSync(candidate)) return { installed: true }; + } return { installed: false }; } diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts new file mode 100644 index 0000000..dc01959 --- /dev/null +++ b/test/agents/claude-desktop.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { platformMock, homedirMock, existsSyncMock } = vi.hoisted(() => { + // Capture the real existsSync before any mocking happens + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { existsSync: realExistsSync } = require("node:fs") as typeof import("node:fs"); + const existsSyncMock = vi.fn<[string], boolean>((p: string) => realExistsSync(p)); + return { + platformMock: vi.fn<[], NodeJS.Platform>(() => "darwin"), + homedirMock: vi.fn<[], string>(() => "/nonexistent"), + existsSyncMock, + }; +}); + +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, platform: platformMock, homedir: homedirMock }; +}); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { ...actual, existsSync: existsSyncMock }; +}); + +const { claudeDesktop } = await import("../../src/agents/claude-desktop.js"); + +function makeTempHome(): string { + return mkdtempSync(join(tmpdir(), "opper-claude-desktop-")); +} + +describe("claude-desktop adapter — detect", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + // Reset to real fs behaviour, but always report /Applications/Claude.app + // as absent so CI and developer machines both behave identically. + existsSyncMock.mockReset(); + existsSyncMock.mockImplementation((p: string) => { + if (p === "/Applications/Claude.app") return false; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { existsSync: real } = require("node:fs") as { existsSync: (p: string) => boolean }; + return real(p); + }); + }); + + it("returns installed=false on linux regardless of fs state", async () => { + platformMock.mockReturnValue("linux"); + expect((await claudeDesktop.detect()).installed).toBe(false); + }); + + it("darwin: returns installed=false when no Claude.app candidate exists", async () => { + expect((await claudeDesktop.detect()).installed).toBe(false); + }); + + it("darwin: returns installed=true when /Applications/Claude.app exists", async () => { + // The adapter checks /Applications/Claude.app first; we can't write + // to it in CI, so verify the user-Applications fallback instead. + mkdirSync(join(home, "Applications", "Claude.app"), { recursive: true }); + const result = await claudeDesktop.detect(); + expect(result.installed).toBe(true); + }); + + it("windows: returns installed=true when a known candidate exists", async () => { + platformMock.mockReturnValue("win32"); + const local = join(home, "AppData", "Local"); + process.env.LOCALAPPDATA = local; + mkdirSync(join(local, "AnthropicClaude"), { recursive: true }); + writeFileSync(join(local, "AnthropicClaude", "Claude.exe"), ""); + const result = await claudeDesktop.detect(); + expect(result.installed).toBe(true); + }); +}); From ecc545b7b938544c4f71c548a67ed14fb23ba8b2 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:48:18 +0200 Subject: [PATCH 05/16] fix(claude-desktop): scope LOCALAPPDATA mutation; clarify darwin test name --- test/agents/claude-desktop.test.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index dc01959..0fa144e 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -58,21 +58,27 @@ describe("claude-desktop adapter — detect", () => { expect((await claudeDesktop.detect()).installed).toBe(false); }); - it("darwin: returns installed=true when /Applications/Claude.app exists", async () => { - // The adapter checks /Applications/Claude.app first; we can't write - // to it in CI, so verify the user-Applications fallback instead. + it("darwin: returns installed=true when ~/Applications/Claude.app exists", async () => { + // /Applications/Claude.app is forced absent by the existsSync mock in + // beforeEach; this test exercises the user-Applications fallback instead. mkdirSync(join(home, "Applications", "Claude.app"), { recursive: true }); const result = await claudeDesktop.detect(); expect(result.installed).toBe(true); }); it("windows: returns installed=true when a known candidate exists", async () => { - platformMock.mockReturnValue("win32"); - const local = join(home, "AppData", "Local"); - process.env.LOCALAPPDATA = local; - mkdirSync(join(local, "AnthropicClaude"), { recursive: true }); - writeFileSync(join(local, "AnthropicClaude", "Claude.exe"), ""); - const result = await claudeDesktop.detect(); - expect(result.installed).toBe(true); + const prev = process.env.LOCALAPPDATA; + try { + platformMock.mockReturnValue("win32"); + const local = join(home, "AppData", "Local"); + process.env.LOCALAPPDATA = local; + mkdirSync(join(local, "AnthropicClaude"), { recursive: true }); + writeFileSync(join(local, "AnthropicClaude", "Claude.exe"), ""); + const result = await claudeDesktop.detect(); + expect(result.installed).toBe(true); + } finally { + if (prev === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = prev; + } }); }); From af6e4039d8baf3c041bda303a7c42ad1af78dada Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:49:59 +0200 Subject: [PATCH 06/16] feat(claude-desktop): add profile path helpers --- src/agents/claude-desktop.ts | 69 ++++++++++++++++++++++++++++++ test/agents/claude-desktop.test.ts | 14 ++++++ 2 files changed, 83 insertions(+) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index 6e78514..9fd04f5 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -52,6 +52,75 @@ function appCandidates(): string[] { } } +const OPPER_PROFILE_ID = "727f05c8-a429-43cc-b1c6-36d8883d98b8"; +const OPPER_PROFILE_NAME = "Opper"; + +interface ThirdPartyPaths { + desktopConfig: string; + meta: string; + profile: string; +} + +interface ConfigTargets { + normalConfigs: string[]; + thirdPartyProfiles: ThirdPartyPaths[]; +} + +function darwinProfileRoots(): { normal: string[]; thirdParty: string[] } { + const base = join(homedir(), "Library", "Application Support"); + return { + normal: [join(base, "Claude")], + thirdParty: [join(base, "Claude-3p")], + }; +} + +function windowsProfileRoots(): { normal: string[]; thirdParty: string[] } { + const local = windowsLocalAppData(); + if (!local) return { normal: [], thirdParty: [] }; + return { + normal: [join(local, "Claude"), join(local, "Claude Nest")], + thirdParty: [join(local, "Claude-3p"), join(local, "Claude Nest-3p")], + }; +} + +function profileRoots(): { normal: string[]; thirdParty: string[] } { + switch (platform()) { + case "darwin": + return darwinProfileRoots(); + case "win32": + return windowsProfileRoots(); + default: + return { normal: [], thirdParty: [] }; + } +} + +function dedupePaths(paths: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const p of paths) { + if (!p) continue; + const key = p.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(p); + } + return out; +} + +function targetPaths(): ConfigTargets { + const { normal, thirdParty } = profileRoots(); + return { + normalConfigs: dedupePaths(normal).map((root) => + join(root, "claude_desktop_config.json"), + ), + thirdPartyProfiles: dedupePaths(thirdParty).map((root) => ({ + desktopConfig: join(root, "claude_desktop_config.json"), + meta: join(root, "configLibrary", "_meta.json"), + profile: join(root, "configLibrary", `${OPPER_PROFILE_ID}.json`), + })), + }; +} + async function detect(): Promise { for (const candidate of appCandidates()) { if (existsSync(candidate)) return { installed: true }; diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index 0fa144e..198cda1 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -82,3 +82,17 @@ describe("claude-desktop adapter — detect", () => { } }); }); + +describe("claude-desktop adapter — paths (via isConfigured)", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + it("returns false when no config files exist (fresh tree)", async () => { + expect(await claudeDesktop.isConfigured()).toBe(false); + }); +}); From 3c1c21710d3c8ab21c9dcc3199e162cf5bbb9b46 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:52:57 +0200 Subject: [PATCH 07/16] feat(claude-desktop): implement configure (write 3p profile) --- src/agents/claude-desktop.ts | 74 +++++++++++++++++++- test/agents/claude-desktop.test.ts | 106 +++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index 9fd04f5..a0d15d8 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -1,7 +1,9 @@ import { existsSync } from "node:fs"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; import { homedir, platform } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { OpperError } from "../errors.js"; +import { OPPER_COMPAT_URL } from "../config/endpoints.js"; import type { AgentAdapter, ConfigureOptions, @@ -121,6 +123,57 @@ function targetPaths(): ConfigTargets { }; } +type JsonObject = Record; + +async function readJsonAllowMissing(path: string): Promise { + try { + const data = await readFile(path, "utf8"); + const parsed = JSON.parse(data) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as JsonObject; + } + return {}; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } +} + +async function writeJson(path: string, data: JsonObject): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf8"); +} + +async function writeDeploymentMode(path: string, mode: "1p" | "3p"): Promise { + const cfg = await readJsonAllowMissing(path); + cfg.deploymentMode = mode; + await writeJson(path, cfg); +} + +async function writeMetaWithOpperEntry(path: string): Promise { + const meta = await readJsonAllowMissing(path); + meta.appliedId = OPPER_PROFILE_ID; + const entries = Array.isArray(meta.entries) ? meta.entries : []; + const filtered = entries.filter((e: unknown) => { + const obj = e as { id?: unknown } | null; + return !(obj && typeof obj === "object" && obj.id === OPPER_PROFILE_ID); + }); + filtered.push({ id: OPPER_PROFILE_ID, name: OPPER_PROFILE_NAME }); + meta.entries = filtered; + await writeJson(path, meta); +} + +async function writeGatewayProfile(path: string, apiKey: string): Promise { + const cfg = await readJsonAllowMissing(path); + cfg.inferenceProvider = "gateway"; + cfg.inferenceGatewayBaseUrl = OPPER_COMPAT_URL; + cfg.inferenceGatewayApiKey = apiKey; + cfg.inferenceGatewayAuthScheme = "bearer"; + cfg.disableDeploymentModeChooser = true; + delete cfg.inferenceModels; + await writeJson(path, cfg); +} + async function detect(): Promise { for (const candidate of appCandidates()) { if (existsSync(candidate)) return { installed: true }; @@ -132,8 +185,23 @@ async function isConfigured(): Promise { return false; } -async function configure(_opts: ConfigureOptions): Promise { - throw new OpperError("AGENT_NOT_FOUND", "claude-desktop adapter not yet implemented"); +async function configure(opts: ConfigureOptions): Promise { + if (!opts.apiKey) { + throw new OpperError( + "AUTH_REQUIRED", + "Claude Desktop configuration needs an Opper API key.", + "Run `opper login` first, or set OPPER_API_KEY.", + ); + } + const targets = targetPaths(); + for (const path of targets.normalConfigs) { + await writeDeploymentMode(path, "3p"); + } + for (const target of targets.thirdPartyProfiles) { + await writeDeploymentMode(target.desktopConfig, "3p"); + await writeMetaWithOpperEntry(target.meta); + await writeGatewayProfile(target.profile, opts.apiKey); + } } async function unconfigure(): Promise { diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index 198cda1..d9094fb 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { readFileSync as readFileSyncReal } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -96,3 +97,108 @@ describe("claude-desktop adapter — paths (via isConfigured)", () => { expect(await claudeDesktop.isConfigured()).toBe(false); }); }); + +describe("claude-desktop adapter — configure", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + function readJSON(path: string): any { + return JSON.parse(readFileSyncReal(path, "utf8")); + } + + it("throws AUTH_REQUIRED when called without an apiKey", async () => { + await expect(claudeDesktop.configure({})).rejects.toMatchObject({ + code: "AUTH_REQUIRED", + }); + }); + + it("writes deploymentMode=3p to both normal and 3p config files", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const base = join(home, "Library", "Application Support"); + expect(readJSON(join(base, "Claude", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "3p", + }); + expect(readJSON(join(base, "Claude-3p", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "3p", + }); + }); + + it("writes the Opper entry into _meta.json and sets appliedId", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const meta = readJSON( + join(home, "Library", "Application Support", "Claude-3p", "configLibrary", "_meta.json"), + ); + expect(meta.appliedId).toBe("727f05c8-a429-43cc-b1c6-36d8883d98b8"); + expect(meta.entries).toContainEqual({ + id: "727f05c8-a429-43cc-b1c6-36d8883d98b8", + name: "Opper", + }); + }); + + it("writes the gateway profile JSON with Opper's compat URL and the api key", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const profile = readJSON( + join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ), + ); + expect(profile).toMatchObject({ + inferenceProvider: "gateway", + inferenceGatewayBaseUrl: "https://api.opper.ai/v3/compat", + inferenceGatewayApiKey: "op_test_key", + inferenceGatewayAuthScheme: "bearer", + disableDeploymentModeChooser: true, + }); + }); + + it("preserves user-owned siblings in the normal config and _meta.json", async () => { + const base = join(home, "Library", "Application Support"); + const normalCfg = join(base, "Claude", "claude_desktop_config.json"); + mkdirSync(join(base, "Claude"), { recursive: true }); + writeFileSync( + normalCfg, + JSON.stringify({ mcpServers: { fs: { command: "fs" } } }, null, 2), + ); + const metaPath = join(base, "Claude-3p", "configLibrary", "_meta.json"); + mkdirSync(join(base, "Claude-3p", "configLibrary"), { recursive: true }); + writeFileSync( + metaPath, + JSON.stringify({ entries: [{ id: "user-other", name: "Other" }] }, null, 2), + ); + + await claudeDesktop.configure({ apiKey: "op_test_key" }); + + expect(readJSON(normalCfg)).toMatchObject({ + mcpServers: { fs: { command: "fs" } }, + deploymentMode: "3p", + }); + const meta = readJSON(metaPath); + expect(meta.entries).toContainEqual({ id: "user-other", name: "Other" }); + expect(meta.entries).toContainEqual({ + id: "727f05c8-a429-43cc-b1c6-36d8883d98b8", + name: "Opper", + }); + }); + + it("is idempotent — running twice does not duplicate the Opper entry", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const meta = readJSON( + join(home, "Library", "Application Support", "Claude-3p", "configLibrary", "_meta.json"), + ); + const opperEntries = (meta.entries as Array<{ id: string }>).filter( + (e) => e.id === "727f05c8-a429-43cc-b1c6-36d8883d98b8", + ); + expect(opperEntries).toHaveLength(1); + }); +}); From 85398828bf0738ecd3086dedab4e40dcd928782b Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:55:25 +0200 Subject: [PATCH 08/16] feat(claude-desktop): implement isConfigured --- src/agents/claude-desktop.ts | 47 +++++++++++++++++++++++++++++- test/agents/claude-desktop.test.ts | 29 ++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index a0d15d8..6a5500d 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -144,6 +144,40 @@ async function writeJson(path: string, data: JsonObject): Promise { await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf8"); } +async function readJsonOrNull(path: string): Promise { + try { + const data = await readFile(path, "utf8"); + const parsed = JSON.parse(data) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as JsonObject; + } + return null; + } catch { + return null; + } +} + +async function deploymentModeIsThirdParty(path: string): Promise { + const cfg = await readJsonOrNull(path); + return cfg?.deploymentMode === "3p"; +} + +async function profileIsOpperGateway(target: ThirdPartyPaths): Promise { + const meta = await readJsonOrNull(target.meta); + if (meta?.appliedId !== OPPER_PROFILE_ID) return false; + const profile = await readJsonOrNull(target.profile); + if (!profile) return false; + if (profile.inferenceProvider !== "gateway") return false; + const url = typeof profile.inferenceGatewayBaseUrl === "string" + ? profile.inferenceGatewayBaseUrl.replace(/\/+$/, "") + : ""; + if (url !== OPPER_COMPAT_URL.replace(/\/+$/, "")) return false; + const key = typeof profile.inferenceGatewayApiKey === "string" + ? profile.inferenceGatewayApiKey.trim() + : ""; + return key.length > 0; +} + async function writeDeploymentMode(path: string, mode: "1p" | "3p"): Promise { const cfg = await readJsonAllowMissing(path); cfg.deploymentMode = mode; @@ -182,7 +216,18 @@ async function detect(): Promise { } async function isConfigured(): Promise { - return false; + const targets = targetPaths(); + if (targets.normalConfigs.length === 0 || targets.thirdPartyProfiles.length === 0) { + return false; + } + for (const path of targets.normalConfigs) { + if (!(await deploymentModeIsThirdParty(path))) return false; + } + for (const target of targets.thirdPartyProfiles) { + if (!(await deploymentModeIsThirdParty(target.desktopConfig))) return false; + if (!(await profileIsOpperGateway(target))) return false; + } + return true; } async function configure(opts: ConfigureOptions): Promise { diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index d9094fb..d911e9e 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -98,6 +98,35 @@ describe("claude-desktop adapter — paths (via isConfigured)", () => { }); }); +describe("claude-desktop adapter — isConfigured", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + it("returns false on a fresh tree", async () => { + expect(await claudeDesktop.isConfigured()).toBe(false); + }); + + it("returns true after configure()", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + expect(await claudeDesktop.isConfigured()).toBe(true); + }); + + it("returns false when only the normal config is in 3p mode (incomplete)", async () => { + const base = join(home, "Library", "Application Support"); + mkdirSync(join(base, "Claude"), { recursive: true }); + writeFileSync( + join(base, "Claude", "claude_desktop_config.json"), + JSON.stringify({ deploymentMode: "3p" }), + ); + expect(await claudeDesktop.isConfigured()).toBe(false); + }); +}); + describe("claude-desktop adapter — configure", () => { let home: string; From 573518f943a5c15d2810b5302a71123412dae320 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:57:42 +0200 Subject: [PATCH 09/16] feat(claude-desktop): implement unconfigure --- src/agents/claude-desktop.ts | 50 +++++++++++++++++- test/agents/claude-desktop.test.ts | 85 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index 6a5500d..ea56ced 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -249,8 +249,56 @@ async function configure(opts: ConfigureOptions): Promise { } } +async function clearOpperEntryFromMeta(path: string): Promise { + const meta = await readJsonOrNull(path); + if (!meta) return; + let changed = false; + if (meta.appliedId === OPPER_PROFILE_ID) { + delete meta.appliedId; + changed = true; + } + if (Array.isArray(meta.entries)) { + const filtered = meta.entries.filter((e: unknown) => { + const obj = e as { id?: unknown } | null; + return !(obj && typeof obj === "object" && obj.id === OPPER_PROFILE_ID); + }); + if (filtered.length !== meta.entries.length) { + meta.entries = filtered; + changed = true; + } + } + if (changed) await writeJson(path, meta); +} + +async function blankGatewayProfile(path: string): Promise { + const cfg = await readJsonOrNull(path); + if (!cfg) return; + delete cfg.inferenceProvider; + delete cfg.inferenceGatewayBaseUrl; + delete cfg.inferenceGatewayApiKey; + delete cfg.inferenceGatewayAuthScheme; + delete cfg.inferenceModels; + cfg.disableDeploymentModeChooser = false; + await writeJson(path, cfg); +} + +async function maybeFlipToFirstParty(path: string): Promise { + const cfg = await readJsonOrNull(path); + if (!cfg) return; + cfg.deploymentMode = "1p"; + await writeJson(path, cfg); +} + async function unconfigure(): Promise { - // Filled in by Task 6. + const targets = targetPaths(); + for (const path of targets.normalConfigs) { + await maybeFlipToFirstParty(path); + } + for (const target of targets.thirdPartyProfiles) { + await maybeFlipToFirstParty(target.desktopConfig); + await clearOpperEntryFromMeta(target.meta); + await blankGatewayProfile(target.profile); + } } async function install(): Promise { diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index d911e9e..fbf8d70 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -231,3 +231,88 @@ describe("claude-desktop adapter — configure", () => { expect(opperEntries).toHaveLength(1); }); }); + +describe("claude-desktop adapter — unconfigure", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + }); + + function readJSON(path: string): any { + return JSON.parse(readFileSyncReal(path, "utf8")); + } + + it("is a no-op on a fresh tree (no errors, no writes)", async () => { + await expect(claudeDesktop.unconfigure()).resolves.toBeUndefined(); + }); + + it("flips deploymentMode back to 1p in both config files", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const base = join(home, "Library", "Application Support"); + expect(readJSON(join(base, "Claude", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "1p", + }); + expect(readJSON(join(base, "Claude-3p", "claude_desktop_config.json"))).toMatchObject({ + deploymentMode: "1p", + }); + }); + + it("removes the Opper entry from _meta.json and clears appliedId", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const meta = readJSON( + join(home, "Library", "Application Support", "Claude-3p", "configLibrary", "_meta.json"), + ); + expect(meta.appliedId).toBeUndefined(); + const opperEntries = (meta.entries as Array<{ id: string }>).filter( + (e) => e.id === "727f05c8-a429-43cc-b1c6-36d8883d98b8", + ); + expect(opperEntries).toHaveLength(0); + }); + + it("preserves user-owned _meta.json entries", async () => { + const base = join(home, "Library", "Application Support"); + mkdirSync(join(base, "Claude-3p", "configLibrary"), { recursive: true }); + writeFileSync( + join(base, "Claude-3p", "configLibrary", "_meta.json"), + JSON.stringify({ entries: [{ id: "user-other", name: "Other" }] }), + ); + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const meta = readJSON( + join(base, "Claude-3p", "configLibrary", "_meta.json"), + ); + expect(meta.entries).toContainEqual({ id: "user-other", name: "Other" }); + }); + + it("blanks the gateway fields in the profile JSON", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.unconfigure(); + const profile = readJSON( + join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ), + ); + expect(profile.inferenceProvider).toBeUndefined(); + expect(profile.inferenceGatewayBaseUrl).toBeUndefined(); + expect(profile.inferenceGatewayApiKey).toBeUndefined(); + expect(profile.inferenceGatewayAuthScheme).toBeUndefined(); + expect(profile.disableDeploymentModeChooser).toBe(false); + }); + + it("isConfigured returns false after unconfigure", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + expect(await claudeDesktop.isConfigured()).toBe(true); + await claudeDesktop.unconfigure(); + expect(await claudeDesktop.isConfigured()).toBe(false); + }); +}); From 33d7dd9e91f0c76535746a359ff2448e5bdecd5d Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 13:59:51 +0200 Subject: [PATCH 10/16] feat(claude-desktop): install hint + spawn arg guard --- src/agents/claude-desktop.ts | 12 ++++++++++-- test/agents/claude-desktop.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index ea56ced..c54e626 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -309,8 +309,16 @@ async function install(): Promise { ); } -async function spawn(_args: string[], _routing: OpperRouting): Promise { - throw new OpperError("AGENT_NOT_FOUND", "claude-desktop adapter not yet implemented"); +async function spawn(args: string[], routing: OpperRouting): Promise { + if (args.length > 0) { + throw new OpperError( + "AGENT_NOT_FOUND", + "claude-desktop does not accept passthrough arguments.", + ); + } + await configure({ apiKey: routing.apiKey }); + // Quit + reopen logic added in Task 8. + return 0; } export const claudeDesktop: AgentAdapter = { diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index fbf8d70..5f8b162 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -316,3 +316,29 @@ describe("claude-desktop adapter — unconfigure", () => { expect(await claudeDesktop.isConfigured()).toBe(false); }); }); + +describe("claude-desktop adapter — install / spawn arg guards", () => { + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + homedirMock.mockReturnValue(makeTempHome()); + }); + + it("install throws AGENT_NOT_FOUND with the manual-install hint", async () => { + await expect(claudeDesktop.install!()).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + hint: expect.stringContaining("claude.ai/download"), + }); + }); + + it("spawn rejects passthrough arguments", async () => { + const ROUTING = { + baseUrl: "https://api.opper.ai/v3/compat", + apiKey: "op_test_key", + model: "claude-opus-4-7", + compatShape: "openai" as const, + }; + await expect(claudeDesktop.spawn!(["foo"], ROUTING)).rejects.toMatchObject({ + message: expect.stringContaining("does not accept"), + }); + }); +}); From 62752209cff1f94e9c80e8345718a5fba2c593b1 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 14:05:30 +0200 Subject: [PATCH 11/16] feat(claude-desktop): spawn opens or restarts Claude --- src/agents/claude-desktop.ts | 89 +++++++++++++++++++++++- test/agents/claude-desktop.test.ts | 106 ++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 3 deletions(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index c54e626..68f83d5 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -4,6 +4,7 @@ import { homedir, platform } from "node:os"; import { join, dirname } from "node:path"; import { OpperError } from "../errors.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; +import { run } from "../util/run.js"; import type { AgentAdapter, ConfigureOptions, @@ -57,6 +58,9 @@ function appCandidates(): string[] { const OPPER_PROFILE_ID = "727f05c8-a429-43cc-b1c6-36d8883d98b8"; const OPPER_PROFILE_NAME = "Opper"; +const QUIT_TIMEOUT_MS = 5_000; +const QUIT_POLL_INTERVAL_MS = 200; + interface ThirdPartyPaths { desktopConfig: string; meta: string; @@ -309,6 +313,77 @@ async function install(): Promise { ); } +function isClaudeRunning(): boolean { + switch (platform()) { + case "darwin": { + const result = run("pgrep", ["-f", "Claude.app/Contents/MacOS/Claude"]); + return result.code === 0 && result.stdout.trim().length > 0; + } + case "win32": { + const result = run("powershell.exe", [ + "-NoProfile", + "-Command", + "(Get-Process claude -ErrorAction SilentlyContinue | " + + "Where-Object { $_.MainWindowHandle -ne 0 } | " + + "Select-Object -First 1).Id", + ]); + return result.code === 0 && result.stdout.trim().length > 0; + } + default: + return false; + } +} + +function quitClaude(): void { + switch (platform()) { + case "darwin": + run("osascript", ["-e", 'tell application "Claude" to quit']); + return; + case "win32": + run("powershell.exe", [ + "-NoProfile", + "-Command", + "Get-Process claude -ErrorAction SilentlyContinue | " + + "Where-Object { $_.MainWindowHandle -ne 0 } | " + + "ForEach-Object { [void]$_.CloseMainWindow() }", + ]); + return; + } +} + +async function waitForClaudeExit(): Promise { + const deadline = Date.now() + QUIT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!isClaudeRunning()) return true; + await new Promise((r) => setTimeout(r, QUIT_POLL_INTERVAL_MS)); + } + return !isClaudeRunning(); +} + +function openClaude(): void { + switch (platform()) { + case "darwin": + run("open", ["-a", "Claude"]); + return; + case "win32": { + const exe = appCandidates().find((p) => existsSync(p)); + if (!exe) { + throw new OpperError( + "AGENT_NOT_FOUND", + "Claude Desktop executable was not found.", + "Open Claude Desktop manually once and re-run.", + ); + } + run("powershell.exe", [ + "-NoProfile", + "-Command", + `Start-Process -FilePath '${exe.replace(/'/g, "''")}'`, + ]); + return; + } + } +} + async function spawn(args: string[], routing: OpperRouting): Promise { if (args.length > 0) { throw new OpperError( @@ -317,7 +392,19 @@ async function spawn(args: string[], routing: OpperRouting): Promise { ); } await configure({ apiKey: routing.apiKey }); - // Quit + reopen logic added in Task 8. + + if (isClaudeRunning()) { + quitClaude(); + const exited = await waitForClaudeExit(); + if (!exited) { + throw new OpperError( + "AGENT_RESTORE_FAILED", + "Claude Desktop did not quit within 5s.", + "Quit Claude Desktop manually and re-run `opper launch claude-desktop`.", + ); + } + } + openClaude(); return 0; } diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index 5f8b162..858a376 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { readFileSync as readFileSyncReal } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const { platformMock, homedirMock, existsSyncMock } = vi.hoisted(() => { +const { platformMock, homedirMock, existsSyncMock, runMock } = vi.hoisted(() => { // Capture the real existsSync before any mocking happens // eslint-disable-next-line @typescript-eslint/no-require-imports const { existsSync: realExistsSync } = require("node:fs") as typeof import("node:fs"); @@ -13,6 +13,7 @@ const { platformMock, homedirMock, existsSyncMock } = vi.hoisted(() => { platformMock: vi.fn<[], NodeJS.Platform>(() => "darwin"), homedirMock: vi.fn<[], string>(() => "/nonexistent"), existsSyncMock, + runMock: vi.fn(), }; }); @@ -26,6 +27,8 @@ vi.mock("node:fs", async () => { return { ...actual, existsSync: existsSyncMock }; }); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + const { claudeDesktop } = await import("../../src/agents/claude-desktop.js"); function makeTempHome(): string { @@ -321,6 +324,8 @@ describe("claude-desktop adapter — install / spawn arg guards", () => { beforeEach(() => { platformMock.mockReturnValue("darwin"); homedirMock.mockReturnValue(makeTempHome()); + runMock.mockReset(); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); }); it("install throws AGENT_NOT_FOUND with the manual-install hint", async () => { @@ -342,3 +347,100 @@ describe("claude-desktop adapter — install / spawn arg guards", () => { }); }); }); + +import type { RunResult } from "../../src/util/run.js"; + +function ok(stdout = ""): RunResult { + return { code: 0, stdout, stderr: "" }; +} + +const ROUTING = { + baseUrl: "https://api.opper.ai/v3/compat", + apiKey: "op_test_key", + model: "claude-opus-4-7", + compatShape: "openai" as const, +}; + +describe("claude-desktop adapter — spawn (macOS)", () => { + let home: string; + + beforeEach(() => { + platformMock.mockReturnValue("darwin"); + home = makeTempHome(); + homedirMock.mockReturnValue(home); + runMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("opens Claude when not already running", async () => { + runMock.mockImplementation((cmd: string) => { + if (cmd === "pgrep") return ok(""); // not running -> empty stdout + return ok(); + }); + const code = await claudeDesktop.spawn!([], ROUTING); + expect(code).toBe(0); + + // First call: detect running. Second call: open -a Claude. + expect(runMock).toHaveBeenCalledWith( + "pgrep", + ["-f", "Claude.app/Contents/MacOS/Claude"], + ); + expect(runMock).toHaveBeenCalledWith("open", ["-a", "Claude"]); + // Should not have called osascript when not running. + const osascriptCalls = runMock.mock.calls.filter((c: string[]) => c[0] === "osascript"); + expect(osascriptCalls).toHaveLength(0); + }); + + it("quits then reopens Claude when running, polling until exit", async () => { + let pgrepCalls = 0; + runMock.mockImplementation((cmd: string) => { + if (cmd === "pgrep") { + pgrepCalls += 1; + // First two pgrep calls: running. Third: gone. + return pgrepCalls < 3 ? ok("12345\n") : ok(""); + } + return ok(); + }); + const code = await claudeDesktop.spawn!([], ROUTING); + expect(code).toBe(0); + + expect(runMock).toHaveBeenCalledWith("osascript", [ + "-e", + 'tell application "Claude" to quit', + ]); + expect(runMock).toHaveBeenCalledWith("open", ["-a", "Claude"]); + expect(pgrepCalls).toBeGreaterThanOrEqual(3); + }); + + it("errors when Claude fails to quit within the timeout", async () => { + // Spy on setTimeout to call back immediately AND advance Date.now() so the + // deadline check exits after one iteration of the polling loop. + const realDateNow = Date.now; + let fakeNow = realDateNow(); + vi.spyOn(global, "setTimeout").mockImplementation( + (fn: TimerHandler, _delay?: number) => { + // Each sleep advances fake time by QUIT_POLL_INTERVAL_MS (200ms). + fakeNow += 200; + (fn as () => void)(); + return 0 as unknown as ReturnType; + }, + ); + vi.spyOn(Date, "now").mockImplementation(() => fakeNow); + + runMock.mockImplementation((cmd: string) => { + if (cmd === "pgrep") return ok("12345\n"); // always running + return ok(); + }); + + try { + await expect(claudeDesktop.spawn!([], ROUTING)).rejects.toMatchObject({ + message: expect.stringContaining("did not quit"), + }); + } finally { + vi.restoreAllMocks(); + } + }); +}); From eb9d73647e6243b938ebffc652181a2c8086bed3 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 14:09:03 +0200 Subject: [PATCH 12/16] feat(agents): add 'opper agents uninstall ' subcommand --- src/cli/agents.ts | 11 +++++++- src/commands/agents.ts | 16 +++++++++++- test/commands/agents.test.ts | 49 +++++++++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/cli/agents.ts b/src/cli/agents.ts index 4e82ed2..296be7e 100644 --- a/src/cli/agents.ts +++ b/src/cli/agents.ts @@ -1,4 +1,4 @@ -import { agentsListCommand } from "../commands/agents.js"; +import { agentsListCommand, agentsUninstallCommand } from "../commands/agents.js"; import { launchCommand } from "../commands/launch.js"; import type { RegisterFn } from "./types.js"; @@ -12,6 +12,15 @@ const register: RegisterFn = (program, ctx) => { .description("List supported agents and whether each is installed") .action(agentsListCommand); + agentsCmd + .command("uninstall ") + .description( + "Remove the Opper integration from an agent's config (does not uninstall the agent itself)", + ) + .action(async (name: string) => { + await agentsUninstallCommand(name); + }); + program .command("launch") .description( diff --git a/src/commands/agents.ts b/src/commands/agents.ts index e1c735d..5920d39 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -1,6 +1,7 @@ -import { listAdapters } from "../agents/registry.js"; +import { listAdapters, getAdapter } from "../agents/registry.js"; import { isLaunchable } from "../agents/types.js"; import { brand } from "../ui/colors.js"; +import { OpperError } from "../errors.js"; interface Row { name: string; @@ -85,3 +86,16 @@ export async function agentsListCommand(): Promise { ); } } + +export async function agentsUninstallCommand(name: string): Promise { + const adapter = getAdapter(name); + if (!adapter) { + throw new OpperError( + "AGENT_NOT_FOUND", + `Unknown agent "${name}"`, + "Run `opper agents list` to see supported agents.", + ); + } + await adapter.unconfigure(); + console.log(`${adapter.displayName} integration removed.`); +} diff --git a/test/commands/agents.test.ts b/test/commands/agents.test.ts index ca0c801..97b1a3c 100644 --- a/test/commands/agents.test.ts +++ b/test/commands/agents.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; const hermesDetect = vi.fn(); const hermesIsConfigured = vi.fn().mockResolvedValue(false); @@ -21,6 +21,11 @@ vi.mock("../../src/agents/registry.js", () => ({ const { agentsListCommand } = await import("../../src/commands/agents.js"); +const hermesUnconfigure = vi.fn(); +const getAdapterMock = vi.mocked( + (await import("../../src/agents/registry.js")).getAdapter, +); + describe("agentsListCommand", () => { it("prints each adapter with installed status, slug, and launch command", async () => { hermesDetect.mockResolvedValue({ @@ -55,3 +60,45 @@ describe("agentsListCommand", () => { } }); }); + +describe("agentsUninstallCommand", () => { + beforeEach(() => { + hermesUnconfigure.mockReset(); + getAdapterMock.mockReset(); + }); + + it("calls unconfigure on the resolved adapter", async () => { + getAdapterMock.mockReturnValue({ + name: "hermes", + displayName: "Hermes Agent", + docsUrl: "https://example.com", + detect: vi.fn(), + isConfigured: vi.fn(), + configure: vi.fn(), + unconfigure: hermesUnconfigure, + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { agentsUninstallCommand } = await import( + "../../src/commands/agents.js" + ); + await agentsUninstallCommand("hermes"); + expect(hermesUnconfigure).toHaveBeenCalled(); + const out = log.mock.calls.map((c) => String(c[0])).join("\n"); + expect(out).toContain("Hermes Agent"); + expect(out).toContain("removed"); + } finally { + log.mockRestore(); + } + }); + + it("throws AGENT_NOT_FOUND for unknown adapter names", async () => { + getAdapterMock.mockReturnValue(null); + const { agentsUninstallCommand } = await import( + "../../src/commands/agents.js" + ); + await expect(agentsUninstallCommand("nope")).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + }); + }); +}); From 03129762838515548bcc37f16cc9971970e1fc76 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 14:15:48 +0200 Subject: [PATCH 13/16] fix(claude-desktop): windows globs, arg-guard code, TCC hint --- src/agents/claude-desktop.ts | 42 ++++++++++++++++++++++++++---- test/agents/claude-desktop.test.ts | 35 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index 68f83d5..bdfcbcf 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { homedir, platform } from "node:os"; import { join, dirname } from "node:path"; @@ -31,10 +31,33 @@ function windowsLocalAppData(): string | null { } } +function windowsGlobCandidates(local: string): string[] { + const parents = [ + join(local, "AnthropicClaude"), + join(local, "Programs", "Claude"), + join(local, "Programs", "Claude Desktop"), + ]; + const out: string[] = []; + for (const parent of parents) { + let entries: string[]; + try { + entries = readdirSync(parent); + } catch { + continue; + } + for (const entry of entries) { + if (entry.startsWith("app-")) { + out.push(join(parent, entry, "Claude.exe")); + } + } + } + return out; +} + function windowsAppCandidates(): string[] { const local = windowsLocalAppData(); if (!local) return []; - return [ + const fixed = [ join(local, "Programs", "Claude", "Claude.exe"), join(local, "Programs", "Claude Desktop", "Claude.exe"), join(local, "Claude", "Claude.exe"), @@ -42,6 +65,7 @@ function windowsAppCandidates(): string[] { join(local, "Claude Desktop", "Claude.exe"), join(local, "AnthropicClaude", "Claude.exe"), ]; + return dedupePaths([...fixed, ...windowsGlobCandidates(local)]); } function appCandidates(): string[] { @@ -336,9 +360,17 @@ function isClaudeRunning(): boolean { function quitClaude(): void { switch (platform()) { - case "darwin": - run("osascript", ["-e", 'tell application "Claude" to quit']); + case "darwin": { + const result = run("osascript", ["-e", 'tell application "Claude" to quit']); + if (result.code !== 0 && /Not authorized/i.test(result.stderr)) { + throw new OpperError( + "AGENT_CONFIG_CONFLICT", + "macOS denied automation permission for Claude Desktop.", + "Grant access in System Settings → Privacy & Security → Automation, then re-run `opper launch claude-desktop`.", + ); + } return; + } case "win32": run("powershell.exe", [ "-NoProfile", @@ -387,7 +419,7 @@ function openClaude(): void { async function spawn(args: string[], routing: OpperRouting): Promise { if (args.length > 0) { throw new OpperError( - "AGENT_NOT_FOUND", + "AGENT_CONFIG_CONFLICT", "claude-desktop does not accept passthrough arguments.", ); } diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index 858a376..0645a46 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -85,6 +85,22 @@ describe("claude-desktop adapter — detect", () => { else process.env.LOCALAPPDATA = prev; } }); + + it("windows: returns installed=true when only a Squirrel app-* sub-dir exists", async () => { + const prev = process.env.LOCALAPPDATA; + try { + platformMock.mockReturnValue("win32"); + const local = join(home, "AppData", "Local"); + process.env.LOCALAPPDATA = local; + mkdirSync(join(local, "AnthropicClaude", "app-1.2.3"), { recursive: true }); + writeFileSync(join(local, "AnthropicClaude", "app-1.2.3", "Claude.exe"), ""); + const result = await claudeDesktop.detect(); + expect(result.installed).toBe(true); + } finally { + if (prev === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = prev; + } + }); }); describe("claude-desktop adapter — paths (via isConfigured)", () => { @@ -343,6 +359,7 @@ describe("claude-desktop adapter — install / spawn arg guards", () => { compatShape: "openai" as const, }; await expect(claudeDesktop.spawn!(["foo"], ROUTING)).rejects.toMatchObject({ + code: "AGENT_CONFIG_CONFLICT", message: expect.stringContaining("does not accept"), }); }); @@ -415,6 +432,24 @@ describe("claude-desktop adapter — spawn (macOS)", () => { expect(pgrepCalls).toBeGreaterThanOrEqual(3); }); + it("surfaces a TCC denial hint when osascript reports 'Not authorized'", async () => { + let pgrepCalls = 0; + runMock.mockImplementation((cmd: string) => { + if (cmd === "pgrep") { + pgrepCalls += 1; + return ok("12345\n"); // running + } + if (cmd === "osascript") { + return { code: 1, stdout: "", stderr: "Not authorized to send Apple events to Claude." }; + } + return ok(); + }); + await expect(claudeDesktop.spawn!([], ROUTING)).rejects.toMatchObject({ + code: "AGENT_CONFIG_CONFLICT", + hint: expect.stringContaining("Privacy & Security"), + }); + }); + it("errors when Claude fails to quit within the timeout", async () => { // Spy on setTimeout to call back immediately AND advance Date.now() so the // deadline check exits after one iteration of the polling loop. From 6d5722a05423fc9e52b16563259dac0b6a4174ce Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 14:40:48 +0200 Subject: [PATCH 14/16] feat(claude-desktop): pin opus 4.7 as picker default via inferenceModels --- src/agents/claude-desktop.ts | 41 +++++++++++++------ test/agents/claude-desktop.test.ts | 65 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index bdfcbcf..47dd0e8 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -4,6 +4,7 @@ import { homedir, platform } from "node:os"; import { join, dirname } from "node:path"; import { OpperError } from "../errors.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; +import { DEFAULT_MODELS } from "../config/models.js"; import { run } from "../util/run.js"; import type { AgentAdapter, @@ -225,14 +226,26 @@ async function writeMetaWithOpperEntry(path: string): Promise { await writeJson(path, meta); } -async function writeGatewayProfile(path: string, apiKey: string): Promise { +async function writeGatewayProfile( + path: string, + apiKey: string, + primaryModel: string, +): Promise { const cfg = await readJsonAllowMissing(path); cfg.inferenceProvider = "gateway"; cfg.inferenceGatewayBaseUrl = OPPER_COMPAT_URL; cfg.inferenceGatewayApiKey = apiKey; cfg.inferenceGatewayAuthScheme = "bearer"; cfg.disableDeploymentModeChooser = true; - delete cfg.inferenceModels; + // First entry is the picker default. Dedupe in case primaryModel + // equals one of the catalog defaults. + const names = Array.from(new Set([ + primaryModel, + DEFAULT_MODELS.opus, + DEFAULT_MODELS.sonnet, + DEFAULT_MODELS.haiku, + ])); + cfg.inferenceModels = names.map((name) => ({ name })); await writeJson(path, cfg); } @@ -258,14 +271,7 @@ async function isConfigured(): Promise { return true; } -async function configure(opts: ConfigureOptions): Promise { - if (!opts.apiKey) { - throw new OpperError( - "AUTH_REQUIRED", - "Claude Desktop configuration needs an Opper API key.", - "Run `opper login` first, or set OPPER_API_KEY.", - ); - } +async function applyOpperProfile(apiKey: string, primaryModel: string): Promise { const targets = targetPaths(); for (const path of targets.normalConfigs) { await writeDeploymentMode(path, "3p"); @@ -273,8 +279,19 @@ async function configure(opts: ConfigureOptions): Promise { for (const target of targets.thirdPartyProfiles) { await writeDeploymentMode(target.desktopConfig, "3p"); await writeMetaWithOpperEntry(target.meta); - await writeGatewayProfile(target.profile, opts.apiKey); + await writeGatewayProfile(target.profile, apiKey, primaryModel); + } +} + +async function configure(opts: ConfigureOptions): Promise { + if (!opts.apiKey) { + throw new OpperError( + "AUTH_REQUIRED", + "Claude Desktop configuration needs an Opper API key.", + "Run `opper login` first, or set OPPER_API_KEY.", + ); } + await applyOpperProfile(opts.apiKey, DEFAULT_MODELS.opus); } async function clearOpperEntryFromMeta(path: string): Promise { @@ -423,7 +440,7 @@ async function spawn(args: string[], routing: OpperRouting): Promise { "claude-desktop does not accept passthrough arguments.", ); } - await configure({ apiKey: routing.apiKey }); + await applyOpperProfile(routing.apiKey, routing.model); if (isClaudeRunning()) { quitClaude(); diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index 0645a46..9daf8f9 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -249,6 +249,46 @@ describe("claude-desktop adapter — configure", () => { ); expect(opperEntries).toHaveLength(1); }); + + it("writes inferenceModels with DEFAULT_MODELS.opus first when configured via configure()", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const profile = readJSON( + join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ), + ); + expect(profile.inferenceModels).toBeInstanceOf(Array); + expect(profile.inferenceModels[0]).toMatchObject({ name: "anthropic/claude-opus-4-7" }); + // List should also include sonnet and haiku for picker convenience. + const names = (profile.inferenceModels as Array<{name: string}>).map(m => m.name); + expect(names).toContain("anthropic/claude-sonnet-4-6"); + expect(names).toContain("anthropic/claude-haiku-4-5"); + }); + + it("idempotent — running twice produces the same inferenceModels list with no duplicates", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const profile = readJSON( + join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ), + ); + expect(profile.inferenceModels).toBeInstanceOf(Array); + // No duplicate names. + const names = (profile.inferenceModels as Array<{name: string}>).map(m => m.name); + const unique = new Set(names); + expect(unique.size).toBe(names.length); + }); }); describe("claude-desktop adapter — unconfigure", () => { @@ -450,6 +490,31 @@ describe("claude-desktop adapter — spawn (macOS)", () => { }); }); + it("spawn writes routing.model as the picker default in inferenceModels", async () => { + runMock.mockImplementation((cmd: string) => { + if (cmd === "pgrep") return ok(""); // not running + return ok(); + }); + const customRouting = { + baseUrl: "https://api.opper.ai/v3/compat", + apiKey: "op_test_key", + model: "anthropic/claude-sonnet-4-6", + compatShape: "openai" as const, + }; + await claudeDesktop.spawn!([], customRouting); + + const profilePath = join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ); + const profile = JSON.parse(readFileSyncReal(profilePath, "utf8")); + expect(profile.inferenceModels[0]).toMatchObject({ name: "anthropic/claude-sonnet-4-6" }); + }); + it("errors when Claude fails to quit within the timeout", async () => { // Spy on setTimeout to call back immediately AND advance Date.now() so the // deadline check exits after one iteration of the polling loop. From 4985a96828ff779c22d2f3534eb4e5f94762025e Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 14:41:55 +0200 Subject: [PATCH 15/16] docs(readme): document claude-desktop, openclaw, and agents uninstall The README's agent table and launch examples were missing two adapters that already shipped (openclaw) and one that ships with this branch (claude-desktop). Also adds the new `opper agents uninstall ` non-interactive uninstall surface. --- README.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 766518f..f425c90 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,14 @@ Key resolution at request time: `OPPER_API_KEY` env var > the slot named by `--k `opper launch ` starts a supported AI agent with its model traffic transparently routed through Opper. Pass-through args after the agent name go straight to the agent's CLI. After the session, the CLI prints a summary with duration, model, and a traces link. ```bash -opper agents list # NAME / DISPLAY / KIND / STATE / CONFIG / COMMAND -opper launch claude # Anthropic Messages compat → /v3/compat -opper launch opencode # OpenAI Chat Completions compat → /v3/compat -opper launch codex # OpenAI Responses compat → /v3/compat -opper launch hermes # OpenAI Chat Completions compat → /v3/compat -opper launch pi # OpenAI Chat Completions compat → /v3/compat +opper agents list # NAME / DISPLAY / KIND / STATE / CONFIG / COMMAND +opper launch claude # Anthropic Messages compat → /v3/compat +opper launch claude-desktop # rewire Claude Desktop (GUI) → /v3/compat +opper launch opencode # OpenAI Chat Completions compat → /v3/compat +opper launch codex # OpenAI Responses compat → /v3/compat +opper launch hermes # OpenAI Chat Completions compat → /v3/compat +opper launch openclaw # OpenAI Chat Completions compat → /v3/compat (background gateway) +opper launch pi # OpenAI Chat Completions compat → /v3/compat # Anything after the agent name is forwarded to its CLI — handy for # scripting / cron with non-interactive flags. @@ -71,15 +73,25 @@ opper launch claude --resume | Agent | Slug | How Opper plugs in | |-------|------|--------------------| | Claude Code | `claude` | `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` env vars | +| Claude Desktop | `claude-desktop` | writes a third-party-inference (`deploymentMode: "3p"`) profile into `~/Library/Application Support/Claude-3p/` (macOS) / `%LOCALAPPDATA%\Claude-3p\` (Windows); quits and reopens the GUI app to apply | | OpenCode | `opencode` | provider block in `~/.config/opencode/opencode.json` | | Codex | `codex` | sentinel-managed `[model_providers.opper]` + `[profiles.opper-opus]` block in `~/.codex/config.toml` | | Hermes | `hermes` | isolated `HERMES_HOME=~/.opper/hermes-home/` so your real `~/.hermes/` is never touched; `OPENAI_API_KEY` env var | +| OpenClaw | `openclaw` | `opper` provider entry in `~/.openclaw/agents/main/agent/models.json`; `opper launch openclaw` defaults to `gateway start` (background daemon) | | Pi | `pi` | `opper` provider entry in `~/.pi/agent/models.json` (added/removed idempotently next to your other providers) | -`opper launch --install` runs the upstream agent's installer if it's missing (where supported). +`opper launch --install` runs the upstream agent's installer if it's missing (where supported). Claude Desktop is GUI-only on macOS/Windows and has no scripted installer — install it from first. The CLI also offers a per-agent submenu (`opper` → Agents → *agent* → Launch with model…) that lets you pick a specific Opper model from the catalog instead of the default. +To remove an agent's Opper integration without uninstalling the agent itself: + +```bash +opper agents uninstall claude-desktop # works for any registered adapter +``` + +This is the non-interactive equivalent of the menu's "Uninstall" action. It clears Opper-owned config (e.g., flips Claude Desktop's `deploymentMode` back to `"1p"`, removes the `opper` provider block from OpenCode / Pi / OpenClaw, etc.) without touching anything you put there yourself. + ## Ask — built-in support agent `opper ask ""` runs an Opper agent grounded on the locally-installed Opper skills (see below). Useful for "how do I…" questions about the platform, SDKs, or the CLI itself. From 2d4a36467c5c9daac164c1bf4f24011a3a29e3c5 Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Tue, 5 May 2026 14:59:23 +0200 Subject: [PATCH 16/16] fix(claude-desktop): tighten file perms and surface launch failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues raised by the codex PR review: P1 — `writeJson` wrote Claude Desktop's profile files with default mode (world-readable post-umask). One of those files stores `inferenceGatewayApiKey`, which on multi-user machines could leak the gateway token to other local users. Set `mode: 0o600` on every write — matches the precedent in `openclaw.ts`. P2 — `openClaude()` ignored `run()`'s exit code, so `spawn()` returned 0 even if `open -a Claude` / `Start-Process` failed. Now we check the result and throw `OpperError` with the subprocess stderr so the user gets an actionable message. --- src/agents/claude-desktop.ts | 27 +++++++++++++++++++++++---- test/agents/claude-desktop.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index 47dd0e8..a782d32 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -170,7 +170,9 @@ async function readJsonAllowMissing(path: string): Promise { async function writeJson(path: string, data: JsonObject): Promise { await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf8"); + // 0o600 because the gateway profile JSON stores inferenceGatewayApiKey; + // applied uniformly so we don't have to reason about which file is which. + await writeFile(path, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); } async function readJsonOrNull(path: string): Promise { @@ -411,9 +413,18 @@ async function waitForClaudeExit(): Promise { function openClaude(): void { switch (platform()) { - case "darwin": - run("open", ["-a", "Claude"]); + case "darwin": { + const result = run("open", ["-a", "Claude"]); + if (result.code !== 0) { + throw new OpperError( + "AGENT_NOT_FOUND", + "Failed to open Claude Desktop.", + (result.stderr.trim() || "`open -a Claude` exited non-zero.") + + " Open Claude Desktop manually and re-run if the problem persists.", + ); + } return; + } case "win32": { const exe = appCandidates().find((p) => existsSync(p)); if (!exe) { @@ -423,11 +434,19 @@ function openClaude(): void { "Open Claude Desktop manually once and re-run.", ); } - run("powershell.exe", [ + const result = run("powershell.exe", [ "-NoProfile", "-Command", `Start-Process -FilePath '${exe.replace(/'/g, "''")}'`, ]); + if (result.code !== 0) { + throw new OpperError( + "AGENT_NOT_FOUND", + "Failed to start Claude Desktop.", + (result.stderr.trim() || "Start-Process exited non-zero.") + + " Open Claude Desktop manually and re-run if the problem persists.", + ); + } return; } } diff --git a/test/agents/claude-desktop.test.ts b/test/agents/claude-desktop.test.ts index 9daf8f9..33415ac 100644 --- a/test/agents/claude-desktop.test.ts +++ b/test/agents/claude-desktop.test.ts @@ -250,6 +250,21 @@ describe("claude-desktop adapter — configure", () => { expect(opperEntries).toHaveLength(1); }); + it("writes the gateway profile JSON with owner-only permissions (0o600)", async () => { + await claudeDesktop.configure({ apiKey: "op_test_key" }); + const profilePath = join( + home, + "Library", + "Application Support", + "Claude-3p", + "configLibrary", + "727f05c8-a429-43cc-b1c6-36d8883d98b8.json", + ); + const { statSync } = await import("node:fs"); + const mode = statSync(profilePath).mode & 0o777; + expect(mode).toBe(0o600); + }); + it("writes inferenceModels with DEFAULT_MODELS.opus first when configured via configure()", async () => { await claudeDesktop.configure({ apiKey: "op_test_key" }); const profile = readJSON( @@ -472,6 +487,18 @@ describe("claude-desktop adapter — spawn (macOS)", () => { expect(pgrepCalls).toBeGreaterThanOrEqual(3); }); + it("throws when `open -a Claude` exits non-zero", async () => { + runMock.mockImplementation((cmd: string) => { + if (cmd === "pgrep") return ok(""); // not running + if (cmd === "open") return { code: 1, stdout: "", stderr: "kLSApplicationNotFoundErr" }; + return ok(); + }); + await expect(claudeDesktop.spawn!([], ROUTING)).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + message: expect.stringContaining("Failed to open"), + }); + }); + it("surfaces a TCC denial hint when osascript reports 'Not authorized'", async () => { let pgrepCalls = 0; runMock.mockImplementation((cmd: string) => {