fix: inline --json-schema file paths so structured output works on CLI 2.1.160 (closes #3)

The docs recommend the file-path form (`--json-schema /path/to/schema.json`)
as primary, but the default-pinned Claude CLI 2.1.160 only accepts an inline
JSON string. Passing a path makes the CLI exit 0 with empty output, so the
action's fail-loud branch trips on every run.

Resolve the value before spawning the CLI: a file-path schema is read
in-process and passed inline (validated + compacted), while an already-inline
schema is left untouched. Because the value is handed to spawn() as an argv
element (never through a shell), a schema with `$ref`/`$defs` round-trips
intact. Unreadable files and invalid JSON now fail with a clear error instead
of the CLI's silent empty output.

Docs (README, example workflow, action.yml) are corrected to explain that the
file-path form is auto-inlined and works on the default CLI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 10:45:40 +10:00
parent 13cde95d51
commit d71f4db3d7
5 changed files with 212 additions and 14 deletions
+119 -5
View File
@@ -1,9 +1,13 @@
#!/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 {
prepareRunConfig,
parseClaudeArgs,
inlineJsonSchemaArgs,
extractStructuredOutput,
type ClaudeOptions,
} from "../src/run-claude";
@@ -336,7 +340,7 @@ describe("prepareRunConfig", () => {
test("claude_args tokens are appended after the managed arguments", () => {
const prepared = prepareRunConfig("/tmp/test-prompt.txt", {
maxTurns: "3",
claudeArgs: "--output-format json --json-schema /tmp/schema.json",
claudeArgs: `--output-format json --json-schema '{"type":"object"}'`,
});
expect(prepared.claudeArgs).toEqual([
@@ -349,7 +353,7 @@ describe("prepareRunConfig", () => {
"--output-format",
"json",
"--json-schema",
"/tmp/schema.json",
'{"type":"object"}',
]);
expect(prepared.hasJsonSchema).toBe(true);
});
@@ -370,12 +374,12 @@ describe("prepareRunConfig", () => {
test("hasJsonSchema is detected for both space and equals forms", () => {
expect(
prepareRunConfig("/tmp/test-prompt.txt", {
claudeArgs: "--json-schema /tmp/s.json",
claudeArgs: `--json-schema '{"type":"object"}'`,
}).hasJsonSchema,
).toBe(true);
expect(
prepareRunConfig("/tmp/test-prompt.txt", {
claudeArgs: "--json-schema=/tmp/s.json",
claudeArgs: `--json-schema='{"type":"object"}'`,
}).hasJsonSchema,
).toBe(true);
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", () => {
test("returns structured_output from the stream-json result event", () => {
const verdict = { decision: "approve", score: 9 };