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
+1
View File
@@ -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}`);
+102 -1
View File
@@ -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");