diff --git a/README.md b/README.md index 2d442ad..d1beecf 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Supported provider names today: - `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx - `claude`: local Claude Code CLI in print mode - `cursor`: local Cursor Agent CLI (experimental; `doctor` is enabled by default) +- `devin`: local Devin CLI in print mode (`--permission-mode` enforces read-only for review/revalidate) - `grok`: local Grok Build CLI - `opencode`: local OpenCode CLI - `pi`: local Pi coding agent in print mode diff --git a/docs/providers.md b/docs/providers.md index 5fcdbf6..149b320 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -19,6 +19,7 @@ Provider names today: - `codex`: shells out to `codex exec` (default) - `acpx`: routes through any ACP-compatible coding agent via `acpx` - `claude`: shells out to Claude Code in print mode (`claude -p`) +- `devin`: shells out to the local Devin CLI in print mode (`devin --print --prompt-file`) - `grok`: shells out to the xAI Grok Build CLI in headless mode (`grok --prompt-file`) - `opencode`: shells out to `opencode run --format json` - `pi`: shells out to `pi -p` (non-interactive print mode) @@ -138,6 +139,44 @@ Migration note: `--provider codex --model gpt-5-codex` is not equivalent to `--provider acpx --model gpt-5-codex`; the latter selects an ACP agent named `gpt-5-codex`. Use `--provider acpx --model codex:gpt-5-codex`. +## Devin + +The `devin` provider shells out to the local [Devin CLI](https://docs.devin.ai/cli) +in non-interactive print mode: + +- availability check: `devin --version` +- review / revalidate: `--permission-mode auto` — the Devin runtime auto-approves + read-only tools only and never grants write/execute in print mode, so this is + enforced read-only behavior (not a prompt-only directive) +- fix: `--permission-mode accept-edits` — the Devin runtime auto-approves workspace + edits; run `fix` only in an isolated trusted checkout +- prompt delivery: writes the full Clawpatch prompt to a temporary file and calls + `devin --print --prompt-file --permission-mode ` +- reasoning effort: the Devin CLI has no `--reasoning-effort` flag (verified against + v3000.2.17), so the requested effort level is injected into the prompt text +- output: parsed from stdout with the shared JSON extractor +- timeout: 300 seconds by default, override with `CLAWPATCH_DEVIN_TIMEOUT_MS` or + `CLAWPATCH_PROVIDER_TIMEOUT_MS` +- model selection: `--model ` is passed through to Devin when configured; + when unset, Devin uses its own configured default (`~/.config/devin/config.json` + on macOS/Linux, `%APPDATA%\devin\config.json` on Windows) +- environment: the Devin subprocess inherits the ambient environment so it can + locate its config-file credentials and proxy settings; clawpatch does not inject + or strip environment variables for this provider + +Provider selection: + +```bash +clawpatch review --provider devin +CLAWPATCH_PROVIDER=devin clawpatch review +clawpatch fix --finding --provider devin +clawpatch doctor --provider devin +``` + +Authentication: run `devin auth login` once before using this provider. The +provider `check` runs `devin --version` only; a missing or expired auth token +surfaces as a `provider-failure` error on the first review/map/fix call. + ## Claude The `claude` provider shells out to the local diff --git a/docs/spec.md b/docs/spec.md index d42eaba..c47f97c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -905,6 +905,7 @@ Implemented providers: - `acpx`: ACP-compatible agents through `acpx`. - `claude`: Claude Code CLI in print mode. - `cursor`: experimental Cursor Agent CLI integration. +- `devin`: Devin CLI in print mode with `--permission-mode` read-only enforcement. - `grok`: Grok Build CLI. - `opencode`: OpenCode CLI. - `pi`: pi coding agent. diff --git a/src/provider.test.ts b/src/provider.test.ts index c4cafd6..42eef03 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ClawpatchError } from "./errors.js"; import { __testing, extractJson, providerByName } from "./provider.js"; @@ -31,6 +32,9 @@ const { cursorFailureMessage, cursorPrompt, cursorTimeoutMs, + devinFailureMessage, + devinPrompt, + devinTimeoutMs, extractAcpxJson, extractCursorJson, extractClaudeStructuredOutput, @@ -796,10 +800,10 @@ describe("Claude provider helpers", () => { expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({ PATH: "/bin", - HOME: "/tmp/claude/home", - XDG_CONFIG_HOME: "/tmp/claude/xdg-config", - XDG_CACHE_HOME: "/tmp/claude/xdg-cache", - XDG_DATA_HOME: "/tmp/claude/xdg-data", + HOME: join("/tmp/claude", "home"), + XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"), + XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"), + XDG_DATA_HOME: join("/tmp/claude", "xdg-data"), TMPDIR: "/tmp/claude", TEMP: "/tmp/claude", TMP: "/tmp/claude", @@ -807,10 +811,10 @@ describe("Claude provider helpers", () => { }); expect(claudeEnv(true, "/tmp/claude", "isolated")).toEqual({ PATH: "/bin", - HOME: "/tmp/claude/home", - XDG_CONFIG_HOME: "/tmp/claude/xdg-config", - XDG_CACHE_HOME: "/tmp/claude/xdg-cache", - XDG_DATA_HOME: "/tmp/claude/xdg-data", + HOME: join("/tmp/claude", "home"), + XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"), + XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"), + XDG_DATA_HOME: join("/tmp/claude", "xdg-data"), TMPDIR: "/tmp/claude", TEMP: "/tmp/claude", TMP: "/tmp/claude", @@ -836,9 +840,9 @@ describe("Claude provider helpers", () => { HOME: "/host-home", USERPROFILE: "C:\\Users\\operator", CLAUDE_CONFIG_DIR: "/host-claude-config", - XDG_CONFIG_HOME: "/tmp/claude/xdg-config", - XDG_CACHE_HOME: "/tmp/claude/xdg-cache", - XDG_DATA_HOME: "/tmp/claude/xdg-data", + XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"), + XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"), + XDG_DATA_HOME: join("/tmp/claude", "xdg-data"), TMPDIR: "/tmp/claude", TEMP: "/tmp/claude", TMP: "/tmp/claude", @@ -886,10 +890,10 @@ describe("Claude provider helpers", () => { expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({ PATH: "/bin", - HOME: "/tmp/claude/home", - XDG_CONFIG_HOME: "/tmp/claude/xdg-config", - XDG_CACHE_HOME: "/tmp/claude/xdg-cache", - XDG_DATA_HOME: "/tmp/claude/xdg-data", + HOME: join("/tmp/claude", "home"), + XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"), + XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"), + XDG_DATA_HOME: join("/tmp/claude", "xdg-data"), TMPDIR: "/tmp/claude", TEMP: "/tmp/claude", TMP: "/tmp/claude", @@ -1742,6 +1746,7 @@ describe("providerByName", () => { it("returns provider instances for optional CLI-backed providers", () => { expect(providerByName("acpx").name).toBe("acpx"); expect(providerByName("claude").name).toBe("claude"); + expect(providerByName("devin").name).toBe("devin"); expect(providerByName("grok").name).toBe("grok"); expect(providerByName("opencode").name).toBe("opencode"); expect(providerByName("pi").name).toBe("pi"); @@ -1833,6 +1838,48 @@ describe("evidenceRefSchema tolerance", () => { }); }); +describe("Devin provider helpers", () => { + it("injects reasoning effort into the prompt and appends the JSON schema", () => { + const schema = { type: "object", properties: { ok: { type: "boolean" } } }; + const withoutEffort = devinPrompt("review this", schema, null); + expect(withoutEffort).toContain("review this"); + expect(withoutEffort).toContain("Return ONLY a JSON object"); + expect(withoutEffort).toContain(JSON.stringify(schema)); + expect(withoutEffort).not.toContain("REASONING EFFORT"); + + const withEffort = devinPrompt("review this", schema, "xhigh"); + expect(withEffort).toContain("REASONING EFFORT: XHIGH"); + expect(withEffort).toContain("Apply xhigh reasoning depth"); + expect(withEffort).toContain("review this"); + expect(withEffort).toContain(JSON.stringify(schema)); + + // "none" effort is treated like no effort (no preamble) + const noneEffort = devinPrompt("review this", schema, "none"); + expect(noneEffort).not.toContain("REASONING EFFORT"); + }); + + it("uses Devin-specific timeout before generic provider timeout", () => { + delete process.env["CLAWPATCH_DEVIN_TIMEOUT_MS"]; + delete process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + expect(devinTimeoutMs()).toBe(300_000); + + process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"] = "2000"; + expect(devinTimeoutMs()).toBe(2000); + + process.env["CLAWPATCH_DEVIN_TIMEOUT_MS"] = "3000"; + expect(devinTimeoutMs()).toBe(3000); + + process.env["CLAWPATCH_DEVIN_TIMEOUT_MS"] = "bad"; + expect(devinTimeoutMs()).toBe(300_000); + }); + + it("uses bounded failure previews from stderr or stdout", () => { + expect(devinFailureMessage("", "auth required")).toContain("auth required"); + expect(devinFailureMessage("stdout failure", "")).toContain("stdout failure"); + expect(devinFailureMessage("", "")).toBe("devin provider failed"); + }); +}); + describe("acpxPromptRetries", () => { afterEach(() => { delete process.env["CLAWPATCH_ACPX_PROMPT_RETRIES"]; diff --git a/src/provider.ts b/src/provider.ts index 73642f4..b5df23e 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -6,6 +6,7 @@ import { acpxProvider, acpxTesting } from "./providers/acpx.js"; import { claudeProvider, claudeTesting } from "./providers/claude.js"; import { codexProvider, codexTesting } from "./providers/codex.js"; import { cursorProvider, cursorTesting } from "./providers/cursor.js"; +import { devinProvider, devinTesting } from "./providers/devin.js"; import { grokProvider } from "./providers/grok.js"; import { mockFailProvider, mockProvider } from "./providers/mock.js"; import { opencodeProvider, opencodeTesting } from "./providers/opencode.js"; @@ -18,6 +19,7 @@ const providers: Readonly> = { claude: claudeProvider, codex: codexProvider, cursor: cursorProvider, + devin: devinProvider, grok: grokProvider, mock: mockProvider, "mock-fail": mockFailProvider, @@ -39,6 +41,7 @@ export const __testing = { ...claudeTesting, ...codexTesting, ...cursorTesting, + ...devinTesting, ...opencodeTesting, ...piTesting, providerExitCode, diff --git a/src/providers/devin.ts b/src/providers/devin.ts new file mode 100644 index 0000000..90c5c2a --- /dev/null +++ b/src/providers/devin.ts @@ -0,0 +1,158 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCommandArgs } from "../exec.js"; +import { ClawpatchError } from "../errors.js"; +import { providerExitCode } from "../provider-errors.js"; +import { extractJson, safeProviderPreview } from "../provider-json.js"; +import { parseOrThrow, parseReviewOutput } from "../provider-output.js"; +import { providerCheckTimeoutMs, providerTimeoutMs } from "../provider-runtime.js"; +import { + agentMapJsonSchema, + fixPlanJsonSchema, + reviewJsonSchema, + revalidateJsonSchema, +} from "../provider-schema.js"; +import type { PartitionedReviewOutput, Provider, ProviderOptions } from "../provider-types.js"; +import { + AgentMapOutput, + FixPlanOutput, + RevalidateOutput, + agentMapOutputSchema, + fixPlanOutputSchema, + revalidateOutputSchema, +} from "../types.js"; + +const DEVIN_DEFAULT_TIMEOUT_MS = 300_000; + +// Devin CLI permission modes (verified against `devin --help`, v3000.2.17): +// auto — auto-approves read-only tools only; prompts for writes (print mode never +// grants, so this is effective read-only enforcement for review/revalidate) +// accept-edits — auto-approves workspace edits in addition to read-only tools (used for fix) +const DEVIN_READ_PERMISSION_MODE = "auto"; +const DEVIN_WRITE_PERMISSION_MODE = "accept-edits"; + +export const devinProvider: Provider = { + name: "devin", + async check(root: string): Promise { + const result = await runCommandArgs("devin", ["--version"], root, undefined, { + timeoutMs: providerCheckTimeoutMs(), + }); + if (result.exitCode !== 0) { + throw new ClawpatchError( + "devin CLI not available. Install from https://docs.devin.ai/cli", + 4, + "provider-auth", + ); + } + return result.stdout.trim() || result.stderr.trim(); + }, + async map(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runDevinJson(root, prompt, options, agentMapJsonSchema, true); + return parseOrThrow(agentMapOutputSchema, output, "devin agent-map"); + }, + async review( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runDevinJson(root, prompt, options, reviewJsonSchema, true); + return parseReviewOutput(output); + }, + async fix(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runDevinJson(root, prompt, options, fixPlanJsonSchema, false); + return parseOrThrow(fixPlanOutputSchema, output, "devin fix-plan"); + }, + async revalidate( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runDevinJson(root, prompt, options, revalidateJsonSchema, true); + return parseOrThrow(revalidateOutputSchema, output, "devin revalidate"); + }, +}; + +async function runDevinJson( + root: string, + prompt: string, + options: ProviderOptions, + schema: object, + readOnly: boolean, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "clawpatch-devin-")); + const promptPath = join(dir, "prompt.txt"); + try { + await writeFile(promptPath, devinPrompt(prompt, schema, options.reasoningEffort), "utf8"); + const args = [ + "--print", + "--prompt-file", + promptPath, + "--permission-mode", + readOnly ? DEVIN_READ_PERMISSION_MODE : DEVIN_WRITE_PERMISSION_MODE, + ]; + if (options.model !== null) { + args.push("--model", options.model); + } + const result = await runCommandArgs("devin", args, root, undefined, { + trimOutput: false, + timeoutMs: devinTimeoutMs(), + }); + if (result.exitCode !== 0) { + throw new ClawpatchError( + devinFailureMessage(result.stdout, result.stderr), + providerExitCode(result.stdout, result.stderr), + "provider-failure", + ); + } + const json = extractJson(result.stdout); + if (json === null) { + throw new ClawpatchError( + `devin provider produced unparseable JSON output (preview: ${safeProviderPreview(result.stdout)})`, + 8, + "malformed-output", + ); + } + return json; + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +function devinPrompt(prompt: string, schema: object, reasoningEffort: string | null): string { + const effortPreamble = + reasoningEffort !== null && reasoningEffort !== "none" + ? `REASONING EFFORT: ${reasoningEffort.toUpperCase()}\n` + + `Apply ${reasoningEffort} reasoning depth to this review. ` + + `At "high" or "xhigh", exhaustively analyze every file, trace data flows, ` + + `and consider edge cases that lower-effort passes would miss.\n\n` + : ""; + return ( + `${effortPreamble}${prompt}\n\n` + + "Return ONLY a JSON object matching this schema. No prose preamble, no markdown fences, " + + "no thinking-out-loud text before the JSON. " + + `Schema:\n${JSON.stringify(schema)}\n` + ); +} + +function devinFailureMessage(stdout: string, stderr: string): string { + const stderrPreview = safeProviderPreview(stderr); + if (stderrPreview.length > 0) { + return `devin provider failed: ${stderrPreview}`; + } + const stdoutPreview = safeProviderPreview(stdout); + if (stdoutPreview.length > 0) { + return `devin provider failed: ${stdoutPreview}`; + } + return "devin provider failed"; +} + +function devinTimeoutMs(): number { + return providerTimeoutMs("CLAWPATCH_DEVIN_TIMEOUT_MS", DEVIN_DEFAULT_TIMEOUT_MS); +} + +export const devinTesting = { + devinFailureMessage, + devinPrompt, + devinTimeoutMs, +};