From b6238ad00ea7ee87c890a5d5adfe4164c2652753 Mon Sep 17 00:00:00 2001 From: km-anthropic Date: Thu, 7 Aug 2025 15:18:00 -0700 Subject: [PATCH] refactor: use industry-standard shell-quote for argument parsing - Replace custom parseShellArgs with battle-tested shell-quote package - Simplify code by removing unnecessary -p filtering (Claude handles it) - Update tests to use shell-quote directly - Add example workflow showing claude_args usage This provides more robust argument parsing while reducing code complexity. --- base-action/bun.lock | 6 ++ base-action/package.json | 4 +- base-action/src/run-claude.ts | 78 ++--------------------- base-action/test/parse-shell-args.test.ts | 70 +++----------------- claude-args-example.yml | 40 ++++++++++++ 5 files changed, 65 insertions(+), 133 deletions(-) create mode 100644 claude-args-example.yml diff --git a/base-action/bun.lock b/base-action/bun.lock index 0f2bb60..16ee322 100644 --- a/base-action/bun.lock +++ b/base-action/bun.lock @@ -5,10 +5,12 @@ "name": "@anthropic-ai/claude-code-base-action", "dependencies": { "@actions/core": "^1.10.1", + "shell-quote": "^1.8.3", }, "devDependencies": { "@types/bun": "^1.2.12", "@types/node": "^20.0.0", + "@types/shell-quote": "^1.7.5", "prettier": "3.5.3", "typescript": "^5.8.3", }, @@ -31,12 +33,16 @@ "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/shell-quote": ["@types/shell-quote@1.7.5", "", {}, "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw=="], + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], diff --git a/base-action/package.json b/base-action/package.json index eb9165e..d0a5973 100644 --- a/base-action/package.json +++ b/base-action/package.json @@ -10,11 +10,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@actions/core": "^1.10.1" + "@actions/core": "^1.10.1", + "shell-quote": "^1.8.3" }, "devDependencies": { "@types/bun": "^1.2.12", "@types/node": "^20.0.0", + "@types/shell-quote": "^1.7.5", "prettier": "3.5.3", "typescript": "^5.8.3" } diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 763beb0..844d932 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -4,6 +4,7 @@ import { promisify } from "util"; import { unlink, writeFile, stat } from "fs/promises"; import { createWriteStream } from "fs"; import { spawn } from "child_process"; +import { parse as parseShellArgs } from "shell-quote"; const execAsync = promisify(exec); @@ -31,67 +32,6 @@ 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 {}; @@ -172,16 +112,12 @@ 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); + if (options.claudeArgs && options.claudeArgs.trim() !== "") { + const parsed = parseShellArgs(options.claudeArgs); + // shell-quote returns an array that can contain strings and objects + // We only want string arguments + const customArgs = parsed.filter((arg): arg is string => typeof arg === "string"); + claudeArgs.push(...customArgs); } // Parse custom environment variables diff --git a/base-action/test/parse-shell-args.test.ts b/base-action/test/parse-shell-args.test.ts index c2ab101..7f8ab39 100644 --- a/base-action/test/parse-shell-args.test.ts +++ b/base-action/test/parse-shell-args.test.ts @@ -1,68 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { parse as parseShellArgs } from "shell-quote"; -// 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", () => { +describe("shell-quote parseShellArgs", () => { test("should handle empty input", () => { expect(parseShellArgs("")).toEqual([]); - expect(parseShellArgs(undefined)).toEqual([]); expect(parseShellArgs(" ")).toEqual([]); }); @@ -116,4 +57,11 @@ describe("parseShellArgs", () => { "You are helpful", ]); }); + + test("should filter out non-string results", () => { + // shell-quote can return objects for operators like | > < etc + const result = parseShellArgs("echo hello"); + const filtered = result.filter(arg => typeof arg === "string"); + expect(filtered).toEqual(["echo", "hello"]); + }); }); \ No newline at end of file diff --git a/claude-args-example.yml b/claude-args-example.yml new file mode 100644 index 0000000..06a6984 --- /dev/null +++ b/claude-args-example.yml @@ -0,0 +1,40 @@ +name: Claude with Custom Args Example + +on: + pull_request: + workflow_dispatch: + inputs: + prompt: + description: "Prompt for Claude" + required: false + default: "Respond with a joke" + +jobs: + claude-custom: + runs-on: ubuntu-latest + steps: + - uses: anthropics/claude-code-action@v1 + with: + 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 \ No newline at end of file