fix: inline --json-schema file paths so structured output works on CLI 2.1.160 (#3) #4
@@ -109,9 +109,10 @@ jobs:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- **Use the file-path form** (`--json-schema /path/to/schema.json`). An inline schema also works if single-quoted (`--json-schema '{"type":"object"}'`), but the schema must be single-quoted so the shell tokenizer treats it as one literal token and does not expand `$` (e.g. `$ref`).
|
- **Use the file-path form** (`--json-schema /path/to/schema.json`). The action reads the file and passes its contents to the CLI **inline**, so the file-path form works even on CLIs whose `--json-schema` only accepts an inline string (including the default-pinned `2.1.160`). The path is preferred because it has no spaces or `$` for the shell to mangle, so a schema with `$ref`/`$defs` round-trips intact. An inline schema also works if single-quoted (`--json-schema '{"type":"object"}'`) so the shell treats it as one literal token and does not expand `$` (e.g. `$ref`).
|
||||||
|
- If the `--json-schema` value is a path that can't be read, or the file isn't valid JSON, the run **fails loudly** with a clear error (rather than the CLI silently emitting empty output).
|
||||||
- If `--json-schema` is supplied but the model returns no structured result, the run **fails loudly** rather than emitting an empty output.
|
- If `--json-schema` is supplied but the model returns no structured result, the run **fails loudly** rather than emitting an empty output.
|
||||||
- **Claude CLI version.** `--json-schema` requires a recent CLI. This action installs `2.1.160` by default. If you pin `path_to_claude_code_executable` (e.g. a pre-baked runner image), that baked CLI must be `--json-schema`-capable — bump it independently. See [Version pins](#version-pins).
|
- **Claude CLI version.** The default-installed CLI (`2.1.160`) supports `--json-schema`, and the action inlines a file-path schema for you — so the file-path form is not version-sensitive. If you pin `path_to_claude_code_executable` (e.g. a pre-baked runner image), that baked CLI must still be new enough to support the `--json-schema` flag itself — bump it independently. See [Version pins](#version-pins).
|
||||||
|
|
||||||
### Version pins
|
### Version pins
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
claude_args:
|
claude_args:
|
||||||
description: "Additional arguments to pass directly to the Claude CLI, forwarded verbatim (e.g. '--json-schema /path/to/schema.json' to get schema-validated output via the structured_output output). Requires a Claude CLI new enough to support the flags used (see README)."
|
description: "Additional arguments to pass directly to the Claude CLI, forwarded verbatim (e.g. '--json-schema /path/to/schema.json' to get schema-validated output via the structured_output output; a file path given to --json-schema is read and inlined automatically, so it works on the default-installed CLI). Requires a Claude CLI new enough to support the flags used (see README)."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as core from "@actions/core";
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { unlink, writeFile, stat } from "fs/promises";
|
import { unlink, writeFile, stat } from "fs/promises";
|
||||||
import { createWriteStream } from "fs";
|
import { createWriteStream, readFileSync } from "fs";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { parse as parseShellArgs } from "shell-quote";
|
import { parse as parseShellArgs } from "shell-quote";
|
||||||
|
|
||||||
@@ -66,6 +66,84 @@ export function parseClaudeArgs(claudeArgs?: string): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single `--json-schema` value. The Claude CLI's `--json-schema`
|
||||||
|
* flag accepts an *inline* JSON string. The documented (and recommended) form
|
||||||
|
* passes a file *path* so a schema containing `$ref`/`$defs` survives shell
|
||||||
|
* tokenization without `$` expansion — but CLIs that only accept inline schemas
|
||||||
|
* (including the default-pinned 2.1.160) silently ignore a path and emit no
|
||||||
|
* structured output. To make the file-path form work everywhere, we read the
|
||||||
|
* file in-process and return its contents as compact inline JSON; because this
|
||||||
|
* value is handed to the CLI as an argv element (never through a shell), the
|
||||||
|
* `$` characters are safe. Values that are already inline JSON (the trimmed
|
||||||
|
* value starts with `{` or `[`) are passed through untouched.
|
||||||
|
*/
|
||||||
|
function resolveJsonSchemaValue(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
return value; // Already an inline JSON schema — leave exactly as supplied.
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents: string;
|
||||||
|
try {
|
||||||
|
contents = readFileSync(value, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to read --json-schema file "${value}": ${
|
||||||
|
e instanceof Error ? e.message : String(e)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(contents));
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`--json-schema file "${value}" is not valid JSON: ${
|
||||||
|
e instanceof Error ? e.message : String(e)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk tokenized CLI args and inline any file-path value supplied to
|
||||||
|
* `--json-schema` (both the `--json-schema <path>` and `--json-schema=<path>`
|
||||||
|
* forms). See {@link resolveJsonSchemaValue} for why. Non-schema args and
|
||||||
|
* already-inline schemas are returned unchanged.
|
||||||
|
*/
|
||||||
|
export function inlineJsonSchemaArgs(args: string[]): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
const PREFIX = "--json-schema=";
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--json-schema") {
|
||||||
|
result.push(arg);
|
||||||
|
const value = args[i + 1];
|
||||||
|
if (value !== undefined) {
|
||||||
|
result.push(resolveJsonSchemaValue(value));
|
||||||
|
i++; // Consume the value token we just inlined.
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith(PREFIX)) {
|
||||||
|
const value = arg.slice(PREFIX.length);
|
||||||
|
result.push(`${PREFIX}${resolveJsonSchemaValue(value)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locate the schema-validated result emitted by the Claude CLI. With
|
* Locate the schema-validated result emitted by the Claude CLI. With
|
||||||
* `--output-format stream-json` the CLI prints one JSON object per line and the
|
* `--output-format stream-json` the CLI prints one JSON object per line and the
|
||||||
@@ -177,8 +255,10 @@ export function prepareRunConfig(
|
|||||||
|
|
||||||
// Forward any additional raw CLI arguments verbatim, after the args this
|
// Forward any additional raw CLI arguments verbatim, after the args this
|
||||||
// action manages. Empty/unset claude_args leaves the arg list byte-identical
|
// action manages. Empty/unset claude_args leaves the arg list byte-identical
|
||||||
// to before this feature, preserving behavior for existing callers.
|
// to before this feature, preserving behavior for existing callers. A
|
||||||
const extraArgs = parseClaudeArgs(options.claudeArgs);
|
// file-path value passed to `--json-schema` is inlined here so it works on
|
||||||
|
// CLIs that only accept inline schemas (e.g. the default-pinned 2.1.160).
|
||||||
|
const extraArgs = inlineJsonSchemaArgs(parseClaudeArgs(options.claudeArgs));
|
||||||
claudeArgs.push(...extraArgs);
|
claudeArgs.push(...extraArgs);
|
||||||
|
|
||||||
const hasJsonSchema = extraArgs.some(
|
const hasJsonSchema = extraArgs.some(
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
prepareRunConfig,
|
prepareRunConfig,
|
||||||
parseClaudeArgs,
|
parseClaudeArgs,
|
||||||
|
inlineJsonSchemaArgs,
|
||||||
extractStructuredOutput,
|
extractStructuredOutput,
|
||||||
type ClaudeOptions,
|
type ClaudeOptions,
|
||||||
} from "../src/run-claude";
|
} from "../src/run-claude";
|
||||||
@@ -336,7 +340,7 @@ describe("prepareRunConfig", () => {
|
|||||||
test("claude_args tokens are appended after the managed arguments", () => {
|
test("claude_args tokens are appended after the managed arguments", () => {
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", {
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", {
|
||||||
maxTurns: "3",
|
maxTurns: "3",
|
||||||
claudeArgs: "--output-format json --json-schema /tmp/schema.json",
|
claudeArgs: `--output-format json --json-schema '{"type":"object"}'`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toEqual([
|
expect(prepared.claudeArgs).toEqual([
|
||||||
@@ -349,7 +353,7 @@ describe("prepareRunConfig", () => {
|
|||||||
"--output-format",
|
"--output-format",
|
||||||
"json",
|
"json",
|
||||||
"--json-schema",
|
"--json-schema",
|
||||||
"/tmp/schema.json",
|
'{"type":"object"}',
|
||||||
]);
|
]);
|
||||||
expect(prepared.hasJsonSchema).toBe(true);
|
expect(prepared.hasJsonSchema).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -370,12 +374,12 @@ describe("prepareRunConfig", () => {
|
|||||||
test("hasJsonSchema is detected for both space and equals forms", () => {
|
test("hasJsonSchema is detected for both space and equals forms", () => {
|
||||||
expect(
|
expect(
|
||||||
prepareRunConfig("/tmp/test-prompt.txt", {
|
prepareRunConfig("/tmp/test-prompt.txt", {
|
||||||
claudeArgs: "--json-schema /tmp/s.json",
|
claudeArgs: `--json-schema '{"type":"object"}'`,
|
||||||
}).hasJsonSchema,
|
}).hasJsonSchema,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
prepareRunConfig("/tmp/test-prompt.txt", {
|
prepareRunConfig("/tmp/test-prompt.txt", {
|
||||||
claudeArgs: "--json-schema=/tmp/s.json",
|
claudeArgs: `--json-schema='{"type":"object"}'`,
|
||||||
}).hasJsonSchema,
|
}).hasJsonSchema,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
@@ -386,6 +390,116 @@ describe("prepareRunConfig", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("inlineJsonSchemaArgs (issue #3 — file-path schemas)", () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "json-schema-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through args that don't touch --json-schema", () => {
|
||||||
|
const args = ["-p", "--max-turns", "3", "--verbose"];
|
||||||
|
expect(inlineJsonSchemaArgs(args)).toEqual(args);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves an already-inline JSON schema untouched (space form)", () => {
|
||||||
|
const inline = '{"type":"object","properties":{"x":{"type":"string"}}}';
|
||||||
|
expect(inlineJsonSchemaArgs(["--json-schema", inline])).toEqual([
|
||||||
|
"--json-schema",
|
||||||
|
inline,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves an already-inline JSON schema untouched (equals form)", () => {
|
||||||
|
const inline = '{"type":"object"}';
|
||||||
|
expect(inlineJsonSchemaArgs([`--json-schema=${inline}`])).toEqual([
|
||||||
|
`--json-schema=${inline}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inlines a file-path schema (space form)", () => {
|
||||||
|
const schema = { type: "object", required: ["decision"] };
|
||||||
|
const file = join(dir, "space.json");
|
||||||
|
writeFileSync(file, JSON.stringify(schema, null, 2));
|
||||||
|
|
||||||
|
expect(inlineJsonSchemaArgs(["--json-schema", file])).toEqual([
|
||||||
|
"--json-schema",
|
||||||
|
JSON.stringify(schema),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inlines a file-path schema (equals form)", () => {
|
||||||
|
const schema = { type: "object" };
|
||||||
|
const file = join(dir, "equals.json");
|
||||||
|
writeFileSync(file, JSON.stringify(schema));
|
||||||
|
|
||||||
|
expect(inlineJsonSchemaArgs([`--json-schema=${file}`])).toEqual([
|
||||||
|
`--json-schema=${JSON.stringify(schema)}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reason the file-path form exists: a schema with $ref/$defs must
|
||||||
|
// round-trip without the shell expanding `$`. Inlining in-process and
|
||||||
|
// handing the result to spawn() as an argv element keeps it intact.
|
||||||
|
test("inlines a file-path schema containing $ref/$defs intact", () => {
|
||||||
|
const schema = {
|
||||||
|
$ref: "#/$defs/Verdict",
|
||||||
|
$defs: {
|
||||||
|
Verdict: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["decision"],
|
||||||
|
properties: {
|
||||||
|
decision: { type: "string", enum: ["approve", "reject"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const file = join(dir, "ref-schema.json");
|
||||||
|
writeFileSync(file, JSON.stringify(schema, null, 2));
|
||||||
|
|
||||||
|
const [flag, value] = inlineJsonSchemaArgs(["--json-schema", file]);
|
||||||
|
expect(flag).toBe("--json-schema");
|
||||||
|
expect(value).toContain("$ref");
|
||||||
|
expect(value).toContain("$defs");
|
||||||
|
expect(JSON.parse(value as string)).toEqual(schema);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws a clear error when the schema file is missing", () => {
|
||||||
|
const missing = join(dir, "does-not-exist.json");
|
||||||
|
expect(() => inlineJsonSchemaArgs(["--json-schema", missing])).toThrow(
|
||||||
|
/Failed to read --json-schema file/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws a clear error when the schema file is not valid JSON", () => {
|
||||||
|
const file = join(dir, "invalid.json");
|
||||||
|
writeFileSync(file, "{ this is not json ");
|
||||||
|
expect(() => inlineJsonSchemaArgs(["--json-schema", file])).toThrow(
|
||||||
|
/is not valid JSON/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prepareRunConfig inlines a file-path schema end-to-end", () => {
|
||||||
|
const schema = { $ref: "#/$defs/V", $defs: { V: { type: "object" } } };
|
||||||
|
const file = join(dir, "prepare.json");
|
||||||
|
writeFileSync(file, JSON.stringify(schema, null, 2));
|
||||||
|
|
||||||
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", {
|
||||||
|
claudeArgs: `--json-schema ${file}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepared.hasJsonSchema).toBe(true);
|
||||||
|
const idx = prepared.claudeArgs.indexOf("--json-schema");
|
||||||
|
expect(idx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(prepared.claudeArgs[idx + 1]).toBe(JSON.stringify(schema));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("extractStructuredOutput", () => {
|
describe("extractStructuredOutput", () => {
|
||||||
test("returns structured_output from the stream-json result event", () => {
|
test("returns structured_output from the stream-json result event", () => {
|
||||||
const verdict = { decision: "approve", score: 9 };
|
const verdict = { decision: "approve", score: 9 };
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
#
|
#
|
||||||
# Instead of having the agent write its verdict to a file, this asks Claude for a
|
# Instead of having the agent write its verdict to a file, this asks Claude for a
|
||||||
# result that conforms to a JSON Schema and reads it back from the action output.
|
# result that conforms to a JSON Schema and reads it back from the action output.
|
||||||
# Requires a Claude CLI new enough to support `--json-schema` (see README →
|
# The default-installed Claude CLI (2.1.160) supports `--json-schema`; the
|
||||||
# "Structured output"). If you consume this action through a pre-baked runner
|
# action reads the schema file below and passes it to the CLI inline, so the
|
||||||
# image (path_to_claude_code_executable), that image's CLI must be bumped too.
|
# file-path form works out of the box (see README → "Structured output"). If
|
||||||
|
# you consume this action through a pre-baked runner image
|
||||||
|
# (path_to_claude_code_executable), that image's CLI must still support the
|
||||||
|
# `--json-schema` flag.
|
||||||
|
|
||||||
name: Claude PR Review (structured output)
|
name: Claude PR Review (structured output)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user