Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -185,6 +185,8 @@ node --check <file> # 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).
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions plugins/devin/commands/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions plugins/devin/scripts/devin-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand All @@ -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
},
Expand Down
94 changes: 88 additions & 6 deletions plugins/devin/scripts/lib/devin.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand All @@ -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" };
Expand Down Expand Up @@ -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 [];
}
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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: "" });
}
8 changes: 7 additions & 1 deletion plugins/devin/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
Loading
Loading