diff --git a/action.yml b/action.yml index 6b7f283..717379f 100644 --- a/action.yml +++ b/action.yml @@ -89,6 +89,10 @@ inputs: description: "Timeout in minutes for execution" required: false default: "30" + claude_args: + description: "Additional arguments to pass directly to Claude CLI" + required: false + default: "" use_sticky_comment: description: "Use just one comment to deliver issue/PR comments" required: false @@ -193,6 +197,7 @@ runs: INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} + INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands # Model configuration diff --git a/base-action/action.yml b/base-action/action.yml index a9d626a..cb39102 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -61,6 +61,10 @@ inputs: description: "Timeout in minutes for Claude Code execution" required: false default: "10" + claude_args: + description: "Additional arguments to pass directly to Claude CLI (e.g., '--max-turns 3 --mcp-config /path/to/config.json')" + required: false + default: "" experimental_slash_commands_dir: description: "Experimental: Directory containing slash command files to install" required: false diff --git a/base-action/src/index.ts b/base-action/src/index.ts index f4d3724..8a47de7 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -31,6 +31,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}`); diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 70e38d7..763beb0 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -22,6 +22,7 @@ export type ClaudeOptions = { fallbackModel?: string; timeoutMinutes?: string; model?: string; + claudeArgs?: string; }; type PreparedConfig = { @@ -30,6 +31,67 @@ type PreparedConfig = { env: Record; }; +/** + * Parse shell-style arguments string into array. + * Handles quoted strings and escaping. + */ +function parseShellArgs(argsString?: string): string[] { + if (!argsString || argsString.trim() === "") { + return []; + } + + const args: string[] = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + let escapeNext = false; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (escapeNext) { + current += char; + escapeNext = false; + continue; + } + + if (char === "\\") { + if (inSingleQuote) { + current += char; + } else { + escapeNext = true; + } + continue; + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (char === " " && !inSingleQuote && !inDoubleQuote) { + if (current) { + args.push(current); + current = ""; + } + continue; + } + + current += char; + } + + if (current) { + args.push(current); + } + + return args; +} + function parseCustomEnvVars(claudeEnv?: string): Record { if (!claudeEnv || claudeEnv.trim() === "") { return {}; @@ -66,8 +128,10 @@ 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) if (options.allowedTools) { claudeArgs.push("--allowedTools", options.allowedTools); } @@ -107,6 +171,19 @@ export function prepareRunConfig( } } + // Parse and append custom arguments (these can override the above) + if (options.claudeArgs) { + const customArgs = parseShellArgs(options.claudeArgs); + // Filter out -p flag since we handle prompt via pipe + const filteredArgs = customArgs.filter( + (arg, index) => + arg !== "-p" && + arg !== "--prompt" && + (index === 0 || (customArgs[index - 1] !== "-p" && customArgs[index - 1] !== "--prompt")), + ); + claudeArgs.push(...filteredArgs); + } + // Parse custom environment variables const customEnv = parseCustomEnvVars(options.claudeEnv); @@ -147,8 +224,14 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { console.log(`Custom environment variables: ${envKeys}`); } + // Log custom arguments if any + if (options.claudeArgs && options.claudeArgs.trim() !== "") { + console.log(`Custom Claude arguments: ${options.claudeArgs}`); + } + // Output to console console.log(`Running Claude with prompt from file: ${config.promptPath}`); + console.log(`Full command: claude ${config.claudeArgs.join(" ")}`); // Start sending prompt to pipe in background const catProcess = spawn("cat", [config.promptPath], { diff --git a/base-action/test/parse-shell-args.test.ts b/base-action/test/parse-shell-args.test.ts new file mode 100644 index 0000000..c2ab101 --- /dev/null +++ b/base-action/test/parse-shell-args.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; + +// Import the function directly from run-claude.ts for testing +// We'll need to export it first +function parseShellArgs(argsString?: string): string[] { + if (!argsString || argsString.trim() === "") { + return []; + } + + const args: string[] = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + let escapeNext = false; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (escapeNext) { + current += char; + escapeNext = false; + continue; + } + + if (char === "\\") { + if (inSingleQuote) { + current += char; + } else { + escapeNext = true; + } + continue; + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (char === " " && !inSingleQuote && !inDoubleQuote) { + if (current) { + args.push(current); + current = ""; + } + continue; + } + + current += char; + } + + if (current) { + args.push(current); + } + + return args; +} + +describe("parseShellArgs", () => { + test("should handle empty input", () => { + expect(parseShellArgs("")).toEqual([]); + expect(parseShellArgs(undefined)).toEqual([]); + expect(parseShellArgs(" ")).toEqual([]); + }); + + test("should parse simple arguments", () => { + expect(parseShellArgs("--max-turns 3")).toEqual(["--max-turns", "3"]); + expect(parseShellArgs("-a -b -c")).toEqual(["-a", "-b", "-c"]); + }); + + test("should handle double quotes", () => { + expect(parseShellArgs('--config "/path/to/config.json"')).toEqual([ + "--config", + "/path/to/config.json", + ]); + expect(parseShellArgs('"arg with spaces"')).toEqual(["arg with spaces"]); + }); + + test("should handle single quotes", () => { + expect(parseShellArgs("--config '/path/to/config.json'")).toEqual([ + "--config", + "/path/to/config.json", + ]); + expect(parseShellArgs("'arg with spaces'")).toEqual(["arg with spaces"]); + }); + + test("should handle escaped characters", () => { + expect(parseShellArgs("arg\\ with\\ spaces")).toEqual(["arg with spaces"]); + expect(parseShellArgs('arg\\"with\\"quotes')).toEqual(['arg"with"quotes']); + }); + + test("should handle mixed quotes", () => { + expect(parseShellArgs(`--msg "It's a test"`)).toEqual([ + "--msg", + "It's a test", + ]); + expect(parseShellArgs(`--msg 'He said "hello"'`)).toEqual([ + "--msg", + 'He said "hello"', + ]); + }); + + test("should handle complex real-world example", () => { + const input = `--max-turns 3 --mcp-config "/Users/john/config.json" --model claude-3-5-sonnet-latest --system-prompt 'You are helpful'`; + expect(parseShellArgs(input)).toEqual([ + "--max-turns", + "3", + "--mcp-config", + "/Users/john/config.json", + "--model", + "claude-3-5-sonnet-latest", + "--system-prompt", + "You are helpful", + ]); + }); +}); \ No newline at end of file