feat: add claudeArgs input for direct CLI argument passing

- Add claude_args input to action.yml for flexible CLI control
- Parse arguments with industry-standard shell-quote library
- Maintain proper argument order: -p [claudeArgs] [legacy] [BASE_ARGS]
- Keep tag mode defaults (needed for functionality)
- Agent mode has no defaults (full user control)
- Add comprehensive tests for new functionality
- Add example workflow showing usage
This commit is contained in:
km-anthropic
2025-08-07 15:45:17 -07:00
parent e2bdca6133
commit a7759cfcd1
5 changed files with 156 additions and 101 deletions

View File

@@ -10,7 +10,8 @@ const execAsync = promisify(exec);
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`; const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["-p", "--verbose", "--output-format", "stream-json"]; // These base args are always appended at the end
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
export type ClaudeOptions = { export type ClaudeOptions = {
allowedTools?: string; allowedTools?: string;
@@ -68,10 +69,21 @@ export function prepareRunConfig(
promptPath: string, promptPath: string,
options: ClaudeOptions, options: ClaudeOptions,
): PreparedConfig { ): PreparedConfig {
// Start with base args // Build arguments in correct order:
const claudeArgs = [...BASE_ARGS]; // 1. -p flag for prompt via pipe
const claudeArgs = ["-p"];
// Add specific options first (these can be overridden by claudeArgs) // 2. User's custom arguments (can override defaults)
if (options.claudeArgs && options.claudeArgs.trim() !== "") {
const parsed = parseShellArgs(options.claudeArgs);
const customArgs = parsed.filter(
(arg): arg is string => typeof arg === "string",
);
claudeArgs.push(...customArgs);
}
// 3. Legacy specific options for backward compatibility
// These will eventually be removed in favor of claudeArgs
if (options.allowedTools) { if (options.allowedTools) {
claudeArgs.push("--allowedTools", options.allowedTools); claudeArgs.push("--allowedTools", options.allowedTools);
} }
@@ -79,12 +91,6 @@ export function prepareRunConfig(
claudeArgs.push("--disallowedTools", options.disallowedTools); claudeArgs.push("--disallowedTools", options.disallowedTools);
} }
if (options.maxTurns) { if (options.maxTurns) {
const maxTurnsNum = parseInt(options.maxTurns, 10);
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
throw new Error(
`maxTurns must be a positive number, got: ${options.maxTurns}`,
);
}
claudeArgs.push("--max-turns", options.maxTurns); claudeArgs.push("--max-turns", options.maxTurns);
} }
if (options.mcpConfig) { if (options.mcpConfig) {
@@ -102,6 +108,11 @@ export function prepareRunConfig(
if (options.model) { if (options.model) {
claudeArgs.push("--model", options.model); claudeArgs.push("--model", options.model);
} }
// 4. Base args always at the end
claudeArgs.push(...BASE_ARGS);
// Validate timeout if provided (affects process wrapper, not Claude)
if (options.timeoutMinutes) { if (options.timeoutMinutes) {
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10); const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) { if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
@@ -111,15 +122,6 @@ export function prepareRunConfig(
} }
} }
// Parse and append custom arguments (these can override the above)
if (options.claudeArgs && options.claudeArgs.trim() !== "") {
const parsed = parseShellArgs(options.claudeArgs);
const customArgs = parsed.filter(
(arg): arg is string => typeof arg === "string",
);
claudeArgs.push(...customArgs);
}
// Parse custom environment variables // Parse custom environment variables
const customEnv = parseCustomEnvVars(options.claudeEnv); const customEnv = parseCustomEnvVars(options.claudeEnv);

View File

@@ -8,7 +8,7 @@ describe("prepareRunConfig", () => {
const options: ClaudeOptions = {}; const options: ClaudeOptions = {};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs.slice(0, 4)).toEqual([ expect(prepared.claudeArgs).toEqual([
"-p", "-p",
"--verbose", "--verbose",
"--output-format", "--output-format",
@@ -125,13 +125,13 @@ describe("prepareRunConfig", () => {
expect(prepared.claudeArgs).toEqual([ expect(prepared.claudeArgs).toEqual([
"-p", "-p",
"--verbose",
"--output-format",
"stream-json",
"--allowedTools", "--allowedTools",
"Bash,Read", "Bash,Read",
"--max-turns", "--max-turns",
"3", "3",
"--verbose",
"--output-format",
"stream-json",
]); ]);
}); });
@@ -149,9 +149,6 @@ describe("prepareRunConfig", () => {
expect(prepared.claudeArgs).toEqual([ expect(prepared.claudeArgs).toEqual([
"-p", "-p",
"--verbose",
"--output-format",
"stream-json",
"--allowedTools", "--allowedTools",
"Bash,Read", "Bash,Read",
"--disallowedTools", "--disallowedTools",
@@ -166,10 +163,13 @@ describe("prepareRunConfig", () => {
"Be concise", "Be concise",
"--fallback-model", "--fallback-model",
"claude-sonnet-4-20250514", "claude-sonnet-4-20250514",
"--verbose",
"--output-format",
"stream-json",
]); ]);
}); });
describe("maxTurns validation", () => { describe("maxTurns handling", () => {
test("should accept valid maxTurns value", () => { test("should accept valid maxTurns value", () => {
const options: ClaudeOptions = { maxTurns: "5" }; const options: ClaudeOptions = { maxTurns: "5" };
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
@@ -177,25 +177,28 @@ describe("prepareRunConfig", () => {
expect(prepared.claudeArgs).toContain("5"); expect(prepared.claudeArgs).toContain("5");
}); });
test("should throw error for non-numeric maxTurns", () => { test("should pass through non-numeric maxTurns without validation (v1.0)", () => {
const options: ClaudeOptions = { maxTurns: "abc" }; const options: ClaudeOptions = { maxTurns: "abc" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
"maxTurns must be a positive number, got: abc", // v1.0: No validation - let Claude handle it
); expect(prepared.claudeArgs).toContain("--max-turns");
expect(prepared.claudeArgs).toContain("abc");
}); });
test("should throw error for negative maxTurns", () => { test("should pass through negative maxTurns without validation (v1.0)", () => {
const options: ClaudeOptions = { maxTurns: "-1" }; const options: ClaudeOptions = { maxTurns: "-1" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
"maxTurns must be a positive number, got: -1", // v1.0: No validation - let Claude handle it
); expect(prepared.claudeArgs).toContain("--max-turns");
expect(prepared.claudeArgs).toContain("-1");
}); });
test("should throw error for zero maxTurns", () => { test("should pass through zero maxTurns without validation (v1.0)", () => {
const options: ClaudeOptions = { maxTurns: "0" }; const options: ClaudeOptions = { maxTurns: "0" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
"maxTurns must be a positive number, got: 0", // v1.0: No validation - let Claude handle it
); expect(prepared.claudeArgs).toContain("--max-turns");
expect(prepared.claudeArgs).toContain("0");
}); });
}); });
@@ -229,6 +232,75 @@ describe("prepareRunConfig", () => {
}); });
}); });
describe("claudeArgs handling", () => {
test("should parse and include custom claude arguments", () => {
const options: ClaudeOptions = {
claudeArgs: "--max-turns 10 --model claude-3-opus-20240229",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toEqual([
"-p",
"--max-turns",
"10",
"--model",
"claude-3-opus-20240229",
"--verbose",
"--output-format",
"stream-json",
]);
});
test("should handle claudeArgs with legacy options", () => {
const options: ClaudeOptions = {
claudeArgs: "--max-turns 10",
allowedTools: "Bash,Read",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toEqual([
"-p",
"--max-turns",
"10",
"--allowedTools",
"Bash,Read",
"--verbose",
"--output-format",
"stream-json",
]);
});
test("should handle empty claudeArgs", () => {
const options: ClaudeOptions = {
claudeArgs: "",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toEqual([
"-p",
"--verbose",
"--output-format",
"stream-json",
]);
});
test("should handle claudeArgs with quoted strings", () => {
const options: ClaudeOptions = {
claudeArgs: '--system-prompt "You are a helpful assistant"',
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toEqual([
"-p",
"--system-prompt",
"You are a helpful assistant",
"--verbose",
"--output-format",
"stream-json",
]);
});
});
describe("custom environment variables", () => { describe("custom environment variables", () => {
test("should parse empty claudeEnv correctly", () => { test("should parse empty claudeEnv correctly", () => {
const options: ClaudeOptions = { claudeEnv: "" }; const options: ClaudeOptions = { claudeEnv: "" };

View File

@@ -1,39 +1,31 @@
name: Claude with Custom Args Example name: Claude Args Example
on: on:
pull_request:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
prompt: prompt:
description: "Prompt for Claude" description: 'Prompt for Claude'
required: false required: true
default: "Respond with a joke" type: string
jobs: jobs:
claude-custom: claude-with-custom-args:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: anthropics/claude-code-action@v1 - uses: actions/checkout@v4
- name: Run Claude with custom arguments
uses: anthropics/claude-code-action@v1
with: with:
mode: agent
prompt: ${{ github.event.inputs.prompt }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Option 1: Simple prompt with custom args # New claudeArgs input allows direct CLI argument control
prompt: "Review this code and provide feedback" # Arguments are passed in the order: -p [claudeArgs] [legacy options] [BASE_ARGS]
claude_args: "--max-turns 3 --model claude-3-5-sonnet-latest" claude_args: |
--max-turns 15
# Option 2: Slash command with custom MCP config --model claude-3-opus-20240229
# prompt: "/review" --allowedTools Edit,Read,Write,Bash
# claude_args: "--mcp-config /workspace/custom-mcp.json --max-turns 5" --disallowedTools WebSearch
--system-prompt "You are a senior engineer focused on code quality"
# Option 3: Override output format and add custom system prompt
# prompt: "Fix the failing tests"
# claude_args: "--output-format json --system-prompt 'You are an expert test fixer'"
# How it works:
# The action will execute: claude -p --verbose --output-format stream-json [your claude_args]
# Where the prompt is piped via stdin
# Benefits:
# - Full control over Claude CLI arguments
# - Use any Claude feature without waiting for action updates
# - Override defaults when needed
# - Combine with existing inputs or use standalone

View File

@@ -23,6 +23,7 @@ import { GITHUB_SERVER_URL } from "../github/api/config";
import type { Mode, ModeContext } from "../modes/types"; import type { Mode, ModeContext } from "../modes/types";
export type { CommonFields, PreparedContext } from "./types"; export type { CommonFields, PreparedContext } from "./types";
// Tag mode defaults - these tools are needed for tag mode to function
const BASE_ALLOWED_TOOLS = [ const BASE_ALLOWED_TOOLS = [
"Edit", "Edit",
"MultiEdit", "MultiEdit",
@@ -32,16 +33,16 @@ const BASE_ALLOWED_TOOLS = [
"Read", "Read",
"Write", "Write",
]; ];
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
export function buildAllowedToolsString( export function buildAllowedToolsString(
customAllowedTools?: string[], customAllowedTools?: string[],
includeActionsTools: boolean = false, includeActionsTools: boolean = false,
useCommitSigning: boolean = false, useCommitSigning: boolean = false,
): string { ): string {
// Tag mode needs these tools to function properly
let baseTools = [...BASE_ALLOWED_TOOLS]; let baseTools = [...BASE_ALLOWED_TOOLS];
// Always include the comment update tool from the comment server // Always include the comment update tool for tag mode
baseTools.push("mcp__github_comment__update_claude_comment"); baseTools.push("mcp__github_comment__update_claude_comment");
// Add commit signing tools if enabled // Add commit signing tools if enabled
@@ -51,7 +52,7 @@ export function buildAllowedToolsString(
"mcp__github_file_ops__delete_files", "mcp__github_file_ops__delete_files",
); );
} else { } else {
// When not using commit signing, add specific Bash git commands only // When not using commit signing, add specific Bash git commands
baseTools.push( baseTools.push(
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
@@ -83,9 +84,10 @@ export function buildDisallowedToolsString(
customDisallowedTools?: string[], customDisallowedTools?: string[],
allowedTools?: string[], allowedTools?: string[],
): string { ): string {
let disallowedTools = [...DISALLOWED_TOOLS]; // Tag mode: Disable WebSearch and WebFetch by default for security
let disallowedTools = ["WebSearch", "WebFetch"];
// If user has explicitly allowed some hardcoded disallowed tools, remove them from disallowed list // If user has explicitly allowed some default disallowed tools, remove them
if (allowedTools && allowedTools.length > 0) { if (allowedTools && allowedTools.length > 0) {
disallowedTools = disallowedTools.filter( disallowedTools = disallowedTools.filter(
(tool) => !allowedTools.includes(tool), (tool) => !allowedTools.includes(tool),

View File

@@ -58,28 +58,15 @@ export const agentMode: Mode = {
promptContent, promptContent,
); );
// Export tool environment variables for agent mode // Agent mode: User has full control via claudeArgs or legacy inputs
const baseTools = [ // No default tools are enforced - Claude Code's defaults will apply
"Edit", // Export user-specified tools only if provided
"MultiEdit", if (context.inputs.allowedTools.length > 0) {
"Glob", core.exportVariable("INPUT_ALLOWED_TOOLS", context.inputs.allowedTools.join(","));
"Grep", }
"LS", if (context.inputs.disallowedTools.length > 0) {
"Read", core.exportVariable("INPUT_DISALLOWED_TOOLS", context.inputs.disallowedTools.join(","));
"Write", }
];
// Add user-specified tools
const allowedTools = [...baseTools, ...context.inputs.allowedTools];
const disallowedTools = [
"WebSearch",
"WebFetch",
...context.inputs.disallowedTools,
];
// Export as INPUT_ prefixed variables for the base action
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
// Agent mode uses a minimal MCP configuration // Agent mode uses a minimal MCP configuration
// We don't need comment servers or PR-specific tools for automation // We don't need comment servers or PR-specific tools for automation