From a7759cfcd135d04672b6fea65788aa9e5902364f Mon Sep 17 00:00:00 2001 From: km-anthropic Date: Thu, 7 Aug 2025 15:45:17 -0700 Subject: [PATCH] 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 --- base-action/src/run-claude.ts | 42 ++++++----- base-action/test/run-claude.test.ts | 112 +++++++++++++++++++++++----- claude-args-example.yml | 48 +++++------- src/create-prompt/index.ts | 24 +++--- src/modes/agent/index.ts | 31 +++----- 5 files changed, 156 insertions(+), 101 deletions(-) diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 5625990..8caeeda 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -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]; - - // Add specific options first (these can be overridden by claudeArgs) + // Build arguments in correct order: + // 1. -p flag for prompt via pipe + const claudeArgs = ["-p"]; + + // 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); diff --git a/base-action/test/run-claude.test.ts b/base-action/test/run-claude.test.ts index 7dcfb18..8e0728c 100644 --- a/base-action/test/run-claude.test.ts +++ b/base-action/test/run-claude.test.ts @@ -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: "" }; diff --git a/claude-args-example.yml b/claude-args-example.yml index 27d1b76..9e9bc40 100644 --- a/claude-args-example.yml +++ b/claude-args-example.yml @@ -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" \ No newline at end of file diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 66fe583..f140ff1 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -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,18 +33,18 @@ 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 if (useCommitSigning) { baseTools.push( @@ -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:*)", @@ -62,7 +63,7 @@ export function buildAllowedToolsString( "Bash(git rm:*)", ); } - + // Add GitHub Actions MCP tools if enabled if (includeActionsTools) { baseTools.push( @@ -71,7 +72,7 @@ export function buildAllowedToolsString( "mcp__github_ci__download_job_log", ); } - + let allAllowedTools = baseTools.join(","); if (customAllowedTools && customAllowedTools.length > 0) { allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`; @@ -83,15 +84,16 @@ export function buildDisallowedToolsString( customDisallowedTools?: string[], allowedTools?: string[], ): string { - let disallowedTools = [...DISALLOWED_TOOLS]; - - // If user has explicitly allowed some hardcoded disallowed tools, remove them from disallowed list + // Tag mode: Disable WebSearch and WebFetch by default for security + let disallowedTools = ["WebSearch", "WebFetch"]; + + // If user has explicitly allowed some default disallowed tools, remove them if (allowedTools && allowedTools.length > 0) { disallowedTools = disallowedTools.filter( (tool) => !allowedTools.includes(tool), ); } - + let allDisallowedTools = disallowedTools.join(","); if (customDisallowedTools && customDisallowedTools.length > 0) { if (allDisallowedTools) { diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index a4ab49e..2e866df 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -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