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:
@@ -55,6 +55,10 @@ inputs:
|
||||
description: "Custom environment variables to pass to Claude Code execution (YAML multiline format)"
|
||||
required: false
|
||||
default: ""
|
||||
claude_args:
|
||||
description: "Additional arguments to pass directly to the Claude CLI (e.g. '--json-schema /path/to/schema.json'). Forwarded verbatim after the action-managed arguments."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Action settings
|
||||
timeout_minutes:
|
||||
@@ -92,6 +96,9 @@ outputs:
|
||||
execution_file:
|
||||
description: "Path to the JSON file containing Claude Code execution log"
|
||||
value: ${{ steps.run_claude.outputs.execution_file }}
|
||||
structured_output:
|
||||
description: "JSON string of the schema-validated result when --json-schema is provided in claude_args (parse with fromJSON() or jq)"
|
||||
value: ${{ steps.run_claude.outputs.structured_output }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -143,6 +150,7 @@ runs:
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
@@ -31,12 +34,16 @@
|
||||
|
||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
|
||||
"@types/shell-quote": ["@types/shell-quote@1.7.5", "", {}, "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1"
|
||||
"@actions/core": "^1.10.1",
|
||||
"shell-quote": "^1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ async function run() {
|
||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { promisify } from "util";
|
||||
import { unlink, writeFile, stat } from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { parse as parseShellArgs } from "shell-quote";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -22,14 +23,81 @@ export type ClaudeOptions = {
|
||||
fallbackModel?: string;
|
||||
timeoutMinutes?: string;
|
||||
model?: string;
|
||||
// Raw, additional arguments forwarded verbatim to the Claude CLI. Mirrors the
|
||||
// upstream v1 `claude_args` passthrough so downstream workflows can use newer
|
||||
// CLI flags (e.g. `--json-schema`) without this fork enumerating each one.
|
||||
claudeArgs?: string;
|
||||
};
|
||||
|
||||
type PreparedConfig = {
|
||||
claudeArgs: string[];
|
||||
promptPath: string;
|
||||
env: Record<string, string>;
|
||||
// True when the forwarded claude_args request schema-validated output
|
||||
// (`--json-schema`), which means we must surface `structured_output`.
|
||||
hasJsonSchema: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip full comment lines (first non-whitespace char is `#`) from a shell
|
||||
* argument string before tokenizing. Inline `#` inside a quoted value is left
|
||||
* untouched — shell-quote handles quoting. Ported from upstream
|
||||
* claude-code-base-action's claude_args parsing.
|
||||
*/
|
||||
function stripShellComments(input: string): string {
|
||||
return input
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the raw `claude_args` string into individual CLI argument tokens
|
||||
* using shell-quote, so quoted values (e.g. `--json-schema '{"$ref": ...}'`)
|
||||
* survive as a single token. Non-string tokens (shell operators/globs) are
|
||||
* dropped, matching upstream behavior. Returns [] for empty/whitespace input.
|
||||
*/
|
||||
export function parseClaudeArgs(claudeArgs?: string): string[] {
|
||||
if (!claudeArgs?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return parseShellArgs(stripShellComments(claudeArgs)).filter(
|
||||
(arg): arg is string => typeof arg === "string",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* final `{"type":"result", ...}` event carries a `structured_output` field when
|
||||
* `--json-schema` was supplied. We scan from the end (the result is last) and
|
||||
* return the first `structured_output` we find. Returns undefined when absent.
|
||||
*/
|
||||
export function extractStructuredOutput(output: string): unknown {
|
||||
const lines = output.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]?.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // Not a JSON line (e.g. human-readable log output)
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"structured_output" in parsed &&
|
||||
(parsed as { structured_output?: unknown }).structured_output != null
|
||||
) {
|
||||
return (parsed as { structured_output: unknown }).structured_output;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
|
||||
if (!claudeEnv || claudeEnv.trim() === "") {
|
||||
return {};
|
||||
@@ -107,6 +175,16 @@ 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);
|
||||
claudeArgs.push(...extraArgs);
|
||||
|
||||
const hasJsonSchema = extraArgs.some(
|
||||
(arg) => arg === "--json-schema" || arg.startsWith("--json-schema="),
|
||||
);
|
||||
|
||||
// Parse custom environment variables
|
||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||
|
||||
@@ -114,6 +192,7 @@ export function prepareRunConfig(
|
||||
claudeArgs,
|
||||
promptPath,
|
||||
env: customEnv,
|
||||
hasJsonSchema,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,8 +388,30 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
core.warning(`Failed to process output for execution metrics: ${e}`);
|
||||
}
|
||||
|
||||
core.setOutput("conclusion", "success");
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
|
||||
// When claude_args requested schema-validated output, surface it as the
|
||||
// `structured_output` action output. Fail loudly (rather than emit empty)
|
||||
// if the schema was requested but no structured result came back, matching
|
||||
// upstream claude-code-base-action behavior.
|
||||
if (config.hasJsonSchema) {
|
||||
const structuredOutput = extractStructuredOutput(output);
|
||||
if (structuredOutput == null) {
|
||||
core.setOutput("conclusion", "failure");
|
||||
core.setFailed(
|
||||
"--json-schema was provided in claude_args but Claude did not return structured_output.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
core.setOutput("structured_output", JSON.stringify(structuredOutput));
|
||||
const fieldCount =
|
||||
typeof structuredOutput === "object"
|
||||
? Object.keys(structuredOutput as object).length
|
||||
: 0;
|
||||
core.info(`Set structured_output with ${fieldCount} field(s)`);
|
||||
}
|
||||
|
||||
core.setOutput("conclusion", "success");
|
||||
} else {
|
||||
core.setOutput("conclusion", "failure");
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user