52882e1d74
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>
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { describe, test, expect } from "bun:test";
|
|
import {
|
|
prepareRunConfig,
|
|
parseClaudeArgs,
|
|
extractStructuredOutput,
|
|
type ClaudeOptions,
|
|
} from "../src/run-claude";
|
|
|
|
describe("prepareRunConfig", () => {
|
|
test("should prepare config with basic arguments", () => {
|
|
const options: ClaudeOptions = {};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs.slice(0, 4)).toEqual([
|
|
"-p",
|
|
"--verbose",
|
|
"--output-format",
|
|
"stream-json",
|
|
]);
|
|
});
|
|
|
|
test("should include promptPath", () => {
|
|
const options: ClaudeOptions = {};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
|
|
});
|
|
|
|
test("should include allowed tools in command arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
allowedTools: "Bash,Read",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toContain("--allowedTools");
|
|
expect(prepared.claudeArgs).toContain("Bash,Read");
|
|
});
|
|
|
|
test("should include disallowed tools in command arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
disallowedTools: "Bash,Read",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toContain("--disallowedTools");
|
|
expect(prepared.claudeArgs).toContain("Bash,Read");
|
|
});
|
|
|
|
test("should include max turns in command arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
maxTurns: "5",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toContain("--max-turns");
|
|
expect(prepared.claudeArgs).toContain("5");
|
|
});
|
|
|
|
test("should include mcp config in command arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
mcpConfig: "/path/to/mcp-config.json",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toContain("--mcp-config");
|
|
expect(prepared.claudeArgs).toContain("/path/to/mcp-config.json");
|
|
});
|
|
|
|
test("should include system prompt in command arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
systemPrompt: "You are a senior backend engineer.",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toContain("--system-prompt");
|
|
expect(prepared.claudeArgs).toContain("You are a senior backend engineer.");
|
|
});
|
|
|
|
test("should include append system prompt in command arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
appendSystemPrompt:
|
|
"After writing code, be sure to code review yourself.",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toContain("--append-system-prompt");
|
|
expect(prepared.claudeArgs).toContain(
|
|
"After writing code, be sure to code review yourself.",
|
|
);
|
|
});
|
|
|
|
test("should include fallback model in command arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
fallbackModel: "claude-sonnet-4-20250514",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toContain("--fallback-model");
|
|
expect(prepared.claudeArgs).toContain("claude-sonnet-4-20250514");
|
|
});
|
|
|
|
test("should use provided prompt path", () => {
|
|
const options: ClaudeOptions = {};
|
|
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
|
|
|
|
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
|
|
});
|
|
|
|
test("should not include optional arguments when not set", () => {
|
|
const options: ClaudeOptions = {};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).not.toContain("--allowedTools");
|
|
expect(prepared.claudeArgs).not.toContain("--disallowedTools");
|
|
expect(prepared.claudeArgs).not.toContain("--max-turns");
|
|
expect(prepared.claudeArgs).not.toContain("--mcp-config");
|
|
expect(prepared.claudeArgs).not.toContain("--system-prompt");
|
|
expect(prepared.claudeArgs).not.toContain("--append-system-prompt");
|
|
expect(prepared.claudeArgs).not.toContain("--fallback-model");
|
|
});
|
|
|
|
test("should preserve order of claude arguments", () => {
|
|
const options: ClaudeOptions = {
|
|
allowedTools: "Bash,Read",
|
|
maxTurns: "3",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toEqual([
|
|
"-p",
|
|
"--verbose",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--allowedTools",
|
|
"Bash,Read",
|
|
"--max-turns",
|
|
"3",
|
|
]);
|
|
});
|
|
|
|
test("should preserve order with all options including fallback model", () => {
|
|
const options: ClaudeOptions = {
|
|
allowedTools: "Bash,Read",
|
|
disallowedTools: "Write",
|
|
maxTurns: "3",
|
|
mcpConfig: "/path/to/config.json",
|
|
systemPrompt: "You are a helpful assistant",
|
|
appendSystemPrompt: "Be concise",
|
|
fallbackModel: "claude-sonnet-4-20250514",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
|
|
expect(prepared.claudeArgs).toEqual([
|
|
"-p",
|
|
"--verbose",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--allowedTools",
|
|
"Bash,Read",
|
|
"--disallowedTools",
|
|
"Write",
|
|
"--max-turns",
|
|
"3",
|
|
"--mcp-config",
|
|
"/path/to/config.json",
|
|
"--system-prompt",
|
|
"You are a helpful assistant",
|
|
"--append-system-prompt",
|
|
"Be concise",
|
|
"--fallback-model",
|
|
"claude-sonnet-4-20250514",
|
|
]);
|
|
});
|
|
|
|
describe("maxTurns validation", () => {
|
|
test("should accept valid maxTurns value", () => {
|
|
const options: ClaudeOptions = { maxTurns: "5" };
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
expect(prepared.claudeArgs).toContain("--max-turns");
|
|
expect(prepared.claudeArgs).toContain("5");
|
|
});
|
|
|
|
test("should throw error for non-numeric maxTurns", () => {
|
|
const options: ClaudeOptions = { maxTurns: "abc" };
|
|
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
|
"maxTurns must be a positive number, got: abc",
|
|
);
|
|
});
|
|
|
|
test("should throw error for negative maxTurns", () => {
|
|
const options: ClaudeOptions = { maxTurns: "-1" };
|
|
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
|
"maxTurns must be a positive number, got: -1",
|
|
);
|
|
});
|
|
|
|
test("should throw error for zero maxTurns", () => {
|
|
const options: ClaudeOptions = { maxTurns: "0" };
|
|
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
|
"maxTurns must be a positive number, got: 0",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("timeoutMinutes validation", () => {
|
|
test("should accept valid timeoutMinutes value", () => {
|
|
const options: ClaudeOptions = { timeoutMinutes: "15" };
|
|
expect(() =>
|
|
prepareRunConfig("/tmp/test-prompt.txt", options),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("should throw error for non-numeric timeoutMinutes", () => {
|
|
const options: ClaudeOptions = { timeoutMinutes: "abc" };
|
|
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
|
"timeoutMinutes must be a positive number, got: abc",
|
|
);
|
|
});
|
|
|
|
test("should throw error for negative timeoutMinutes", () => {
|
|
const options: ClaudeOptions = { timeoutMinutes: "-5" };
|
|
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
|
"timeoutMinutes must be a positive number, got: -5",
|
|
);
|
|
});
|
|
|
|
test("should throw error for zero timeoutMinutes", () => {
|
|
const options: ClaudeOptions = { timeoutMinutes: "0" };
|
|
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
|
"timeoutMinutes must be a positive number, got: 0",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("custom environment variables", () => {
|
|
test("should parse empty claudeEnv correctly", () => {
|
|
const options: ClaudeOptions = { claudeEnv: "" };
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
expect(prepared.env).toEqual({});
|
|
});
|
|
|
|
test("should parse single environment variable", () => {
|
|
const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123" };
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
expect(prepared.env).toEqual({ API_KEY: "secret123" });
|
|
});
|
|
|
|
test("should parse multiple environment variables", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeEnv: "API_KEY: secret123\nDEBUG: true\nUSER: testuser",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
expect(prepared.env).toEqual({
|
|
API_KEY: "secret123",
|
|
DEBUG: "true",
|
|
USER: "testuser",
|
|
});
|
|
});
|
|
|
|
test("should handle environment variables with spaces around values", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeEnv: "API_KEY: secret123 \n DEBUG : true ",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
expect(prepared.env).toEqual({
|
|
API_KEY: "secret123",
|
|
DEBUG: "true",
|
|
});
|
|
});
|
|
|
|
test("should skip empty lines and comments", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeEnv:
|
|
"API_KEY: secret123\n\n# This is a comment\nDEBUG: true\n# Another comment",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
expect(prepared.env).toEqual({
|
|
API_KEY: "secret123",
|
|
DEBUG: "true",
|
|
});
|
|
});
|
|
|
|
test("should skip lines without colons", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeEnv: "API_KEY: secret123\nINVALID_LINE\nDEBUG: true",
|
|
};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
expect(prepared.env).toEqual({
|
|
API_KEY: "secret123",
|
|
DEBUG: "true",
|
|
});
|
|
});
|
|
|
|
test("should handle undefined claudeEnv", () => {
|
|
const options: ClaudeOptions = {};
|
|
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
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);
|
|
});
|
|
});
|
|
});
|