feat: implement Claude Code GitHub Action v1.0 with auto-detection and slash commands (#421)

* feat: implement Claude Code GitHub Action v1.0 with auto-detection and slash commands

Major features:
- Mode auto-detection based on GitHub event type
- Unified prompt field replacing override_prompt and direct_prompt
- Slash command system with pre-built commands
- Full backward compatibility with v0.x

Key changes:
- Add mode detector for automatic mode selection
- Implement slash command loader with YAML frontmatter support
- Update action.yml with new prompt input
- Create pre-built slash commands for common tasks
- Update all tests for v1.0 compatibility

Breaking changes (with compatibility):
- Mode input now optional (auto-detected)
- override_prompt deprecated (use prompt)
- direct_prompt deprecated (use prompt)

* test + formatting fixes

* feat: simplify to two modes (tag and agent) for v1.0

BREAKING CHANGES:
- Remove review mode entirely - now handled via slash commands in agent mode
- Remove all deprecated backward compatibility fields (mode, anthropic_model, override_prompt, direct_prompt)
- Simplify mode detection: prompt overrides everything, then @claude mentions trigger tag mode, default is agent mode
- Remove slash command resolution from GitHub Action - Claude Code handles natively
- Remove variable substitution - prompts passed through as-is

Architecture changes:
- Only two modes now: tag (for @claude mentions) and agent (everything else)
- Agent mode is the default for all events including PRs
- Users configure behavior via prompts/slash commands (e.g. /review)
- GitHub Action is now a thin wrapper that passes prompts to Claude Code
- Mode names changed: 'experimental-review' → removed entirely

This aligns with the philosophy that the GitHub Action should do minimal work and delegate to Claude Code for all intelligent behavior.

* fix: address PR review comments for v1.0 simplification

- Remove duplicate prompt field spread (line 160)
- Remove async from generatePrompt since slash commands are handled by Claude Code
- Add detailed comment explaining why prompt → agent mode logic
- Remove entire slash-commands loader and directories as Claude Code handles natively
- Simplify prompt generation to just pass through to Claude Code

These changes align with v1.0 philosophy: GitHub Action is a thin wrapper
that delegates everything to Claude Code for native handling.

* chore: remove unused js-yaml dependencies

These were added for slash-command YAML parsing but are no longer
needed since we removed slash-command preprocessing entirely

* fix: remove experimental-review mode reference from MCP config

The inline comment server configuration was checking for deprecated
'mode' field. Since review mode is removed in v1.0, this conditional
block is no longer needed.

* prettify

* feat: add claudeArgs input for direct CLI argument passing

- Add claude_args input to both action.yml files
- Implement shell-style argument parsing with quote handling
- Pass arguments directly to Claude CLI for maximum flexibility
- Add comprehensive tests for argument parsing
- Log custom arguments for debugging

Users can now pass any Claude CLI arguments directly:
  claude_args: '--max-turns 3 --mcp-config /path/to/config.json'

This provides power users full control over Claude's behavior without
waiting for specific inputs to be added to the action.

* 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.

* bun format

* 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

* format

* refactor: complete v1.0 simplification by removing all legacy inputs

- Remove all backward compatibility for v1.0 simplification
- Remove 10 legacy inputs from base-action/action.yml
- Remove 9 legacy inputs from main action.yml
- Simplify ClaudeOptions type to just timeoutMinutes and claudeArgs
- Remove all legacy option handling from prepareRunConfig
- Update tests to remove references to deleted fields
- Remove obsolete test file github/context.test.ts
- Clean up types to remove customInstructions, allowedTools, disallowedTools

Users now use claudeArgs exclusively for CLI control.

* fix: update MCP server tests after removing additionalPermissions

- Change github_ci server logic to check for workflow token presence
- Update test names to reflect new behavior
- Fix test that was incorrectly setting workflow token

* model version update

* Update package json

* remove deprecated workflow file (tests features we no longer support)

* Simplify agent mode and re-add additional_permissions input

- Agent mode now only triggers when explicit prompt is provided
- Removed automatic triggering for workflow_dispatch/schedule without prompt
- Re-added additional_permissions input for requesting GitHub permissions
- Fixed TypeScript types for mock context helpers to properly handle partial inputs
- Updated documentation to reflect simplified mode behavior

* Fix MCP config not being passed to Claude CLI

The MCP servers (including github_comment server) were configured but not passed to Claude. This caused the "update_claude_comment" tool to be unavailable.

Changes:
- Write MCP config to a file at $RUNNER_TEMP/claude-mcp-config.json
- Add mcp_config_file output from prepare.ts
- Pass MCP config file via --mcp-config flag in claude_args
- Use fs/promises writeFile to match codebase conventions

* Fix MCP tool availability and shell escaping in tag mode

Pass MCP config and allowed tools through claude_args to ensure tools like
mcp__github_comment__update_claude_comment are properly available to Claude CLI.

Key changes:
- Tag mode outputs claude_args with MCP config (as JSON string) and allowed tools
- Fixed shell escaping vulnerability when JSON contains single quotes
- Agent mode passes through user-provided claude_args unchanged
- Re-added mcp_config input for users to provide custom MCP servers
- Cleaned up misleading comments and unused file operations
- Clarified test workflow is for fork testing

Security fix: Properly escape single quotes in MCP config JSON to prevent
shell injection vulnerabilities.

Co-Authored-By: Claude <noreply@anthropic.com>

* bun format

* tests, typecheck, format

* registry test update

* Update agent mode to have github server as a default

* Fix agent mode to include GitHub MCP server with proper token

* Simplify review workflow - prevent multiple submissions

- Rename workflow to avoid conflicts
- Remove review submission tools
- Keep only essential tools for reading and analyzing PR

* Add GitHub MCP server and context prefix to agent mode

- Include main GitHub MCP server (Docker-based) by default
- Fetch and prefix GitHub context to prompts when in PR/issue context
- Users no longer need to manually configure GitHub tools

* Delete .github/workflows/claude-auto-review-test.yml

* Remove github_comment and inline_comment servers from agent mode defaults

- Agent mode now only includes the main GitHub MCP server by default
- Users can add additional servers via mcp_config if needed
- Reduces unnecessary MCP server overhead

* Remove all default MCP servers from agent mode

Agent mode now starts with no default servers - users must explicitly configure any MCP servers they need via mcp_config input

* Remove GitHub context prefixing and clean up agent mode

- Remove automatic GitHub context fetching and prefixing
- Remove unused imports (fetcher, formatter, context checks)
- Clean up comments
- Agent mode now simply passes through the user's prompt as-is

* Add GitHub MCP support to agent mode

- Parse --allowedTools from claude_args to detect when user wants GitHub MCPs
- Wire up github_inline_comment server in prepareMcpConfig for PR contexts
- Update agent mode to use prepareMcpConfig instead of manual config
- Add comprehensive tests for parseAllowedTools edge cases
- Fix TypeScript types to support both entity and automation contexts

* Format code with prettier

* Fix agent mode test to expect branch values

* Fix agent test to handle dynamic branch names from environment

* Better fix: Control environment variables in agent test for predictable behavior

* minor formatting

* Simplify MCP configuration to use multiple --mcp-config flags

- Remove MCP config merging logic from prepareMcpConfig
- Update agent and tag modes to pass multiple --mcp-config flags
- Let Claude handle config merging natively through multiple flags
- Fix TypeScript errors in test file

This approach is cleaner and relies on Claude's built-in support for multiple --mcp-config flags instead of manual JSON merging.

* feat: Copy project subagents to Claude runtime environment

Enables custom subagents defined in .claude/agents/ to work in GitHub Actions by:
- Checking for project agents in GITHUB_WORKSPACE/.claude/agents/
- Creating ~/.claude/agents/ directory if needed
- Copying all .md agent files to Claude's runtime location
- Following same pattern as slash commands for consistency

Includes comprehensive test coverage for the new functionality.

* formatting

* Add auto-fix CI workflows with slash command and inline approaches

- Add /fix-ci slash command for programmatic CI failure fixing
- Create auto-fix-ci.yml workflow using slash command approach
- Create auto-fix-ci-inline.yml workflow with full inline prompt
- Both workflows automatically analyze CI failures and create fix branches

* Add workflow_run event support and auto-fix CI workflows

- Add support for workflow_run event type in GitHub context
- Create /fix-ci slash command for programmatic CI failure fixing
- Add auto-fix-ci.yml workflow using slash command approach
- Add auto-fix-ci-inline.yml workflow with full inline prompt
- Both workflows automatically analyze CI failures and create fix branches
- Fix workflow syntax issues with optional chaining operator

* Use proper WorkflowRunEvent type instead of any

* bun formatting

* Remove auto-fix workflows and commands from v1-dev

These files should only exist in km-anthropic fork:
- .github/workflows/auto-fix-ci.yml
- .github/workflows/auto-fix-ci-inline.yml
- slash-commands/fix-ci.md
- .claude/commands/fix-ci.md

The workflow_run event support remains as it's useful for general automation.

* feat: Expose GitHub token as action output for external use

This allows workflows to use the Claude App token obtained by the action
for posting comments as claude[bot] instead of github-actions[bot].

Changes:
- Add github_token output to action.yml
- Export token from prepare.ts after authentication
- Allows workflows to use the same token Claude uses internally

* Debug: Add logging and always output github_token in prepare step

* Fix: Add git authentication to agent mode

Agent mode now fetches the authenticated user (claude[bot] when using Claude App token)
and configures git identity properly, matching the behavior of tag mode.

This fixes the issue where commits in agent mode were failing due to missing git identity.

* minor bun format

* remove unnecessary file

* fix: Add branch environment variable support to agent mode for signed commits

- Read CLAUDE_BRANCH and BASE_BRANCH env vars in agent mode
- Pass correct branch info to MCP file ops server
- Enables signed auto-fix workflows to create branches via API

* feat: Add auto-fix CI workflow examples

- Add auto-fix-ci example with inline git commits
- Add auto-fix-ci-signed example with signed commits via MCP
- Include corresponding slash commands for both workflows
- Examples demonstrate automated CI failure detection and fixing

* fix: Fix TypeScript error in agent mode git config

- Remove dependency on configureGitAuth which expects ParsedGitHubContext
- Implement git configuration directly for automation contexts
- Properly handle git authentication for agent mode

* fix: Align agent mode git config with existing patterns

- Use GITHUB_SERVER_URL from config module consistently
- Remove existing headers before setting new ones
- Use remote URL with embedded token like git-config.ts does
- Match the existing git authentication pattern in the codebase

* refactor: Use shared configureGitAuth function in agent mode

- Update configureGitAuth to accept GitHubContext instead of ParsedGitHubContext
- This allows both tag mode and agent mode to use the same function
- Removes code duplication and ensures consistent git configuration

* feat: Improve error message for 403 permission errors when committing

When the github_file_ops MCP server gets a 403 error, it now shows a cleaner
message suggesting to rebase from main/master branch to fix the issue.

* docs: Update documentation for v1.0 release (#476)

* docs: Update documentation for v1.0 release

- Integrate breaking changes naturally without alarming users
- Replace deprecated inputs (direct_prompt, custom_instructions, mode) with new unified approach
- Update all examples to use prompt and claude_args instead of deprecated inputs
- Add migration guides to help users transition from v0.x to v1.0
- Emphasize automatic mode detection as a key feature
- Update all workflow examples to @v1 from @beta
- Document how claude_args provides direct CLI control
- Update FAQ with automatic mode detection explanation
- Convert all tool configuration to use claude_args format

* fix: Apply prettier formatting to documentation files

* fix: Update all Claude model versions to latest and improve documentation accuracy

- Update all model references to claude-4-0-sonnet-20250805 (latest Sonnet 4)
- Update Bedrock models to anthropic.claude-4-0-sonnet-20250805-v1:0
- Update Vertex models to claude-4-0-sonnet@20250805
- Fix cloud-providers.md to use claude_args instead of deprecated model input
- Ensure all examples use @v1 instead of @beta
- Keep claude-opus-4-1-20250805 in examples where Opus is demonstrated
- Align all documentation with v1.0 patterns consistently

* feat: Add dedicated migration guide as requested in PR feedback

- Create comprehensive migration-guide.md with step-by-step instructions
- Add prominent links to migration guide in README.md
- Update usage.md to reference the separate migration guide
- Include before/after examples for all common scenarios
- Add checklist for systematic migration
- Address Ashwin's feedback about having a separate, clearly linked migration guide

* feat: Add comprehensive examples for hero use cases

- Add dedicated issue deduplication workflow example
- Add issue triage example (moved from .github/workflows)
- Update all examples to use v1-dev branch consistently
- Enable MCP tools in claude-auto-review.yml
- Consolidate PR review examples into single comprehensive example

Hero use cases now covered:
1. Code reviews (claude-auto-review.yml)
2. Issue triaging (issue-triage.yml)
3. Issue deduplication (issue-deduplication.yml)
4. Auto-fix CI failures (auto-fix-ci/auto-fix-ci.yml)

All examples updated to follow v1-dev paradigm with proper prompt and claude_args configuration.

* refactor: Remove timeout_minutes parameter from action (#482)

This change removes the custom timeout_minutes parameter from the action in favor of using GitHub Actions' native timeout-minutes feature.

Changes:
- Removed timeout_minutes input from action.yml and base-action/action.yml
- Removed all timeout handling logic from base-action/src/run-claude.ts
- Updated base-action/src/index.ts to remove timeoutMinutes parameter
- Removed timeout-related tests from base-action/test/run-claude.test.ts
- Removed timeout_minutes from all example workflow files (19 files)

Rationale:
- Simplifies the codebase by removing custom timeout logic
- Users can use GitHub Actions' native timeout-minutes at the job/step level
- Reduces complexity and maintenance burden
- Follows GitHub Actions best practices

BREAKING CHANGE: The timeout_minutes parameter is no longer supported. Users should use GitHub Actions' native timeout-minutes instead.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>

* refactor: Remove unused slash commands and agents copying logic

Removes experimental file copying features that had no default content:
- Removed experimental_slash_commands_dir parameter and related logic
- Removed automatic project agents copying from .claude/agents/
- Eliminated flaky error-prone cp operations with stderr suppression
- Removed 175 lines of unused code and associated tests

These features were infrastructure without default content that used
problematic error handling patterns (2>/dev/null || true) which could
hide real filesystem errors.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: Remove references to timeout_minutes parameter

The timeout_minutes parameter was removed in commit 986e40a but
documentation still referenced it. This updates:
- docs/usage.md: Removed timeout_minutes from inputs table
- base-action/README.md: Removed from inputs table and example

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: km-anthropic <km-anthropic@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Kashyap Murali <13315300+katchu11@users.noreply.github.com>
This commit is contained in:
km-anthropic
2025-08-25 12:51:37 -07:00
committed by GitHub
parent 9c7e1bac94
commit 0630ef383a
68 changed files with 2315 additions and 2387 deletions

View File

@@ -100,7 +100,6 @@ Add the following to your workflow file:
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
| `timeout_minutes` | Timeout in minutes for Claude Code execution | No | '10' |
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
@@ -320,7 +319,6 @@ You can combine MCP config with other inputs like allowed tools:
prompt: "Access the custom MCP server and use its tools"
mcp_config: "mcp-config.json"
allowed_tools: "Bash(git:*),View,mcp__server-name__custom_tool"
timeout_minutes: "15"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
```

View File

@@ -14,56 +14,16 @@ inputs:
description: "Path to a file containing the prompt to send to Claude Code (mutually exclusive with prompt)"
required: false
default: ""
allowed_tools:
description: "Comma-separated list of allowed tools for Claude Code to use"
required: false
default: ""
disallowed_tools:
description: "Comma-separated list of disallowed tools that Claude Code cannot use"
required: false
default: ""
max_turns:
description: "Maximum number of conversation turns (default: no limit)"
required: false
default: ""
mcp_config:
description: "MCP configuration as JSON string or path to MCP configuration JSON file"
required: false
default: ""
settings:
description: "Claude Code settings as JSON string or path to settings JSON file"
required: false
default: ""
system_prompt:
description: "Override system prompt"
required: false
default: ""
append_system_prompt:
description: "Append to system prompt"
required: false
default: ""
model:
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
required: false
anthropic_model:
description: "DEPRECATED: Use 'model' instead. Model to use (provider-specific format required for Bedrock/Vertex)"
required: false
fallback_model:
description: "Enable automatic fallback to specified model when default model is unavailable"
required: false
claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML multiline format)"
required: false
default: ""
# Action settings
timeout_minutes:
description: "Timeout in minutes for Claude Code execution"
required: false
default: "10"
experimental_slash_commands_dir:
description: "Experimental: Directory containing slash command files to install"
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: ""
# Authentication settings
anthropic_api_key:
@@ -160,20 +120,10 @@ runs:
env:
# Model configuration
CLAUDE_CODE_ACTION: "1"
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
INPUT_PROMPT: ${{ inputs.prompt }}
INPUT_PROMPT_FILE: ${{ inputs.prompt_file }}
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
INPUT_MCP_CONFIG: ${{ inputs.mcp_config }}
INPUT_SETTINGS: ${{ inputs.settings }}
INPUT_SYSTEM_PROMPT: ${{ inputs.system_prompt }}
INPUT_APPEND_SYSTEM_PROMPT: ${{ inputs.append_system_prompt }}
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}

View File

@@ -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=="],

View File

@@ -104,5 +104,4 @@ jobs:
prompt_file: /tmp/claude-prompts/triage-prompt.txt
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
mcp_config: /tmp/mcp-config/mcp-servers.json
timeout_minutes: "5"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

View File

@@ -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"
}

View File

@@ -13,7 +13,6 @@ async function run() {
await setupClaudeCodeSettings(
process.env.INPUT_SETTINGS,
undefined, // homeDir
process.env.INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR,
);
const promptConfig = await preparePrompt({
@@ -22,6 +21,7 @@ async function run() {
});
await runClaude(promptConfig.path, {
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS,

View File

@@ -4,14 +4,18 @@ 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);
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"];
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
export type ClaudeOptions = {
claudeArgs?: string;
model?: string;
pathToClaudeCodeExecutable?: string;
allowedTools?: string;
disallowedTools?: string;
maxTurns?: string;
@@ -20,9 +24,6 @@ export type ClaudeOptions = {
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
timeoutMinutes?: string;
model?: string;
pathToClaudeCodeExecutable?: string;
};
type PreparedConfig = {
@@ -31,85 +32,30 @@ type PreparedConfig = {
env: Record<string, string>;
};
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
if (!claudeEnv || claudeEnv.trim() === "") {
return {};
}
const customEnv: Record<string, string> = {};
// Split by lines and parse each line as KEY: VALUE
const lines = claudeEnv.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
continue; // Skip empty lines and comments
}
const colonIndex = trimmedLine.indexOf(":");
if (colonIndex === -1) {
continue; // Skip lines without colons
}
const key = trimmedLine.substring(0, colonIndex).trim();
const value = trimmedLine.substring(colonIndex + 1).trim();
if (key) {
customEnv[key] = value;
}
}
return customEnv;
}
export function prepareRunConfig(
promptPath: string,
options: ClaudeOptions,
): PreparedConfig {
const claudeArgs = [...BASE_ARGS];
// Build Claude CLI arguments:
// 1. Prompt flag (always first)
// 2. User's claudeArgs (full control)
// 3. BASE_ARGS (always last, cannot be overridden)
if (options.allowedTools) {
claudeArgs.push("--allowedTools", options.allowedTools);
}
if (options.disallowedTools) {
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) {
claudeArgs.push("--mcp-config", options.mcpConfig);
}
if (options.systemPrompt) {
claudeArgs.push("--system-prompt", options.systemPrompt);
}
if (options.appendSystemPrompt) {
claudeArgs.push("--append-system-prompt", options.appendSystemPrompt);
}
if (options.fallbackModel) {
claudeArgs.push("--fallback-model", options.fallbackModel);
}
if (options.model) {
claudeArgs.push("--model", options.model);
}
if (options.timeoutMinutes) {
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
throw new Error(
`timeoutMinutes must be a positive number, got: ${options.timeoutMinutes}`,
);
}
const claudeArgs = ["-p"];
// Parse and add user's custom Claude arguments
if (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);
// BASE_ARGS are always appended last (cannot be overridden)
claudeArgs.push(...BASE_ARGS);
const customEnv: Record<string, string> = {};
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
@@ -154,8 +100,14 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
}
// 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], {
@@ -231,57 +183,15 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
claudeProcess.kill("SIGTERM");
});
// Wait for Claude to finish with timeout
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
if (options.timeoutMinutes) {
timeoutMs = parseInt(options.timeoutMinutes, 10) * 60 * 1000;
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
if (isNaN(envTimeout) || envTimeout <= 0) {
throw new Error(
`INPUT_TIMEOUT_MINUTES must be a positive number, got: ${process.env.INPUT_TIMEOUT_MINUTES}`,
);
}
timeoutMs = envTimeout * 60 * 1000;
}
// Wait for Claude to finish
const exitCode = await new Promise<number>((resolve) => {
let resolved = false;
// Set a timeout for the process
const timeoutId = setTimeout(() => {
if (!resolved) {
console.error(
`Claude process timed out after ${timeoutMs / 1000} seconds`,
);
claudeProcess.kill("SIGTERM");
// Give it 5 seconds to terminate gracefully, then force kill
setTimeout(() => {
try {
claudeProcess.kill("SIGKILL");
} catch (e) {
// Process may already be dead
}
}, 5000);
resolved = true;
resolve(124); // Standard timeout exit code
}
}, timeoutMs);
claudeProcess.on("close", (code) => {
if (!resolved) {
clearTimeout(timeoutId);
resolved = true;
resolve(code || 0);
}
resolve(code || 0);
});
claudeProcess.on("error", (error) => {
if (!resolved) {
console.error("Claude process error:", error);
clearTimeout(timeoutId);
resolved = true;
resolve(1);
}
console.error("Claude process error:", error);
resolve(1);
});
});

View File

@@ -5,7 +5,6 @@ import { readFile } from "fs/promises";
export async function setupClaudeCodeSettings(
settingsInput?: string,
homeDir?: string,
slashCommandsDir?: string,
) {
const home = homeDir ?? homedir();
const settingsPath = `${home}/.claude/settings.json`;
@@ -66,17 +65,4 @@ export async function setupClaudeCodeSettings(
await $`echo ${JSON.stringify(settings, null, 2)} > ${settingsPath}`.quiet();
console.log(`Settings saved successfully`);
if (slashCommandsDir) {
console.log(
`Copying slash commands from ${slashCommandsDir} to ${home}/.claude/`,
);
try {
await $`test -d ${slashCommandsDir}`.quiet();
await $`cp ${slashCommandsDir}/*.md ${home}/.claude/ 2>/dev/null || true`.quiet();
console.log(`Slash commands copied successfully`);
} catch (e) {
console.log(`Slash commands directory not found or error copying: ${e}`);
}
}
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import { parse as parseShellArgs } from "shell-quote";
describe("shell-quote parseShellArgs", () => {
test("should handle empty input", () => {
expect(parseShellArgs("")).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",
]);
});
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"]);
});
});

View File

@@ -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",
@@ -23,79 +23,6 @@ describe("prepareRunConfig", () => {
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
});
test("should include allowed tools in command arguments", () => {
const options: ClaudeOptions = {
allowedTools: "Bash,Read",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--allowedTools");
expect(prepared.claudeArgs).toContain("Bash,Read");
});
test("should include disallowed tools in command arguments", () => {
const options: ClaudeOptions = {
disallowedTools: "Bash,Read",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--disallowedTools");
expect(prepared.claudeArgs).toContain("Bash,Read");
});
test("should include max turns in command arguments", () => {
const options: ClaudeOptions = {
maxTurns: "5",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--max-turns");
expect(prepared.claudeArgs).toContain("5");
});
test("should include mcp config in command arguments", () => {
const options: ClaudeOptions = {
mcpConfig: "/path/to/mcp-config.json",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--mcp-config");
expect(prepared.claudeArgs).toContain("/path/to/mcp-config.json");
});
test("should include system prompt in command arguments", () => {
const options: ClaudeOptions = {
systemPrompt: "You are a senior backend engineer.",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--system-prompt");
expect(prepared.claudeArgs).toContain("You are a senior backend engineer.");
});
test("should include append system prompt in command arguments", () => {
const options: ClaudeOptions = {
appendSystemPrompt:
"After writing code, be sure to code review yourself.",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--append-system-prompt");
expect(prepared.claudeArgs).toContain(
"After writing code, be sure to code review yourself.",
);
});
test("should include fallback model in command arguments", () => {
const options: ClaudeOptions = {
fallbackModel: "claude-sonnet-4-20250514",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--fallback-model");
expect(prepared.claudeArgs).toContain("claude-sonnet-4-20250514");
});
test("should use provided prompt path", () => {
const options: ClaudeOptions = {};
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
@@ -103,195 +30,53 @@ describe("prepareRunConfig", () => {
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
});
test("should not include optional arguments when not set", () => {
const options: ClaudeOptions = {};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).not.toContain("--allowedTools");
expect(prepared.claudeArgs).not.toContain("--disallowedTools");
expect(prepared.claudeArgs).not.toContain("--max-turns");
expect(prepared.claudeArgs).not.toContain("--mcp-config");
expect(prepared.claudeArgs).not.toContain("--system-prompt");
expect(prepared.claudeArgs).not.toContain("--append-system-prompt");
expect(prepared.claudeArgs).not.toContain("--fallback-model");
});
test("should preserve order of claude arguments", () => {
const options: ClaudeOptions = {
allowedTools: "Bash,Read",
maxTurns: "3",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toEqual([
"-p",
"--verbose",
"--output-format",
"stream-json",
"--allowedTools",
"Bash,Read",
"--max-turns",
"3",
]);
});
test("should preserve order with all options including fallback model", () => {
const options: ClaudeOptions = {
allowedTools: "Bash,Read",
disallowedTools: "Write",
maxTurns: "3",
mcpConfig: "/path/to/config.json",
systemPrompt: "You are a helpful assistant",
appendSystemPrompt: "Be concise",
fallbackModel: "claude-sonnet-4-20250514",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toEqual([
"-p",
"--verbose",
"--output-format",
"stream-json",
"--allowedTools",
"Bash,Read",
"--disallowedTools",
"Write",
"--max-turns",
"3",
"--mcp-config",
"/path/to/config.json",
"--system-prompt",
"You are a helpful assistant",
"--append-system-prompt",
"Be concise",
"--fallback-model",
"claude-sonnet-4-20250514",
]);
});
describe("maxTurns validation", () => {
test("should accept valid maxTurns value", () => {
const options: ClaudeOptions = { maxTurns: "5" };
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--max-turns");
expect(prepared.claudeArgs).toContain("5");
});
test("should throw error for non-numeric maxTurns", () => {
const options: ClaudeOptions = { maxTurns: "abc" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
"maxTurns must be a positive number, got: abc",
);
});
test("should throw error for negative maxTurns", () => {
const options: ClaudeOptions = { maxTurns: "-1" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
"maxTurns must be a positive number, got: -1",
);
});
test("should throw error for zero maxTurns", () => {
const options: ClaudeOptions = { maxTurns: "0" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
"maxTurns must be a positive number, got: 0",
);
});
});
describe("timeoutMinutes validation", () => {
test("should accept valid timeoutMinutes value", () => {
const options: ClaudeOptions = { timeoutMinutes: "15" };
expect(() =>
prepareRunConfig("/tmp/test-prompt.txt", options),
).not.toThrow();
});
test("should throw error for non-numeric timeoutMinutes", () => {
const options: ClaudeOptions = { timeoutMinutes: "abc" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
"timeoutMinutes must be a positive number, got: abc",
);
});
test("should throw error for negative timeoutMinutes", () => {
const options: ClaudeOptions = { timeoutMinutes: "-5" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
"timeoutMinutes must be a positive number, got: -5",
);
});
test("should throw error for zero timeoutMinutes", () => {
const options: ClaudeOptions = { timeoutMinutes: "0" };
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
"timeoutMinutes must be a positive number, got: 0",
);
});
});
describe("custom environment variables", () => {
test("should parse empty claudeEnv correctly", () => {
const options: ClaudeOptions = { claudeEnv: "" };
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.env).toEqual({});
});
test("should parse single environment variable", () => {
const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123" };
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.env).toEqual({ API_KEY: "secret123" });
});
test("should parse multiple environment variables", () => {
describe("claudeArgs handling", () => {
test("should parse and include custom claude arguments", () => {
const options: ClaudeOptions = {
claudeEnv: "API_KEY: secret123\nDEBUG: true\nUSER: testuser",
claudeArgs: "--max-turns 10 --model claude-3-opus-20240229",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.env).toEqual({
API_KEY: "secret123",
DEBUG: "true",
USER: "testuser",
});
expect(prepared.claudeArgs).toEqual([
"-p",
"--max-turns",
"10",
"--model",
"claude-3-opus-20240229",
"--verbose",
"--output-format",
"stream-json",
]);
});
test("should handle environment variables with spaces around values", () => {
test("should handle empty claudeArgs", () => {
const options: ClaudeOptions = {
claudeEnv: "API_KEY: secret123 \n DEBUG : true ",
claudeArgs: "",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.env).toEqual({
API_KEY: "secret123",
DEBUG: "true",
});
expect(prepared.claudeArgs).toEqual([
"-p",
"--verbose",
"--output-format",
"stream-json",
]);
});
test("should skip empty lines and comments", () => {
test("should handle claudeArgs with quoted strings", () => {
const options: ClaudeOptions = {
claudeEnv:
"API_KEY: secret123\n\n# This is a comment\nDEBUG: true\n# Another comment",
claudeArgs: '--system-prompt "You are a helpful assistant"',
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.env).toEqual({
API_KEY: "secret123",
DEBUG: "true",
});
});
test("should skip lines without colons", () => {
const options: ClaudeOptions = {
claudeEnv: "API_KEY: secret123\nINVALID_LINE\nDEBUG: true",
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.env).toEqual({
API_KEY: "secret123",
DEBUG: "true",
});
});
test("should handle undefined claudeEnv", () => {
const options: ClaudeOptions = {};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.env).toEqual({});
expect(prepared.claudeArgs).toEqual([
"-p",
"--system-prompt",
"You are a helpful assistant",
"--verbose",
"--output-format",
"stream-json",
]);
});
});
});

View File

@@ -3,7 +3,7 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { setupClaudeCodeSettings } from "../src/setup-claude-code-settings";
import { tmpdir } from "os";
import { mkdir, writeFile, readFile, rm, readdir } from "fs/promises";
import { mkdir, writeFile, readFile, rm } from "fs/promises";
import { join } from "path";
const testHomeDir = join(
@@ -147,72 +147,4 @@ describe("setupClaudeCodeSettings", () => {
expect(settings.newKey).toBe("newValue");
expect(settings.model).toBe("claude-opus-4-1-20250805");
});
test("should copy slash commands to .claude directory when path provided", async () => {
const testSlashCommandsDir = join(testHomeDir, "test-slash-commands");
await mkdir(testSlashCommandsDir, { recursive: true });
await writeFile(
join(testSlashCommandsDir, "test-command.md"),
"---\ndescription: Test command\n---\nTest content",
);
await setupClaudeCodeSettings(undefined, testHomeDir, testSlashCommandsDir);
const testCommandPath = join(testHomeDir, ".claude", "test-command.md");
const content = await readFile(testCommandPath, "utf-8");
expect(content).toContain("Test content");
});
test("should skip slash commands when no directory provided", async () => {
await setupClaudeCodeSettings(undefined, testHomeDir);
const settingsContent = await readFile(settingsPath, "utf-8");
const settings = JSON.parse(settingsContent);
expect(settings.enableAllProjectMcpServers).toBe(true);
});
test("should handle missing slash commands directory gracefully", async () => {
const nonExistentDir = join(testHomeDir, "non-existent");
await setupClaudeCodeSettings(undefined, testHomeDir, nonExistentDir);
const settingsContent = await readFile(settingsPath, "utf-8");
expect(JSON.parse(settingsContent).enableAllProjectMcpServers).toBe(true);
});
test("should skip non-.md files in slash commands directory", async () => {
const testSlashCommandsDir = join(testHomeDir, "test-slash-commands");
await mkdir(testSlashCommandsDir, { recursive: true });
await writeFile(join(testSlashCommandsDir, "not-markdown.txt"), "ignored");
await writeFile(join(testSlashCommandsDir, "valid.md"), "copied");
await writeFile(join(testSlashCommandsDir, "another.md"), "also copied");
await setupClaudeCodeSettings(undefined, testHomeDir, testSlashCommandsDir);
const copiedFiles = await readdir(join(testHomeDir, ".claude"));
expect(copiedFiles).toContain("valid.md");
expect(copiedFiles).toContain("another.md");
expect(copiedFiles).not.toContain("not-markdown.txt");
expect(copiedFiles).toContain("settings.json"); // Settings should also exist
});
test("should handle slash commands path that is a file not directory", async () => {
const testFile = join(testHomeDir, "not-a-directory.txt");
await writeFile(testFile, "This is a file, not a directory");
await setupClaudeCodeSettings(undefined, testHomeDir, testFile);
const settingsContent = await readFile(settingsPath, "utf-8");
expect(JSON.parse(settingsContent).enableAllProjectMcpServers).toBe(true);
});
test("should handle empty slash commands directory", async () => {
const emptyDir = join(testHomeDir, "empty-slash-commands");
await mkdir(emptyDir, { recursive: true });
await setupClaudeCodeSettings(undefined, testHomeDir, emptyDir);
const settingsContent = await readFile(settingsPath, "utf-8");
expect(JSON.parse(settingsContent).enableAllProjectMcpServers).toBe(true);
});
});