mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
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:
@@ -10,7 +10,8 @@ const execAsync = promisify(exec);
|
||||
|
||||
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
|
||||
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 = {
|
||||
allowedTools?: string;
|
||||
@@ -68,10 +69,21 @@ export function prepareRunConfig(
|
||||
promptPath: string,
|
||||
options: ClaudeOptions,
|
||||
): PreparedConfig {
|
||||
// Start with base args
|
||||
const claudeArgs = [...BASE_ARGS];
|
||||
// Build arguments in correct order:
|
||||
// 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) {
|
||||
claudeArgs.push("--allowedTools", options.allowedTools);
|
||||
}
|
||||
@@ -79,12 +91,6 @@ export function prepareRunConfig(
|
||||
claudeArgs.push("--disallowedTools", options.disallowedTools);
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (options.mcpConfig) {
|
||||
@@ -102,6 +108,11 @@ export function prepareRunConfig(
|
||||
if (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) {
|
||||
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
|
||||
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
|
||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("prepareRunConfig", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs.slice(0, 4)).toEqual([
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
@@ -125,13 +125,13 @@ describe("prepareRunConfig", () => {
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--allowedTools",
|
||||
"Bash,Read",
|
||||
"--max-turns",
|
||||
"3",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -149,9 +149,6 @@ describe("prepareRunConfig", () => {
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--allowedTools",
|
||||
"Bash,Read",
|
||||
"--disallowedTools",
|
||||
@@ -166,10 +163,13 @@ describe("prepareRunConfig", () => {
|
||||
"Be concise",
|
||||
"--fallback-model",
|
||||
"claude-sonnet-4-20250514",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("maxTurns validation", () => {
|
||||
describe("maxTurns handling", () => {
|
||||
test("should accept valid maxTurns value", () => {
|
||||
const options: ClaudeOptions = { maxTurns: "5" };
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
@@ -177,25 +177,28 @@ describe("prepareRunConfig", () => {
|
||||
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" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"maxTurns must be a positive number, got: abc",
|
||||
);
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
// 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" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"maxTurns must be a positive number, got: -1",
|
||||
);
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
// 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" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"maxTurns must be a positive number, got: 0",
|
||||
);
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
// 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", () => {
|
||||
test("should parse empty claudeEnv correctly", () => {
|
||||
const options: ClaudeOptions = { claudeEnv: "" };
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
name: Claude with Custom Args Example
|
||||
name: Claude Args Example
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prompt:
|
||||
description: "Prompt for Claude"
|
||||
required: false
|
||||
default: "Respond with a joke"
|
||||
description: 'Prompt for Claude'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-custom:
|
||||
claude-with-custom-args:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Claude with custom arguments
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
mode: agent
|
||||
prompt: ${{ github.event.inputs.prompt }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Option 1: Simple prompt with custom args
|
||||
prompt: "Review this code and provide feedback"
|
||||
claude_args: "--max-turns 3 --model claude-3-5-sonnet-latest"
|
||||
|
||||
# Option 2: Slash command with custom MCP config
|
||||
# prompt: "/review"
|
||||
# claude_args: "--mcp-config /workspace/custom-mcp.json --max-turns 5"
|
||||
|
||||
# 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
|
||||
# New claudeArgs input allows direct CLI argument control
|
||||
# Arguments are passed in the order: -p [claudeArgs] [legacy options] [BASE_ARGS]
|
||||
claude_args: |
|
||||
--max-turns 15
|
||||
--model claude-3-opus-20240229
|
||||
--allowedTools Edit,Read,Write,Bash
|
||||
--disallowedTools WebSearch
|
||||
--system-prompt "You are a senior engineer focused on code quality"
|
||||
@@ -23,6 +23,7 @@ import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import type { Mode, ModeContext } from "../modes/types";
|
||||
export type { CommonFields, PreparedContext } from "./types";
|
||||
|
||||
// Tag mode defaults - these tools are needed for tag mode to function
|
||||
const BASE_ALLOWED_TOOLS = [
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
@@ -32,16 +33,16 @@ const BASE_ALLOWED_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
];
|
||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||
|
||||
export function buildAllowedToolsString(
|
||||
customAllowedTools?: string[],
|
||||
includeActionsTools: boolean = false,
|
||||
useCommitSigning: boolean = false,
|
||||
): string {
|
||||
// Tag mode needs these tools to function properly
|
||||
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");
|
||||
|
||||
// Add commit signing tools if enabled
|
||||
@@ -51,7 +52,7 @@ export function buildAllowedToolsString(
|
||||
"mcp__github_file_ops__delete_files",
|
||||
);
|
||||
} else {
|
||||
// When not using commit signing, add specific Bash git commands only
|
||||
// When not using commit signing, add specific Bash git commands
|
||||
baseTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
@@ -83,9 +84,10 @@ export function buildDisallowedToolsString(
|
||||
customDisallowedTools?: string[],
|
||||
allowedTools?: 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) {
|
||||
disallowedTools = disallowedTools.filter(
|
||||
(tool) => !allowedTools.includes(tool),
|
||||
|
||||
@@ -58,28 +58,15 @@ export const agentMode: Mode = {
|
||||
promptContent,
|
||||
);
|
||||
|
||||
// Export tool environment variables for agent mode
|
||||
const baseTools = [
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Read",
|
||||
"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: User has full control via claudeArgs or legacy inputs
|
||||
// No default tools are enforced - Claude Code's defaults will apply
|
||||
// Export user-specified tools only if provided
|
||||
if (context.inputs.allowedTools.length > 0) {
|
||||
core.exportVariable("INPUT_ALLOWED_TOOLS", context.inputs.allowedTools.join(","));
|
||||
}
|
||||
if (context.inputs.disallowedTools.length > 0) {
|
||||
core.exportVariable("INPUT_DISALLOWED_TOOLS", context.inputs.disallowedTools.join(","));
|
||||
}
|
||||
|
||||
// Agent mode uses a minimal MCP configuration
|
||||
// We don't need comment servers or PR-specific tools for automation
|
||||
|
||||
Reference in New Issue
Block a user