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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user