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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 63 additions & 2 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <id> --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 `@<path>` 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 <pattern>` supports provider-prefixed IDs like
`anthropic/claude-sonnet-4` and thinking-level shorthands like `sonnet:high`
- Reasoning effort: `--thinking <level>` 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.
12 changes: 12 additions & 0 deletions src/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
extractOpencodeJson,
parseAcpxAgent,
parseCodexJson,
piThinkingLevel,
providerJsonSchema,
} = __testing;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down
132 changes: 132 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -192,6 +195,134 @@ const grokProvider: Provider = {
},
};

const PI_DEFAULT_TIMEOUT_MS = 180_000;

const piProvider: Provider = {
name: "pi",
async check(root: string): Promise<string> {
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<AgentMapOutput> {
const output = await runPiJson(root, prompt, options, agentMapJsonSchema, true);
return agentMapOutputSchema.parse(output);
},
async review(root: string, prompt: string, options: ProviderOptions): Promise<ReviewOutput> {
const output = await runPiJson(root, prompt, options, reviewJsonSchema, true);
return reviewOutputSchema.parse(output);
},
async fix(root: string, prompt: string, options: ProviderOptions): Promise<FixPlanOutput> {
const output = await runPiJson(root, prompt, options, fixPlanJsonSchema, false);
return fixPlanOutputSchema.parse(output);
},
async revalidate(
root: string,
prompt: string,
options: ProviderOptions,
): Promise<RevalidateOutput> {
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<unknown> {
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<string> {
Expand Down Expand Up @@ -843,5 +974,6 @@ export const __testing = {
extractOpencodeJson,
parseAcpxAgent,
parseCodexJson,
piThinkingLevel,
providerJsonSchema,
};