Skip to content
Draft
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`claude-sdk` env-var passthrough** — The `claude-sdk` harness now honors
`PROSE_CLAUDE_PERMISSION_MODE` (`default`, `acceptEdits`, `bypassPermissions`,
or `plan`) and forwards it to the Claude Agent SDK as `permissionMode`. This
mirrors the existing `PROSE_CODEX_*` pattern and unblocks non-interactive
`prose run --harness claude-sdk` invocations (CI, conformance runs, scheduled
jobs) that previously stalled on per-write permission prompts because
`permissionMode` is a query-level SDK option that `settingSources` does not
flow into. See `tools/cli/README.md` and
`tools/cli/src/harnesses/claude-options.ts`.

## [0.14.0] - 2026-05-19

### Added
Expand Down
7 changes: 7 additions & 0 deletions tools/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ Codex harnesses also honor `PROSE_CODEX_ADD_DIR` as a comma-separated list of
additional writable directories and `PROSE_CODEX_NETWORK` (`true` or `false`)
for outbound network access.

For externally sandboxed or non-interactive runs, the `claude-sdk` harness
honors `PROSE_CLAUDE_PERMISSION_MODE` (`default`, `acceptEdits`,
`bypassPermissions`, or `plan`) and forwards it to the Claude Agent SDK as
`permissionMode`. The SDK defaults to `default` (prompt for tool use) when the
variable is unset, which is appropriate for interactive sessions but blocks
automated runs that cannot answer permission prompts.

## Skill Setup

OpenProse execution depends on the `open-prose` agent skill. Before running a
Expand Down
32 changes: 32 additions & 0 deletions tools/cli/src/harnesses/claude-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ClaudeSdkQuery } from "./claude-sdk.js";

const CLAUDE_PERMISSION_MODES = ["default", "acceptEdits", "bypassPermissions", "plan"] as const;

type ClaudeQueryOptions = NonNullable<Parameters<ClaudeSdkQuery>[0]["options"]>;

export function claudeRuntimeOptions(
env: Record<string, string | undefined> | undefined,
): Pick<ClaudeQueryOptions, "permissionMode"> {
const permissionMode = claudeEnvOption("PROSE_CLAUDE_PERMISSION_MODE", CLAUDE_PERMISSION_MODES, env);

return {
...(permissionMode === undefined ? {} : { permissionMode }),
};
}

function claudeEnvOption<const T extends readonly string[]>(
name: string,
allowedValues: T,
env: Record<string, string | undefined> | undefined,
): T[number] | undefined {
const value = env?.[name] ?? process.env[name];
if (value === undefined || value === "") {
return undefined;
}

if (allowedValues.includes(value)) {
return value;
}

throw new Error(`${name} must be one of: ${allowedValues.join(", ")}`);
}
3 changes: 3 additions & 0 deletions tools/cli/src/harnesses/claude-sdk.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { claudeRuntimeOptions } from "./claude-options.js";
import { writeLine } from "./streams.js";
import type { Harness, HarnessRunOptions } from "./types.js";

Expand All @@ -22,6 +23,7 @@ export function createClaudeSdkHarness(options: ClaudeSdkHarnessOptions = {}): H
name: "claude-sdk",
async run(prompt, runOptions) {
const abortController = bridgeAbortController(runOptions.signal);
const runtimeOptions = claudeRuntimeOptions(runOptions.env);
const stream = await Promise.resolve(
query({
prompt,
Expand All @@ -33,6 +35,7 @@ export function createClaudeSdkHarness(options: ClaudeSdkHarnessOptions = {}): H
...(runOptions.cwd === undefined ? {} : { cwd: runOptions.cwd }),
...(runOptions.env === undefined ? {} : { env: runOptions.env }),
includePartialMessages: true,
...runtimeOptions,
settingSources: ["user", "project"],
stderr: (chunk: string) => runOptions.stderr.write(chunk),
...(runOptions.systemPromptAppend === undefined
Expand Down
72 changes: 72 additions & 0 deletions tools/cli/tests/harnesses/harnesses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,78 @@ describe("claude-sdk harness", () => {
expect(io.stderr).toBe("bad\n");
});

test("forwards PROSE_CLAUDE_PERMISSION_MODE to the SDK as permissionMode", async () => {
const io = memoryStreams();
const calls: unknown[] = [];
const harness = createClaudeSdkHarness({
query: async (args) => {
calls.push(args);
return {
async *[Symbol.asyncIterator]() {
yield { type: "result", subtype: "success", result: "ok", is_error: false };
},
close() {},
} as never;
},
});

const exitCode = await harness.run("prose run inspector.prose.md", {
...io.options,
env: { PROSE_CLAUDE_PERMISSION_MODE: "bypassPermissions" },
});

expect(exitCode).toBe(0);
expect(calls).toEqual([
expect.objectContaining({
options: expect.objectContaining({
permissionMode: "bypassPermissions",
}),
}),
]);
});

test("omits permissionMode when PROSE_CLAUDE_PERMISSION_MODE is unset", async () => {
const io = memoryStreams();
const calls: unknown[] = [];
const harness = createClaudeSdkHarness({
query: async (args) => {
calls.push(args);
return {
async *[Symbol.asyncIterator]() {
yield { type: "result", subtype: "success", result: "ok", is_error: false };
},
close() {},
} as never;
},
});

// Pass an explicit empty env so the test does not depend on the
// host shell's PROSE_CLAUDE_PERMISSION_MODE.
await harness.run("prose status", { ...io.options, env: {} });

expect(calls).toHaveLength(1);
const call = calls[0] as { options: Record<string, unknown> };
expect(call.options).not.toHaveProperty("permissionMode");
});

test("rejects invalid PROSE_CLAUDE_PERMISSION_MODE", async () => {
const io = memoryStreams();
const harness = createClaudeSdkHarness({
query: async () => {
throw new Error("unexpected query");
},
});

await expect(
harness.run("prose run inspector.prose.md", {
...io.options,
env: { PROSE_CLAUDE_PERMISSION_MODE: "yolo" },
}),
).rejects.toThrow(
"PROSE_CLAUDE_PERMISSION_MODE must be one of: default, acceptEdits, bypassPermissions, plan",
);
});

test("always forwards settingSources: ['user', 'project']", async () => {
const io = memoryStreams();
const calls: unknown[] = [];
Expand Down
Loading