diff --git a/CHANGELOG.md b/CHANGELOG.md index 00af33a..fe2446b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.2.1 - Unreleased +- Added a `pi` provider for routing review, fix, revalidate, and agent map through the [pi coding agent](https://pi.dev) in non-interactive print mode. - Added explicit Codex reasoning effort selection via `--reasoning-effort`, `CLAWPATCH_REASONING_EFFORT`, and provider config, with `doctor` reporting the active setting. - Added deterministic Express, Fastify, and Hono route mapping for Node projects, thanks @rohitjavvadi. - Fixed provider commands with relative `--root` paths by canonicalizing explicit roots before invoking Codex or other providers. diff --git a/docs/providers.md b/docs/providers.md index 7b07fa8..9453888 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -17,6 +17,7 @@ Provider names today: - `acpx`: routes through any ACP-compatible coding agent via `acpx` - `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) - `mock`: deterministic provider for tests and fixtures - `mock-fail`: failure provider for tests @@ -134,7 +135,67 @@ How the Grok provider works: - Structured output: validates the returned JSON against the same Zod schemas used for Codex - Large prompts: always uses `--prompt-file` instead of passing prompt text on the command line +## Pi + +The `pi` provider shells out to the local [pi coding agent](https://pi.dev) +in non-interactive print mode (`pi -p`). + +Install pi: + +```bash +curl -fsSL https://pi.dev/install.sh | sh +``` + +Authenticate with an API key: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +Or use a subscription: + +```bash +pi +/login +``` + +Then verify: + +```bash +pi --version +``` + +Provider selection: + +```bash +clawpatch review --provider pi +CLAWPATCH_PROVIDER=pi clawpatch review +clawpatch fix --finding --provider pi --model anthropic/claude-sonnet-4 +clawpatch doctor --provider pi +``` + +How the pi provider works: + +- Non-interactive mode: `pi -p --no-session` with all discovery flags disabled + (`--no-context-files --no-skills --no-extensions --no-prompt-templates --no-themes`) + to isolate the agent from project and user configuration +- Prompt delivery: written to a temp file and passed via `@` file reference +- Read-only operations (map, review, revalidate): `--tools read` restricts the + agent to the read tool only +- Write operations (fix): uses the default tool set (read, bash, edit, write) +- Model selection: `--model ` supports provider-prefixed IDs like + `anthropic/claude-sonnet-4` and thinking-level shorthands like `sonnet:high` +- Reasoning effort: `--thinking ` maps from clawpatch's reasoning effort +- Output: parsed from stdout text using the shared `extractJson` helper +- Timeout: 180 seconds by default, override with `CLAWPATCH_PI_TIMEOUT_MS` or + `CLAWPATCH_PROVIDER_TIMEOUT_MS` + +Permission caveat: pi's `--tools read` restricts the agent to the read tool for +review and revalidate, but enforcement depends on pi honoring the tool allowlist. +For write operations during `fix`, the agent has full filesystem and shell access. +For untrusted code, run `clawpatch fix --provider pi` inside an isolated checkout. + Direct OpenAI API, local-model, and multi-model panel providers are not implemented yet. The `acpx` provider is the generic route for ACP-compatible -agents; the `grok` and `opencode` providers are direct integrations for local -CLIs. +agents; the `grok`, `opencode`, and `pi` providers are direct integrations +for local CLIs. diff --git a/src/provider.test.ts b/src/provider.test.ts index 6c160f1..31ebc1e 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -11,6 +11,7 @@ const { extractOpencodeJson, parseAcpxAgent, parseCodexJson, + piThinkingLevel, providerJsonSchema, } = __testing; @@ -160,6 +161,16 @@ describe("providerJsonSchema", () => { }); }); +describe("piThinkingLevel", () => { + it("maps clawpatch none to pi off", () => { + expect(piThinkingLevel("none")).toBe("off"); + }); + + it("passes supported pi thinking levels through", () => { + expect(piThinkingLevel("xhigh")).toBe("xhigh"); + }); +}); + function schemaKeys(value: unknown): string[] { if (Array.isArray(value)) { return value.flatMap(schemaKeys); @@ -407,6 +418,7 @@ describe("providerByName", () => { expect(providerByName("acpx").name).toBe("acpx"); expect(providerByName("grok").name).toBe("grok"); expect(providerByName("opencode").name).toBe("opencode"); + expect(providerByName("pi").name).toBe("pi"); }); it("still supports codex, mock, and mock-fail", () => { diff --git a/src/provider.ts b/src/provider.ts index 7feb1e9..adfb102 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -51,6 +51,9 @@ export function providerByName(name: string): Provider { if (name === "grok") { return grokProvider; } + if (name === "pi") { + return piProvider; + } if (name === "mock") { return mockProvider; } @@ -192,6 +195,134 @@ const grokProvider: Provider = { }, }; +const PI_DEFAULT_TIMEOUT_MS = 180_000; + +const piProvider: Provider = { + name: "pi", + async check(root: string): Promise { + const result = await runCommandArgs("pi", ["--version"], root); + if (result.exitCode !== 0) { + throw new ClawpatchError("pi CLI not available", 4, "provider-auth"); + } + return (result.stdout.trim() || result.stderr.trim()); + }, + async map(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runPiJson(root, prompt, options, agentMapJsonSchema, true); + return agentMapOutputSchema.parse(output); + }, + async review(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runPiJson(root, prompt, options, reviewJsonSchema, true); + return reviewOutputSchema.parse(output); + }, + async fix(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runPiJson(root, prompt, options, fixPlanJsonSchema, false); + return fixPlanOutputSchema.parse(output); + }, + async revalidate( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runPiJson(root, prompt, options, revalidateJsonSchema, true); + return revalidateOutputSchema.parse(output); + }, +}; + +async function runPiJson( + root: string, + prompt: string, + options: ProviderOptions, + schema: object, + readOnly: boolean, +): Promise { + const dir = await mkdtemp(join(tmpdir(), "clawpatch-pi-")); + const promptPath = join(dir, "prompt.txt"); + await writeFile(promptPath, piPrompt(prompt, schema, readOnly), "utf8"); + + try { + const args = [ + "-p", + "--no-session", + "--no-context-files", + "--no-skills", + "--no-extensions", + "--no-prompt-templates", + "--no-themes", + `@${promptPath}`, + "Follow the attached clawpatch prompt. Return only the requested JSON object.", + ]; + if (options.model !== null) { + args.push("--model", options.model); + } + if (options.reasoningEffort !== null) { + args.push("--thinking", piThinkingLevel(options.reasoningEffort)); + } + if (readOnly) { + args.push("--tools", "read"); + } + const result = await runCommandArgs("pi", args, root, undefined, { + trimOutput: false, + timeoutMs: piTimeoutMs(), + }); + if (result.exitCode !== 0) { + throw new ClawpatchError( + piFailureMessage(result.stdout, result.stderr), + providerExitCode(result.stderr), + "provider-failure", + ); + } + const text = result.stdout.trim(); + if (text.length === 0) { + throw new ClawpatchError("pi provider produced no output", 8, "malformed-output"); + } + const parsed = extractJson(text); + if (parsed === null) { + throw new ClawpatchError( + `pi provider produced unparsable JSON (output preview: ${safeProviderPreview(text)})`, + 8, + "malformed-output", + ); + } + return parsed; + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +function piPrompt(prompt: string, schema: object, readOnly: boolean): string { + const promptBody = readOnly + ? "READ-ONLY REVIEW MODE.\n" + + "Do not modify, create, or delete any files.\n" + + "Do not run shell commands.\n\n" + + prompt + : prompt; + return `${promptBody}\n\nProvider output schema:\n${JSON.stringify(schema, null, 2)}\n\nReturn only one JSON object matching the schema.`; +} + +function piFailureMessage(stdout: string, stderr: string): string { + if (stderr.trim().length > 0) { + return `pi provider failed: ${safeProviderPreview(stderr)}`; + } + const preview = safeProviderPreview(stdout); + return preview.length === 0 + ? "pi provider failed" + : `pi provider failed (output preview: ${preview})`; +} + +function piThinkingLevel(reasoningEffort: ReasoningEffort): string { + return reasoningEffort === "none" ? "off" : reasoningEffort; +} + +function piTimeoutMs(): number { + const raw = + process.env["CLAWPATCH_PI_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + if (raw === undefined) { + return PI_DEFAULT_TIMEOUT_MS; + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : PI_DEFAULT_TIMEOUT_MS; +} + const mockProvider: Provider = { name: "mock", async check(): Promise { @@ -843,5 +974,6 @@ export const __testing = { extractOpencodeJson, parseAcpxAgent, parseCodexJson, + piThinkingLevel, providerJsonSchema, };