From d71f4db3d76b1c9c43b33f4a258284479ca9a55a Mon Sep 17 00:00:00 2001 From: Ryan Alavi Date: Wed, 3 Jun 2026 10:45:40 +1000 Subject: [PATCH] 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) --- README.md | 5 +- action.yml | 2 +- base-action/src/run-claude.ts | 86 ++++++++++++++++++- base-action/test/run-claude.test.ts | 124 +++++++++++++++++++++++++-- examples/gitea-structured-output.yml | 9 +- 5 files changed, 212 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index cfe0e51..22f0e91 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,10 @@ jobs: 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. -- **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 diff --git a/action.yml b/action.yml index 8f573c7..f8c4fc9 100644 --- a/action.yml +++ b/action.yml @@ -82,7 +82,7 @@ inputs: required: false default: "" 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 default: "" diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 3011a5e..83f4dfa 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -2,7 +2,7 @@ import * as core from "@actions/core"; import { exec } from "child_process"; import { promisify } from "util"; import { unlink, writeFile, stat } from "fs/promises"; -import { createWriteStream } from "fs"; +import { createWriteStream, readFileSync } from "fs"; import { spawn } from "child_process"; 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 ` and `--json-schema=` + * 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 * `--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 // action manages. Empty/unset claude_args leaves the arg list byte-identical - // to before this feature, preserving behavior for existing callers. - const extraArgs = parseClaudeArgs(options.claudeArgs); + // to before this feature, preserving behavior for existing callers. A + // 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); const hasJsonSchema = extraArgs.some( diff --git a/base-action/test/run-claude.test.ts b/base-action/test/run-claude.test.ts index 2d193bd..b7b3cf2 100644 --- a/base-action/test/run-claude.test.ts +++ b/base-action/test/run-claude.test.ts @@ -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 }; diff --git a/examples/gitea-structured-output.yml b/examples/gitea-structured-output.yml index 1b8b3a1..faaf664 100644 --- a/examples/gitea-structured-output.yml +++ b/examples/gitea-structured-output.yml @@ -2,9 +2,12 @@ # # 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. -# Requires a Claude CLI new enough to support `--json-schema` (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 be bumped too. +# The default-installed Claude CLI (2.1.160) supports `--json-schema`; the +# action reads the schema file below and passes it to the CLI inline, so the +# 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) -- 2.52.0