From 72ec06a81dc92857dfa2b714fee65f5469566846 Mon Sep 17 00:00:00 2001 From: decolua Date: Sun, 26 Jul 2026 10:03:22 +0700 Subject: [PATCH] feat(cli-tools): add Devin CLI provider with ACP stdio executor Wire Devin CLI as a routed provider that spawns the local `devin acp` binary. Add the DevinCliExecutor, register it in the executor map, expose its status through the cli-tools batch endpoint and devin-settings route, and document setup in cliTools constants. Co-Authored-By: Claude Fable 5 --- open-sse/executors/devin-cli.js | 442 ++++++++++++++++++ open-sse/executors/index.js | 3 + open-sse/providers/registry/devin-cli.js | 62 +++ public/providers/devin-cli.png | Bin 0 -> 2525 bytes src/app/api/cli-tools/all-statuses/route.js | 2 + src/app/api/cli-tools/devin-settings/route.js | 78 ++++ src/shared/constants/cliTools.js | 26 ++ 7 files changed, 613 insertions(+) create mode 100644 open-sse/executors/devin-cli.js create mode 100644 open-sse/providers/registry/devin-cli.js create mode 100644 public/providers/devin-cli.png create mode 100644 src/app/api/cli-tools/devin-settings/route.js diff --git a/open-sse/executors/devin-cli.js b/open-sse/executors/devin-cli.js new file mode 100644 index 00000000..e470b5ac --- /dev/null +++ b/open-sse/executors/devin-cli.js @@ -0,0 +1,442 @@ +/** + * DevinCliExecutor — routes completions through the official Devin CLI binary + * via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio. + * + * Protocol flow: + * 1. Spawn `devin acp --agent-type summarizer` (summarizer = no FS tools, + * pure text replies, safe for proxy use). + * 2. Send: initialize → session/new (with model + cwd) → session/prompt. + * 3. Receive: session/update notifications (streaming text deltas). + * 4. Emit deltas as OpenAI-compatible SSE chunks. + * 5. Kill subprocess on [DONE] or error. + * + * Auth: credentials.apiKey / accessToken → WINDSURF_API_KEY env var passed to + * devin. If unset, devin falls back to credentials stored by `devin auth login`. + * + * Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths. + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { BaseExecutor } from "./base.js"; + +// ─── Binary discovery ──────────────────────────────────────────────────────── + +function resolveDevinBin() { + // 1. Explicit override + const envBin = process.env.CLI_DEVIN_BIN?.trim(); + if (envBin) return envBin; + + // 2. Common name (PATH lookup handled by spawn shell option) + const isWin = process.platform === "win32"; + + // 3. Windows installer default: %LOCALAPPDATA%\devin\cli\bin\devin.exe + if (isWin) { + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + const winPath = path.join(localAppData, "devin", "cli", "bin", "devin.exe"); + if (fs.existsSync(winPath)) return winPath; + } + + // 4. Linux/macOS installer paths + const home = os.homedir(); + for (const candidate of [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + ]) { + if (fs.existsSync(candidate)) return candidate; + } + + // Fallback — rely on PATH + return isWin ? "devin.exe" : "devin"; +} + +// ─── ACP JSON-RPC helper ──────────────────────────────────────────────────── + +function rpc(method, params, id) { + const msg = { jsonrpc: "2.0", method, params }; + if (id !== undefined) msg.id = id; + return JSON.stringify(msg) + "\n"; +} + +// ─── Multi-turn message → single prompt builder ───────────────────────────── + +function buildPromptText(messages) { + // Devin CLI (summarizer mode) receives a single text prompt. + // Inline the whole conversation so the model has full context. + const lines = []; + for (const m of messages) { + const role = String(m.role || "user"); + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + for (const p of m.content) { + if (p && typeof p === "object" && p.type === "text") { + text += String(p.text || ""); + } + } + } + if (!text.trim()) continue; + if (role === "system") { + lines.push(`[System]\n${text}`); + } else if (role === "assistant") { + lines.push(`[Assistant]\n${text}`); + } else { + lines.push(`[User]\n${text}`); + } + } + return lines.join("\n\n") || "(empty)"; +} + +// ─── DevinCliExecutor ───────────────────────────────────────────────────────── + +export class DevinCliExecutor extends BaseExecutor { + constructor() { + super("devin-cli", { id: "devin-cli", baseUrl: "devin://acp/stdio" }); + } + + buildUrl() { + return "devin://acp/stdio"; + } + + buildHeaders() { + return {}; + } + + transformRequest() { + return null; + } + + async execute({ model, body, credentials, signal, log }) { + const b = body ?? {}; + const messages = Array.isArray(b.messages) ? b.messages : []; + const promptText = buildPromptText(messages); + const apiKey = + credentials.apiKey || credentials.accessToken || process.env.WINDSURF_API_KEY || ""; + const devinBin = resolveDevinBin(); + + log?.info?.("DEVIN", `devin acp → model=${model}, bin=${devinBin}`); + + const sseStream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const emit = (data) => controller.enqueue(enc.encode(data)); + + const env = { ...process.env }; + if (apiKey) env.WINDSURF_API_KEY = apiKey; + + const child = spawn(devinBin, ["acp", "--agent-type", "summarizer"], { + env, + stdio: ["pipe", "pipe", "pipe"], + // On Windows, devin.exe may need shell resolution + shell: process.platform === "win32", + }); + + let spawnError = null; + let stdinClosed = false; + + child.on("error", (err) => { + spawnError = err; + const msg = + err.message.includes("ENOENT") || err.message.includes("not found") + ? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.` + : `Devin CLI spawn error: ${err.message}`; + emit( + `data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n` + ); + emit("data: [DONE]\n\n"); + controller.close(); + }); + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + }); + } + + // ── JSON-RPC state machine ────────────────────────────────────────── + let idCounter = 1; + let sessionId = null; + let initDone = false; + let sessionCreated = false; + let promptSent = false; + const responseId = `chatcmpl-devin-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let roleEmitted = false; + let totalText = ""; + let finished = false; + + const sendRpc = (method, params) => { + if (stdinClosed || child.stdin.destroyed) return; + const id = idCounter++; + try { + child.stdin.write(rpc(method, params, id)); + } catch { + /* ignore write errors after close */ + } + return id; + }; + + const finish = (error) => { + if (finished) return; + finished = true; + + if (error) { + emit( + `data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n` + ); + } else { + // Emit finish chunk + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: Math.ceil(promptText.length / 4), + completion_tokens: Math.ceil(totalText.length / 4), + total_tokens: Math.ceil((promptText.length + totalText.length) / 4), + estimated: true, + }, + })}\n\n` + ); + } + emit("data: [DONE]\n\n"); + + // Gracefully close stdin → devin will exit + try { + if (!stdinClosed) { + stdinClosed = true; + child.stdin.end(); + } + } catch { + /* ignore */ + } + + // Give it 2s to exit cleanly, then SIGKILL + const killTimer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, 2000); + killTimer.unref?.(); + + controller.close(); + }; + + // ── stdout reader (NDJSON) ────────────────────────────────────────── + let buffer = ""; + + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let nl; + // Each ACP message is a newline-terminated JSON line + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; // ignore non-JSON lines (banner text, etc.) + } + + // ── Initialize response ─────────────────────────────────────── + if (!initDone && msg.result !== undefined && !msg.method) { + initDone = true; + // Create session: send session/new with model and a temp cwd + sendRpc("session/new", { + cwd: process.cwd(), + model: model || undefined, + }); + continue; + } + + // ── session/new response → get sessionId ────────────────────── + if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) { + const res = msg.result || {}; + sessionId = res.sessionId || null; + if (!sessionId) { + finish("Devin ACP: session/new returned no sessionId"); + return; + } + sessionCreated = true; + // Send the prompt + promptSent = true; + sendRpc("session/prompt", { + sessionId, + content: [{ type: "text", text: promptText }], + }); + continue; + } + + // ── session/prompt response (ack) ───────────────────────────── + if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) { + // Acknowledged — streaming notifications will follow + continue; + } + + // ── Streaming notifications (session/update) ────────────────── + if (msg.method === "session/update" || msg.method === "$/update") { + const params = msg.params; + if (!params) continue; + + const type = params.type; + + if (type === "message_delta" || type === "text_delta" || type === "content_delta") { + const delta = + params.content || params.delta || params.text || ""; + if (delta) { + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { role: "assistant", content: "" }, + finish_reason: null, + }, + ], + })}\n\n` + ); + roleEmitted = true; + } + totalText += delta; + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + })}\n\n` + ); + } + } else if (type === "message_stop" || type === "stop" || type === "done") { + finish(); + return; + } else if (type === "error") { + finish(String(params.message || params.error || "Devin ACP error")); + return; + } + continue; + } + + // ── session/prompt final result (non-streaming path) ────────── + if (promptSent && msg.result !== undefined && !msg.method && !finished) { + const res = msg.result || undefined; + // Extract text from result if we haven't streamed anything yet + if (!roleEmitted && res) { + const content = extractResultText(res); + if (content) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { role: "assistant", content: "" }, + finish_reason: null, + }, + ], + })}\n\n` + ); + totalText = content; + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content }, finish_reason: null }], + })}\n\n` + ); + } + } + const stopReason = (res && res.stopReason) || ""; + if (stopReason && stopReason !== "cancelled") { + finish(); + } + } + + // ── Error responses ─────────────────────────────────────────── + if (msg.error) { + finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`); + return; + } + } + }); + + child.stderr.on("data", (chunk) => { + log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("close", (code) => { + if (!finished) { + if (code !== 0 && !spawnError) { + finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`); + } else { + finish(); + } + } + }); + + // ── Send initialize ─────────────────────────────────────────────── + sendRpc("initialize", { + protocolVersion: "0.3", + clientInfo: { name: "9router", version: "1.0" }, + capabilities: {}, + }); + }, + }); + + return { + response: new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: "devin://acp/stdio", + headers: {}, + transformedBody: { model, promptLength: body?.messages }, + }; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Extract text from a final ACP session/prompt result object across common shapes. +function extractResultText(result) { + // { message: { content: "..." } } + // { messages: [{ content: "..." }] } + // { content: "..." } + // { text: "..." } + if (typeof result.content === "string") return result.content; + if (typeof result.text === "string") return result.text; + const msg = result.message; + if (msg && typeof msg.content === "string") return msg.content; + const msgs = result.messages; + if (Array.isArray(msgs)) { + return msgs + .filter((m) => m.role === "assistant") + .map((m) => String(m.content || "")) + .join("\n"); + } + return ""; +} + +export default DevinCliExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 7191facd..92e10464 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -25,6 +25,7 @@ import TraeExecutor from "./trae.js"; import ZedExecutor from "./zed.js"; import WindsurfExecutor from "./windsurf.js"; import { DefaultExecutor } from "./default.js"; +import { DevinCliExecutor } from "./devin-cli.js"; const executors = { antigravity: new AntigravityExecutor(), @@ -58,6 +59,7 @@ const executors = { trae: new TraeExecutor(), zed: new ZedExecutor(), windsurf: new WindsurfExecutor(), + "devin-cli": new DevinCliExecutor(), }; const defaultCache = new Map(); @@ -100,3 +102,4 @@ export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; export { default as TraeExecutor } from "./trae.js"; export { default as ZedExecutor } from "./zed.js"; export { default as WindsurfExecutor } from "./windsurf.js"; +export { DevinCliExecutor } from "./devin-cli.js"; diff --git a/open-sse/providers/registry/devin-cli.js b/open-sse/providers/registry/devin-cli.js new file mode 100644 index 00000000..173b559b --- /dev/null +++ b/open-sse/providers/registry/devin-cli.js @@ -0,0 +1,62 @@ +export default { + id: "devin-cli", + alias: "dv", + aliases: ["devin"], + uiAlias: "dv", + display: { + name: "Devin CLI", + icon: "smart_toy", + color: "#6366F1", + textIcon: "DV", + website: "https://devin.ai", + notice: { + signupUrl: "https://cli.devin.ai", + text: "Install the Devin CLI and run `devin auth login` first. Spawns the `devin` binary via ACP/stdio — no API key field.", + }, + }, + category: "free", + authType: "none", + noAuth: true, + authModes: ["none"], + transport: { + baseUrl: "devin://acp/stdio", + format: "openai", + }, + models: [ + { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, + { id: "swe-1.6", name: "SWE-1.6" }, + { id: "swe-1.5-fast", name: "SWE-1.5 Fast" }, + { id: "swe-1.5", name: "SWE-1.5" }, + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 }, + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M", contextLength: 1000000 }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking", contextLength: 200000 }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 }, + { id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 }, + { id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 }, + { id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1000000 }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High", contextLength: 1000000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 }, + { id: "deepseek-v4", name: "DeepSeek V4", contextLength: 64000 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 131000 }, + { id: "glm-5.1", name: "GLM-5.1", contextLength: 128000 }, + ], +}; diff --git a/public/providers/devin-cli.png b/public/providers/devin-cli.png new file mode 100644 index 0000000000000000000000000000000000000000..1fe62d2374323b529977da2c8dd1aa868725542b GIT binary patch literal 2525 zcmV<32_p81P)!d0sVwDiVq?+0LwqjRj$f|r zYoBv`pPv*dcJ4hsH_!h*=ef^$&aneUz*XP4$?$RlKyxuZ#|8uebg>s|Bs^#J2RN*3 z%vImm51<3Uc97AnCJ5jP0KXaud#!5J5&^jC8*jybC8L*yAONV5a7&>LTEZ*8s$A?l5Hi$OG7mAb>9B0m%6FqZyze(DpcJ%GcHe9hCgBH#7v; z91?)dApzJN5`fJq3jwInMc!SI7HI{bMi+I%dpw?m*Mxbb6o49CgvaB-$cPuw#sA{+ z#21+MU&Fw_J`zHXNR8!D=?a{GuiVu3m6$4!_lL!kO%-&R#mV46Yov{4~>7;`vIPK;_;$= zPHpWv{PM{saqr$eO#A()saC9&WU^ryGhANLw$!jOnUt$T%(ScDJLgtqr|B z-PrlevkB*kj%_^v=I7_}?{B{auo5J;wzlS7h@vO~T%DZ4(W9>b5Z4*+Lh-v?|qSXTAil)U179%T5JNMBAA?p)`sRsan<>e)O=sk<7s%rdX<3`lhuH)LD zHEY%s+(u<(B|M%T=-j>y|G9G)bF(*uszDIS12EW~^3L0Dp|_`-cXuo!6k5RV`hPD} zNt#d|fWhvN^XJba5cn51Zu~JGee_3pwG|32;Nwp|!Ni0QUw!4r&6_t7(_(qIR|vJW z>+snf*@VDW2le$m+!#BRyJcq*yA{ZSTM_*q*4FS1ORb8Y1T)OlnZp_Z% z!S@gF^R~8x;}j);H~)GX$BrF`tFA#mCtaJ%%S-s~{(WrQw#|}&&kQp&*M%Aagh>RM zg$)aRef`*f;2;zwfKX@w`wtw%&S##*@bDSacbBWK0mH**Ff%j5`?9zpbPSLVX5iuB zGkO}Ay`PV3y*fE1Ozxr7Naq2JFoWl7c*f-96f`Zy`*OG-wE&31k#Y!NSXG$O+G^iL zBGL-LxQKMah9-T{Cy=`k*xbGykxl^Vb_v^!9Y)o4Mhi=F86Vl2n%3j} z_ugH#X;ktL6OvsY{}@6K0ogW%%7n2@>+0kbCMTx=tOR^+cY8s*j+7HZ&IPbB-0BG% zD+)Xc{ro??@M2Ec%3>p&2Poz)Av}$5+^|X%;{$>TK#eY9XlT&#y@7c1`WtWR)6yat z9}s8=V0S*RqEJnXAsUThetuqmudS^u@4m2UY4%tOh*=malm`HSXf#@K0VqmfWe2f% z;U@5~XvFPq&$%p(wI{>=gi<^P7L(Djact?>nqVyBY-3+vKX!Kg8j2Fg+7_FZX6+R^ zNmUAj5de>Wl%fRm>YP-c&))a>eAv>l75fhyL@2bd>Nrh{VRUR9k8k>!G=^sgGX#`+ z6^*9FaPrh^rj9jwg!%bfc>3vI>4B=E1f<@DRG1(Tfa%Sktw2!%*wV2zXM-gNbRq|M zX0WXXU`xPqCE3uj8lGX>7~t;R5bsMC0$~Io!%Et6L>K|c?n-4v7y*z6;Day%AP>L? zVFW-PfDgh5fII*ngb@IF06qw_IJBy|26MAF@WV$QDf;dpqhsSZbm%a79S9@<09jZ6 z7K)+-FgWzOzIvDtY%YLd{7zsiO2dp_6a`ScVMIs)Fbb&(2I#9~%^=0(9+UE0bZU+ zD*%S<6*;^SrXna`$nb?BOe&b*0|~(9kN|8B3Bcx500{E#1i_791%NKzy&$;JMMHqi zVFI9Gz2{x`YzF1z;zOJfXdmF%W zyn9I$g}rJdoZtZ*sg9yppgb{-{fu-^0h>YD!Ez#q6`QWYk?ssved8tov%GswSiMctc>}lz6{Zn z2VhWOC|FLrt00000NkvXXu0mjftr4XB literal 0 HcmV?d00001 diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index c3ac832b..5925e526 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -14,6 +14,7 @@ import { GET as kiloGet } from "../kilo-settings/route"; import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; import { GET as jcodeGet } from "../jcode-settings/route"; import { GET as grokBuildGet } from "../grok-build-settings/route"; +import { GET as devinGet } from "../devin-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -29,6 +30,7 @@ const STATUS_GETTERS = { "deepseek-tui": deepseekTuiGet, jcode: jcodeGet, "grok-build": grokBuildGet, + devin: devinGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/devin-settings/route.js b/src/app/api/cli-tools/devin-settings/route.js new file mode 100644 index 00000000..1679ef92 --- /dev/null +++ b/src/app/api/cli-tools/devin-settings/route.js @@ -0,0 +1,78 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +// Mirror the executor's resolveDevinBin discovery so the dashboard's status +// matches what the runtime actually spawns. +const candidateDevinPaths = () => { + const home = os.homedir(); + const paths = [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + ]; + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.exe")); + } + return paths; +}; + +const checkDevinInstalled = async () => { + // 1. PATH lookup + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where devin" : "which devin"; + await execAsync(command, { windowsHide: true }); + return { installed: true, source: "path" }; + } catch { + // fall through to filesystem probes + } + // 2. Known installer paths + for (const candidate of candidateDevinPaths()) { + try { + await fs.access(candidate); + return { installed: true, source: candidate }; + } catch { /* keep probing */ } + } + return { installed: false, source: null }; +}; + +const readDevinVersion = async () => { + try { + const { stdout } = await execAsync("devin --version", { windowsHide: true }); + return stdout.trim().split("\n")[0] || null; + } catch { + return null; + } +}; + +// GET — install detection only. No config to write: the binary handles its own auth. +export async function GET() { + try { + const { installed, source } = await checkDevinInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + message: "Devin CLI is not installed. Install it from https://cli.devin.ai and run `devin auth login`.", + installUrl: "https://cli.devin.ai", + }); + } + const version = await readDevinVersion(); + return NextResponse.json({ + installed: true, + source, + version, + message: "Devin CLI detected. Make sure `devin auth login` has been run.", + }); + } catch (error) { + console.log("Error checking devin settings:", error); + return NextResponse.json({ error: "Failed to check devin settings" }, { status: 500 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index fc49d36a..78ce98d0 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -390,6 +390,32 @@ amp --model "{{model}}" }, ], }, + devin: { + id: "devin", + name: "Devin CLI", + image: "/providers/devin-cli.png", + color: "#6366F1", + description: "Cognition Devin CLI — local binary called by the Devin CLI provider via ACP/stdio", + configType: "guide", + installUrl: "https://cli.devin.ai", + notes: [ + { type: "info", text: "This is a local dependency, not a routed CLI. The Devin CLI provider spawns `devin acp --agent-type summarizer` and relays its output." }, + { type: "warning", text: "Install the Devin CLI and run `devin auth login` — without it, the provider returns a spawn error on first request." }, + ], + guideSteps: [ + { step: 1, title: "Install Devin CLI", desc: "Install via the official installer at cli.devin.ai.", docsUrl: "https://cli.devin.ai" }, + { step: 2, title: "Authenticate", desc: "Log in once so the binary stores its own credentials." }, + { step: 3, title: "Use the provider", desc: "Pick any Devin CLI model under the Providers tab — no API key field needed." }, + ], + codeBlock: { + language: "bash", + code: `# Install Devin CLI (see https://cli.devin.ai for options) +devin auth login + +# Verify detection (optional) +devin --version`, + }, + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli",