feat: add claude_args passthrough and structured_output (closes #1)

Bring the upstream v1 `claude_args` input and `structured_output` output
into this Gitea fork so downstream workflows can run the agent with
arbitrary Claude CLI flags (notably `--json-schema`) and read the
schema-validated verdict back as an action output, without an
agent-written file.

Approach (issue Option B, ported rather than bumped): the production
"Run Claude Code" step previously delegated to the external
anthropics/claude-code-base-action@v0.0.63, which has neither input.
Upstream base-action main has them but has migrated to the Claude Agent
SDK — a wholesale divergence that would change v0.0.63 behavior every
downstream step relies on. Instead this invokes the vendored base-action
directly via `bun run ${github.action_path}/base-action/src/index.ts`
(the same github.action_path pattern prepare.ts already uses, reliable
on Gitea), and ports only the two features onto the v0.0.63 process-spawn
engine.

- base-action/src/run-claude.ts: tokenize claude_args with shell-quote
  (comment-line stripping; quoted/file-path schemas survive intact) and
  append after the byte-identical BASE_ARGS, so empty claude_args yields
  an unchanged arg list; extract structured_output from the stream-json
  result event when --json-schema is present, failing loudly if absent.
- base-action/src/index.ts: forward INPUT_CLAUDE_ARGS.
- base-action/action.yml: add claude_args input + structured_output output.
- action.yml: add claude_args input + structured_output output; replace
  the external base-action delegation with a direct bun-run of the
  vendored copy (full INPUT_*/provider env contract derived from the
  vendored validate-env/index); bump the default Claude CLI install to
  2.1.160 (--json-schema requires a newer CLI than 1.0.117).
- shell-quote added to both package.json files; root bun.lock reconciled
  (it was stale vs package.json — non-frozen installs already resolved
  the newer tree).
- Tests for tokenizer, hasJsonSchema, and structured-output extraction.
- README: inputs/outputs tables, "Structured output" + "Version pins".
- examples/gitea-structured-output.yml.

Follow-up (cannot be done from this repo): consumers using a pre-baked
runner image (path_to_claude_code_executable) must rebuild that image
with a --json-schema-capable Claude CLI and restart the runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 22:22:51 +10:00
parent 92631f4d12
commit 52882e1d74
11 changed files with 482 additions and 97 deletions
+129 -1
View File
@@ -1,7 +1,12 @@
#!/usr/bin/env bun
import { describe, test, expect } from "bun:test";
import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude";
import {
prepareRunConfig,
parseClaudeArgs,
extractStructuredOutput,
type ClaudeOptions,
} from "../src/run-claude";
describe("prepareRunConfig", () => {
test("should prepare config with basic arguments", () => {
@@ -294,4 +299,127 @@ describe("prepareRunConfig", () => {
expect(prepared.env).toEqual({});
});
});
describe("claude_args passthrough", () => {
test("empty/undefined claude_args leaves the arg list byte-identical", () => {
const baseline = prepareRunConfig("/tmp/test-prompt.txt", {
allowedTools: "Bash,Read",
maxTurns: "3",
});
const withEmpty = prepareRunConfig("/tmp/test-prompt.txt", {
allowedTools: "Bash,Read",
maxTurns: "3",
claudeArgs: "",
});
const withWhitespace = prepareRunConfig("/tmp/test-prompt.txt", {
allowedTools: "Bash,Read",
maxTurns: "3",
claudeArgs: " \n ",
});
const expected = [
"-p",
"--verbose",
"--output-format",
"stream-json",
"--allowedTools",
"Bash,Read",
"--max-turns",
"3",
];
expect(baseline.claudeArgs).toEqual(expected);
expect(withEmpty.claudeArgs).toEqual(expected);
expect(withWhitespace.claudeArgs).toEqual(expected);
expect(baseline.hasJsonSchema).toBe(false);
});
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",
});
expect(prepared.claudeArgs).toEqual([
"-p",
"--verbose",
"--output-format",
"stream-json",
"--max-turns",
"3",
"--output-format",
"json",
"--json-schema",
"/tmp/schema.json",
]);
expect(prepared.hasJsonSchema).toBe(true);
});
test("a single-quoted inline JSON schema survives as one token (incl. $ref)", () => {
const schema = '{"$ref":"#/$defs/Verdict","type":"object"}';
const tokens = parseClaudeArgs(`--json-schema '${schema}'`);
expect(tokens).toEqual(["--json-schema", schema]);
});
test("comment lines are stripped before tokenizing", () => {
const tokens = parseClaudeArgs(
"# leading comment\n--max-turns 5\n # indented comment\n--verbose",
);
expect(tokens).toEqual(["--max-turns", "5", "--verbose"]);
});
test("hasJsonSchema is detected for both space and equals forms", () => {
expect(
prepareRunConfig("/tmp/test-prompt.txt", {
claudeArgs: "--json-schema /tmp/s.json",
}).hasJsonSchema,
).toBe(true);
expect(
prepareRunConfig("/tmp/test-prompt.txt", {
claudeArgs: "--json-schema=/tmp/s.json",
}).hasJsonSchema,
).toBe(true);
expect(
prepareRunConfig("/tmp/test-prompt.txt", {
claudeArgs: "--max-turns 3",
}).hasJsonSchema,
).toBe(false);
});
});
describe("extractStructuredOutput", () => {
test("returns structured_output from the stream-json result event", () => {
const verdict = { decision: "approve", score: 9 };
const output = [
JSON.stringify({ type: "system", subtype: "init" }),
JSON.stringify({ type: "assistant", message: { content: [] } }),
JSON.stringify({
type: "result",
subtype: "success",
is_error: false,
structured_output: verdict,
}),
].join("\n");
expect(extractStructuredOutput(output)).toEqual(verdict);
});
test("returns undefined when no structured_output is present", () => {
const output = [
JSON.stringify({ type: "system", subtype: "init" }),
JSON.stringify({ type: "result", subtype: "success", is_error: false }),
].join("\n");
expect(extractStructuredOutput(output)).toBeUndefined();
});
test("ignores non-JSON lines and trailing whitespace", () => {
const verdict = { ok: true };
const output = `Running Claude...\n${JSON.stringify({
type: "result",
structured_output: verdict,
})}\n\n`;
expect(extractStructuredOutput(output)).toEqual(verdict);
});
});
});