diff --git a/AGENTS.md b/AGENTS.md index 75f29e9..67723d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,4 +76,8 @@ npm test ``` Uses a fake `devin` binary fixture (`DEVIN_COMPANION_DEVIN_BINARY` env var -points the companion at any devin-compatible binary). +points the companion at any devin-compatible binary). Binary lookup order: +`DEVIN_COMPANION_DEVIN_BINARY`, then `devin` on `PATH`, then on macOS the +Devin Desktop app-bundle CLI. `setup --json` reports `devin.path` and +`devin.source`. `DEVIN_COMPANION_DESKTOP_BINARY_CANDIDATE` overrides the +app-bundle path for tests only. diff --git a/README.md b/README.md index 2a44c1d..376eba0 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Modeled after [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin- ## Requirements -- [Devin CLI](https://docs.devin.ai/cli) installed and authenticated (`devin auth status`), or `DEVIN_API_KEY` set for cloud handoffs. +- [Devin CLI](https://docs.devin.ai/cli) installed and authenticated (`devin auth status`), or `DEVIN_API_KEY` set for cloud handoffs. On macOS, the CLI bundled with Devin Desktop (`/Applications/Devin.app`) is detected automatically when `devin` is not on your `PATH`. - Node.js >= 18.18 (for the companion runtime). - Git (review commands run inside a repository). @@ -185,6 +185,8 @@ node --check # syntax-check any module `DEVIN_COMPANION_DEVIN_BINARY` overrides the devin binary path (used by tests; handy for smoke tests against a stub). +The devin binary is resolved once per process in this order: `DEVIN_COMPANION_DEVIN_BINARY`, then `devin` on `PATH`, then (macOS only) the Devin Desktop app bundle at `/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin`. `/devin:setup` reports which one was chosen. `DEVIN_COMPANION_DESKTOP_BINARY_CANDIDATE` replaces the app-bundle path and exists for tests only. + ## Credits Derived from [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc) by OpenAI — original concept, architecture, and much of the companion runtime are theirs, adapted for the Devin CLI and Devin API. See [NOTICE](NOTICE). diff --git a/llms.txt b/llms.txt index c45a708..2f7ecd3 100644 --- a/llms.txt +++ b/llms.txt @@ -35,4 +35,4 @@ Claude Code uses `/plugin marketplace add .` and `/plugin install devin@devin-pl ## Compatibility -Node.js 18.18 or newer. Git provides review context and handoff diffs (both degrade gracefully without it). Local commands require an authenticated Devin CLI; cloud handoffs require `DEVIN_API_KEY` instead of the CLI. +Node.js 18.18 or newer. Git provides review context and handoff diffs (both degrade gracefully without it). Local commands require an authenticated Devin CLI (on macOS, the CLI bundled with Devin Desktop is detected automatically when `devin` is not on PATH); cloud handoffs require `DEVIN_API_KEY` instead of the CLI. diff --git a/plugins/devin/commands/setup.md b/plugins/devin/commands/setup.md index 6a9a993..d0f7b5f 100644 --- a/plugins/devin/commands/setup.md +++ b/plugins/devin/commands/setup.md @@ -13,6 +13,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/devin-companion.mjs" setup --json $ARGUMENTS If the result says Devin is unavailable: - Do not offer to install it yourself. - Point the user at the install instructions: `brew install cognition/tap/devin` or https://docs.devin.ai/cli +- On macOS, the CLI bundled with Devin Desktop is detected automatically, so installing Devin Desktop also works. - After they install, they can re-run `/devin:setup`. If Devin is installed but not authenticated: diff --git a/plugins/devin/scripts/devin-companion.mjs b/plugins/devin/scripts/devin-companion.mjs index 17a21e6..9d71914 100755 --- a/plugins/devin/scripts/devin-companion.mjs +++ b/plugins/devin/scripts/devin-companion.mjs @@ -18,12 +18,14 @@ import { import { buildDevinPrintArgs, DEFAULT_MODEL, - DEVIN_BINARY, + BINARY_SOURCE_LABELS, devinFailureMessage, findLatestDevinSession, findTaskResumeCandidate, getDevinAuthStatus, + getDevinBinary, getDevinAvailability, + getResolvedDevinBinary, normalizePermissionMode, runDevinPrint } from "./lib/devin.mjs"; @@ -145,7 +147,8 @@ async function cmdSetup(cwd, argv) { setConfig(cwd, "stopReviewGate", false); } - const binaryProbe = binaryAvailable(DEVIN_BINARY, ["version"], { cwd }); + const resolvedBinary = getResolvedDevinBinary(); + const binaryProbe = binaryAvailable(getDevinBinary(), ["version"], { cwd }); const auth = binaryProbe.available ? getDevinAuthStatus(cwd) : { authenticated: false, detail: "devin not installed" }; const cloud = getCloudConfig(); const gitProbe = binaryAvailable("git", ["--version"], { cwd }); @@ -156,7 +159,10 @@ async function cmdSetup(cwd, argv) { ready: binaryProbe.available && auth.authenticated, devin: { installed: binaryProbe.available, + path: resolvedBinary?.path ?? null, + source: resolvedBinary ? BINARY_SOURCE_LABELS[resolvedBinary.source] : null, version: binaryProbe.available ? binaryProbe.detail : null, + probeDetail: binaryProbe.available ? null : binaryProbe.detail, authenticated: auth.authenticated, authDetail: auth.detail ?? null }, diff --git a/plugins/devin/scripts/lib/devin.mjs b/plugins/devin/scripts/lib/devin.mjs index d913b06..7f8a368 100644 --- a/plugins/devin/scripts/lib/devin.mjs +++ b/plugins/devin/scripts/lib/devin.mjs @@ -1,10 +1,21 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; +import path from "node:path"; import process from "node:process"; import { binaryAvailable, formatCommandFailure, runCommand } from "./process.mjs"; -export const DEVIN_BINARY = process.env.DEVIN_COMPANION_DEVIN_BINARY ?? "devin"; +export const BINARY_ENV = "DEVIN_COMPANION_DEVIN_BINARY"; +// Devin Desktop ships the CLI inside its app bundle without putting it on PATH. +export const DESKTOP_BINARY_CANDIDATE = + "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin"; +// Test-only: replaces the app-bundle path (and skips the darwin check) so tests can simulate Devin Desktop. +export const DESKTOP_CANDIDATE_ENV = "DEVIN_COMPANION_DESKTOP_BINARY_CANDIDATE"; +export const BINARY_SOURCE_LABELS = { + env: BINARY_ENV, + path: "PATH", + desktop: "Devin Desktop app bundle" +}; export const SESSION_ID_ENV = "DEVIN_COMPANION_SESSION_ID"; export const DEFAULT_MODEL = "swe-2-max"; @@ -18,8 +29,79 @@ const VALID_PERMISSION_MODES = new Set([ "autonomous" ]); +function defaultIsExecutable(file) { + try { + if (!fs.statSync(file).isFile()) { + return false; + } + fs.accessSync(file, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +// Order: explicit env override, `devin` on PATH, then the Devin Desktop app bundle (macOS only). +// Returns { path, source } or null when no devin CLI can be found. +export function resolveDevinBinary({ + env = process.env, + platform = process.platform, + candidates = [DESKTOP_BINARY_CANDIDATE], + isExecutable = defaultIsExecutable, + realpath = fs.realpathSync +} = {}) { + if (env[BINARY_ENV]) { + // Use exactly what the user chose; a bad value surfaces as a failed probe, never a silent fallback. + return { path: env[BINARY_ENV], source: "env" }; + } + + if (platform === "win32") { + // Windows commands run through a shell that does its own PATH and PATHEXT lookup. + return { path: "devin", source: "path" }; + } + + for (const dir of (env.PATH ?? "").split(path.delimiter)) { + if (!dir) { + continue; + } + const found = path.join(dir, "devin"); + if (isExecutable(found)) { + return { path: found, source: "path" }; + } + } + + const testCandidate = env[DESKTOP_CANDIDATE_ENV]; + const fallbacks = testCandidate ? [testCandidate] : platform === "darwin" ? candidates : []; + for (const candidate of fallbacks) { + try { + const resolved = realpath(candidate); + if (isExecutable(resolved)) { + return { path: resolved, source: "desktop" }; + } + } catch { + // Missing or unreadable candidate: try the next one. + } + } + return null; +} + +let resolvedBinary; + +// Resolved once per process so setup, auth, session listing, and print runs all use the same binary. +export function getResolvedDevinBinary() { + if (resolvedBinary === undefined) { + resolvedBinary = resolveDevinBinary(); + } + return resolvedBinary; +} + +// The command to spawn. Falls back to a bare "devin" so a missing CLI fails with the usual ENOENT. +export function getDevinBinary() { + return getResolvedDevinBinary()?.path ?? "devin"; +} + export function getDevinAvailability(cwd) { - const probe = binaryAvailable(DEVIN_BINARY, ["version"], { cwd }); + const probe = binaryAvailable(getDevinBinary(), ["version"], { cwd }); if (!probe.available) { return { available: false, @@ -39,7 +121,7 @@ export function getDevinAvailability(cwd) { } export function getDevinAuthStatus(cwd) { - const result = runCommand(DEVIN_BINARY, ["auth", "status"], { cwd }); + const result = runCommand(getDevinBinary(), ["auth", "status"], { cwd }); if (result.error) { if (result.error.code === "ENOENT") { return { authenticated: false, detail: "devin binary not found" }; @@ -113,7 +195,7 @@ export function buildDevinPrintArgs({ } export function listDevinSessions(cwd) { - const result = runCommand(DEVIN_BINARY, ["list", "--format", "json"], { cwd }); + const result = runCommand(getDevinBinary(), ["list", "--format", "json"], { cwd }); if (result.error || result.status !== 0) { return []; } @@ -168,7 +250,7 @@ export function findTaskResumeCandidate(cwd) { export function runDevinPrint({ cwd, args, onStderr = null, onSpawn = null } = {}) { return new Promise((resolve, reject) => { - const child = spawn(DEVIN_BINARY, args, { + const child = spawn(getDevinBinary(), args, { cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], @@ -217,5 +299,5 @@ export function devinFailureMessage(result) { if (stdout) { return `devin exited ${result.exitStatus} with no error output`; } - return formatCommandFailure({ command: DEVIN_BINARY, args: [], status: result.exitStatus, signal: result.signal, stderr: "", stdout: "" }); + return formatCommandFailure({ command: getDevinBinary(), args: [], status: result.exitStatus, signal: result.signal, stderr: "", stdout: "" }); } diff --git a/plugins/devin/scripts/lib/render.mjs b/plugins/devin/scripts/lib/render.mjs index 9163a59..2f569bc 100644 --- a/plugins/devin/scripts/lib/render.mjs +++ b/plugins/devin/scripts/lib/render.mjs @@ -29,13 +29,19 @@ function truncate(text, max = 120) { export function renderSetupReport(report) { const lines = ["Devin companion setup", ""]; lines.push(`- devin CLI: ${report.devin.installed ? `installed (${report.devin.version ?? "unknown version"})` : "NOT FOUND"}`); + if (report.devin.path) { + lines.push(`- devin path: ${report.devin.path} (from ${report.devin.source})`); + } lines.push(`- auth: ${report.devin.authenticated ? `ok — ${report.devin.authDetail}` : `not authenticated (${report.devin.authDetail ?? "unknown"})`}`); lines.push(`- cloud API (DEVIN_API_KEY): ${report.cloud.available ? `set (${report.cloud.keyKind} key)` : "not set — /devin:handoff unavailable"}`); lines.push(`- git: ${report.git.available ? "ok" : `missing (${report.git.detail})`}`); lines.push(`- review gate: ${report.config.stopReviewGate ? "enabled" : "disabled"}`); lines.push(""); - if (!report.devin.installed) { + if (!report.devin.installed && report.devin.path) { + lines.push(`The selected devin binary could not be run (${report.devin.probeDetail ?? "unknown error"}). Check ${report.devin.source} or fix the install.`); + } else if (!report.devin.installed) { lines.push("Devin CLI is missing. Install it: https://docs.devin.ai/cli or `npm i -g devin-cli` if applicable."); + lines.push("On macOS, the CLI bundled with Devin Desktop is detected automatically when /Applications/Devin.app is installed."); } else if (!report.devin.authenticated) { lines.push("Devin is installed but not authenticated. Run `!devin auth login` (or `/login` inside devin) then re-run /devin:setup."); } diff --git a/tests/binary.test.mjs b/tests/binary.test.mjs new file mode 100644 index 0000000..876fc0c --- /dev/null +++ b/tests/binary.test.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createFakeDevin, createTempRepo } from "./fake-devin-fixture.mjs"; +import { + DESKTOP_BINARY_CANDIDATE, + DESKTOP_CANDIDATE_ENV, + resolveDevinBinary +} from "../plugins/devin/scripts/lib/devin.mjs"; +import { renderSetupReport } from "../plugins/devin/scripts/lib/render.mjs"; + +const COMPANION = fileURLToPath(new URL("../plugins/devin/scripts/devin-companion.mjs", import.meta.url)); + +function makeDir(prefix) { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); +} + +// A PATH directory holding only the tools the fake devin script and the companion need, +// so a real devin installed on the host machine can never be found. +function makeToolOnlyPath() { + const dir = makeDir("devin-tool-path-"); + for (const tool of ["bash", "cat", "cp", "git"]) { + for (const hostDir of (process.env.PATH ?? "").split(path.delimiter)) { + const candidate = path.join(hostDir, tool); + if (hostDir && fs.existsSync(candidate)) { + fs.symlinkSync(candidate, path.join(dir, tool)); + break; + } + } + } + return dir; +} + +function makeExecutable(file) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + return file; +} + +test("explicit DEVIN_COMPANION_DEVIN_BINARY wins over PATH and the app bundle", () => { + const root = makeDir("devin-resolve-"); + try { + const onPath = makeExecutable(path.join(root, "bin", "devin")); + const bundle = makeExecutable(path.join(root, "bundle", "devin")); + const resolved = resolveDevinBinary({ + env: { DEVIN_COMPANION_DEVIN_BINARY: "/custom/devin", PATH: path.dirname(onPath) }, + platform: "darwin", + candidates: [bundle] + }); + assert.deepEqual(resolved, { path: "/custom/devin", source: "env" }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("devin on PATH beats the app bundle", () => { + const root = makeDir("devin-resolve-"); + try { + const onPath = makeExecutable(path.join(root, "bin", "devin")); + const bundle = makeExecutable(path.join(root, "bundle", "devin")); + const resolved = resolveDevinBinary({ + env: { PATH: [path.join(root, "empty"), path.dirname(onPath)].join(path.delimiter) }, + platform: "darwin", + candidates: [bundle] + }); + assert.deepEqual(resolved, { path: onPath, source: "path" }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("Desktop-only install selects the app bundle and uses its realpath", () => { + const root = makeDir("devin-resolve-"); + try { + const real = makeExecutable(path.join(root, "real", "devin")); + const link = path.join(root, "Devin.app", "devin"); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(real, link); + const resolved = resolveDevinBinary({ + env: { PATH: path.join(root, "empty") }, + platform: "darwin", + candidates: [link] + }); + assert.deepEqual(resolved, { path: real, source: "desktop" }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("app bundle candidate is ignored off macOS", () => { + const root = makeDir("devin-resolve-"); + try { + const bundle = makeExecutable(path.join(root, "bundle", "devin")); + for (const platform of ["linux", "freebsd"]) { + assert.equal(resolveDevinBinary({ env: { PATH: "" }, platform, candidates: [bundle] }), null); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("non-executable files and directories are skipped", () => { + const root = makeDir("devin-resolve-"); + try { + const plainFile = path.join(root, "plain", "devin"); + fs.mkdirSync(path.dirname(plainFile), { recursive: true }); + fs.writeFileSync(plainFile, "not executable", { mode: 0o644 }); + const dirNamedDevin = path.join(root, "dirbin", "devin"); + fs.mkdirSync(dirNamedDevin, { recursive: true }); + const bundleDir = path.join(root, "bundle-dir"); + fs.mkdirSync(bundleDir); + const good = makeExecutable(path.join(root, "good", "devin")); + + const env = { PATH: [path.dirname(plainFile), path.dirname(dirNamedDevin)].join(path.delimiter) }; + assert.equal(resolveDevinBinary({ env, platform: "darwin", candidates: [plainFile, bundleDir] }), null); + assert.deepEqual( + resolveDevinBinary({ env, platform: "darwin", candidates: [plainFile, bundleDir, good] }), + { path: good, source: "desktop" } + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("nothing found returns null", () => { + assert.equal(resolveDevinBinary({ env: { PATH: "" }, platform: "darwin", candidates: ["/nonexistent/devin"] }), null); +}); + +test("the default app-bundle candidate is the documented macOS path", () => { + assert.equal( + DESKTOP_BINARY_CANDIDATE, + "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin" + ); +}); + +test("setup guidance for a missing CLI mentions Devin Desktop", () => { + const text = renderSetupReport({ + ready: false, + devin: { installed: false, path: null, source: null, authenticated: false, authDetail: "devin not installed" }, + cloud: { available: false }, + git: { available: true }, + config: { stopReviewGate: false } + }); + assert.match(text, /NOT FOUND/); + assert.match(text, /Devin Desktop/); +}); + +function runSetup(env, cwd) { + const result = spawnSync(process.execPath, [COMPANION, "setup", "--json"], { + cwd, + encoding: "utf8", + env: { ...process.env, DEVIN_COMPANION_DEVIN_BINARY: "", ...env }, + timeout: 30000 + }); + return { result, report: result.stdout ? JSON.parse(result.stdout) : null }; +} + +test("setup reports not installed when no devin is reachable", () => { + const repo = createTempRepo(); + const emptyPath = makeDir("devin-empty-path-"); + try { + const { result, report } = runSetup({ PATH: emptyPath, [DESKTOP_CANDIDATE_ENV]: path.join(emptyPath, "missing") }, repo.dir); + assert.equal(result.status, 0, result.stderr); + assert.equal(report.devin.installed, false); + assert.equal(report.devin.path, null); + assert.equal(report.ready, false); + } finally { + fs.rmSync(emptyPath, { recursive: true, force: true }); + repo.cleanup(); + } +}); + +test("Desktop-only machine: setup and task use the app-bundle binary", () => { + const repo = createTempRepo(); + const fake = createFakeDevin({ responseText: "FROM_BUNDLE" }); + const emptyPath = makeToolOnlyPath(); + const dataDir = makeDir("devin-plugin-data-"); + try { + const env = { PATH: emptyPath, [DESKTOP_CANDIDATE_ENV]: fake.binPath, CLAUDE_PLUGIN_DATA: dataDir }; + const { result, report } = runSetup(env, repo.dir); + assert.equal(result.status, 0, result.stderr); + assert.equal(report.devin.installed, true); + assert.equal(report.devin.path, fs.realpathSync(fake.binPath)); + assert.equal(report.devin.source, "Devin Desktop app bundle"); + assert.equal(report.ready, true); + + // Same binary for print runs: the fake logs every invocation's args. + const task = spawnSync(process.execPath, [COMPANION, "task", "--", "hello"], { + cwd: repo.dir, + encoding: "utf8", + env: { ...process.env, DEVIN_COMPANION_DEVIN_BINARY: "", ...env }, + timeout: 30000 + }); + const argsLog = fs.readFileSync(`${fake.marker}.args`, "utf8"); + assert.match(argsLog, /^version$/m); + assert.match(argsLog, /^auth status$/m); + assert.equal(task.status, 0, task.stderr); + assert.match(task.stdout, /FROM_BUNDLE/); + assert.match(argsLog, /^-p /m); + } finally { + fs.rmSync(emptyPath, { recursive: true, force: true }); + fs.rmSync(dataDir, { recursive: true, force: true }); + fake.cleanup(); + repo.cleanup(); + } +}); + +test("PATH devin wins over the app bundle in the companion", () => { + const repo = createTempRepo(); + const onPath = createFakeDevin(); + const bundle = createFakeDevin(); + try { + const { report } = runSetup({ PATH: onPath.dir, [DESKTOP_CANDIDATE_ENV]: bundle.binPath }, repo.dir); + assert.equal(report.devin.path, path.join(onPath.dir, "devin")); + assert.equal(report.devin.source, "PATH"); + } finally { + onPath.cleanup(); + bundle.cleanup(); + repo.cleanup(); + } +}); + +test("explicit env binary that cannot run is reported, not replaced by a fallback", () => { + const repo = createTempRepo(); + const bundle = createFakeDevin(); + try { + const missing = path.join(repo.dir, "no-such-devin"); + const { report } = runSetup( + { DEVIN_COMPANION_DEVIN_BINARY: missing, PATH: bundle.dir, [DESKTOP_CANDIDATE_ENV]: bundle.binPath }, + repo.dir + ); + assert.equal(report.devin.installed, false); + assert.equal(report.devin.path, missing); + assert.equal(report.devin.source, "DEVIN_COMPANION_DEVIN_BINARY"); + } finally { + bundle.cleanup(); + repo.cleanup(); + } +});