diff --git a/action.yml b/action.yml index b7dfe22..5c9101c 100644 --- a/action.yml +++ b/action.yml @@ -118,10 +118,10 @@ inputs: outputs: execution_file: description: "Path to the Claude Code execution output file" - value: ${{ steps.claude-code.outputs.execution_file }} + value: ${{ steps.claude.outputs.execution_file }} branch_name: description: "The branch created by Claude Code for this execution" - value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} + value: ${{ steps.claude.outputs.CLAUDE_BRANCH }} runs: using: "composite" @@ -137,20 +137,36 @@ runs: cd ${GITHUB_ACTION_PATH} bun install - - name: Prepare action - id: prepare + - name: Run Claude + id: claude shell: bash run: | - bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts + # Install base-action dependencies + echo "Installing base-action dependencies..." + cd ${GITHUB_ACTION_PATH}/base-action + bun install + echo "Base-action dependencies installed" + cd - + + # Install Claude Code globally + bun install -g @anthropic-ai/claude-code@1.0.67 + + # Setup network restrictions if needed + if [[ "${{ inputs.experimental_allowed_domains }}" != "" ]]; then + chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh + ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh + fi + + # Run the unified entrypoint + bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts env: + # Mode and trigger configuration MODE: ${{ inputs.mode }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} LABEL_TRIGGER: ${{ inputs.label_trigger }} BASE_BRANCH: ${{ inputs.base_branch }} BRANCH_PREFIX: ${{ inputs.branch_prefix }} - ALLOWED_TOOLS: ${{ inputs.allowed_tools }} - DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }} CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }} DIRECT_PROMPT: ${{ inputs.direct_prompt }} OVERRIDE_PROMPT: ${{ inputs.override_prompt }} @@ -161,57 +177,21 @@ runs: DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} - - - name: Install Base Action Dependencies - if: steps.prepare.outputs.contains_trigger == 'true' - shell: bash - run: | - echo "Installing base-action dependencies..." - cd ${GITHUB_ACTION_PATH}/base-action - bun install - echo "Base-action dependencies installed" - cd - - # Install Claude Code globally - bun install -g @anthropic-ai/claude-code@1.0.69 - - - name: Setup Network Restrictions - if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != '' - shell: bash - run: | - chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh - ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh - env: EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }} - - name: Run Claude Code - id: claude-code - if: steps.prepare.outputs.contains_trigger == 'true' - shell: bash - run: | - - # Run the base-action - bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts - env: - # Base-action inputs - CLAUDE_CODE_ACTION: "1" - INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt - INPUT_ALLOWED_TOOLS: ${{ env.ALLOWED_TOOLS }} - INPUT_DISALLOWED_TOOLS: ${{ env.DISALLOWED_TOOLS }} - INPUT_MAX_TURNS: ${{ inputs.max_turns }} - INPUT_MCP_CONFIG: ${{ steps.prepare.outputs.mcp_config }} - INPUT_SETTINGS: ${{ inputs.settings }} - INPUT_SYSTEM_PROMPT: "" - INPUT_APPEND_SYSTEM_PROMPT: ${{ env.APPEND_SYSTEM_PROMPT }} - INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} - INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} - INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} + # Claude configuration + ALLOWED_TOOLS: ${{ inputs.allowed_tools }} + DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }} + MAX_TURNS: ${{ inputs.max_turns }} + SETTINGS: ${{ inputs.settings }} + TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} + CLAUDE_ENV: ${{ inputs.claude_env }} + FALLBACK_MODEL: ${{ inputs.fallback_model }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands # Model configuration + MODEL: ${{ inputs.model }} ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }} - GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} - NODE_VERSION: ${{ env.NODE_VERSION }} - DETAILED_PERMISSION_MESSAGES: "1" # Provider configuration ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} @@ -239,31 +219,31 @@ runs: VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }} - name: Update comment with job link - if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() + if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.claude_comment_id && always() shell: bash run: | bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts env: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} - CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }} + CLAUDE_COMMENT_ID: ${{ steps.claude.outputs.claude_comment_id }} GITHUB_RUN_ID: ${{ github.run_id }} - GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.claude.outputs.GITHUB_TOKEN }} GITHUB_EVENT_NAME: ${{ github.event_name }} TRIGGER_COMMENT_ID: ${{ github.event.comment.id }} - CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} + CLAUDE_BRANCH: ${{ steps.claude.outputs.CLAUDE_BRANCH }} IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }} - BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }} - CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }} - OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }} + BASE_BRANCH: ${{ steps.claude.outputs.BASE_BRANCH }} + CLAUDE_SUCCESS: ${{ steps.claude.outputs.conclusion == 'success' }} + OUTPUT_FILE: ${{ steps.claude.outputs.execution_file || '' }} TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }} - PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }} - PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} + PREPARE_SUCCESS: ${{ steps.claude.outcome == 'success' }} + PREPARE_ERROR: ${{ steps.claude.outputs.prepare_error || '' }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} - name: Display Claude Code Report - if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' + if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.execution_file != '' shell: bash run: | # Try to format the turns, but if it fails, dump the raw JSON @@ -275,7 +255,7 @@ runs: echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo '```json' >> $GITHUB_STEP_SUMMARY - cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY + cat "${{ steps.claude.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY fi @@ -286,6 +266,6 @@ runs: curl -L \ -X DELETE \ -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \ + -H "Authorization: Bearer ${{ steps.claude.outputs.GITHUB_TOKEN }}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ ${GITHUB_API_URL:-https://api.github.com}/installation/token diff --git a/base-action/src/index.ts b/base-action/src/index.ts index f4d3724..ee8157b 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -2,7 +2,7 @@ import * as core from "@actions/core"; import { preparePrompt } from "./prepare-prompt"; -import { runClaude } from "./run-claude"; +import { runClaudeCore } from "./run-claude-core"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { validateEnvironmentVariables } from "./validate-env"; @@ -21,7 +21,9 @@ async function run() { promptFile: process.env.INPUT_PROMPT_FILE || "", }); - await runClaude(promptConfig.path, { + await runClaudeCore({ + promptFile: promptConfig.path, + settings: process.env.INPUT_SETTINGS, allowedTools: process.env.INPUT_ALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, maxTurns: process.env.INPUT_MAX_TURNS, @@ -31,6 +33,7 @@ async function run() { claudeEnv: process.env.INPUT_CLAUDE_ENV, fallbackModel: process.env.INPUT_FALLBACK_MODEL, model: process.env.ANTHROPIC_MODEL, + timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES, }); } catch (error) { core.setFailed(`Action failed with error: ${error}`); diff --git a/base-action/src/run-claude-core.ts b/base-action/src/run-claude-core.ts new file mode 100644 index 0000000..6d23e4e --- /dev/null +++ b/base-action/src/run-claude-core.ts @@ -0,0 +1,366 @@ +#!/usr/bin/env bun + +import * as core from "@actions/core"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { unlink, writeFile, stat } from "fs/promises"; +import { createWriteStream } from "fs"; +import { spawn } from "child_process"; + +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"]; + +export type ClaudeOptions = { + allowedTools?: string; + disallowedTools?: string; + maxTurns?: string; + mcpConfig?: string; + systemPrompt?: string; + appendSystemPrompt?: string; + claudeEnv?: string; + fallbackModel?: string; + model?: string; + timeoutMinutes?: string; +}; + +export type RunClaudeConfig = { + promptFile: string; + settings?: string; + allowedTools?: string; + disallowedTools?: string; + maxTurns?: string; + mcpConfig?: string; + systemPrompt?: string; + appendSystemPrompt?: string; + claudeEnv?: string; + fallbackModel?: string; + model?: string; + timeoutMinutes?: string; + env?: Record; +}; + +function parseCustomEnvVars(claudeEnv?: string): Record { + if (!claudeEnv || claudeEnv.trim() === "") { + return {}; + } + + const customEnv: Record = {}; + + // 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; +} + +function prepareClaudeArgs(config: RunClaudeConfig): string[] { + const claudeArgs = [...BASE_ARGS]; + + if (config.allowedTools) { + claudeArgs.push("--allowedTools", config.allowedTools); + } + if (config.disallowedTools) { + claudeArgs.push("--disallowedTools", config.disallowedTools); + } + if (config.maxTurns) { + const maxTurnsNum = parseInt(config.maxTurns, 10); + if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) { + throw new Error( + `maxTurns must be a positive number, got: ${config.maxTurns}`, + ); + } + claudeArgs.push("--max-turns", config.maxTurns); + } + if (config.mcpConfig) { + claudeArgs.push("--mcp-config", config.mcpConfig); + } + if (config.systemPrompt) { + claudeArgs.push("--system-prompt", config.systemPrompt); + } + if (config.appendSystemPrompt) { + claudeArgs.push("--append-system-prompt", config.appendSystemPrompt); + } + if (config.fallbackModel) { + claudeArgs.push("--fallback-model", config.fallbackModel); + } + if (config.model) { + claudeArgs.push("--model", config.model); + } + if (config.timeoutMinutes) { + const timeoutMinutesNum = parseInt(config.timeoutMinutes, 10); + if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) { + throw new Error( + `timeoutMinutes must be a positive number, got: ${config.timeoutMinutes}`, + ); + } + } + + return claudeArgs; +} + +export function prepareRunConfig( + promptPath: string, + options: ClaudeOptions, +): { claudeArgs: string[]; promptPath: string; env: Record } { + const config: RunClaudeConfig = { + promptFile: promptPath, + ...options, + }; + + const claudeArgs = prepareClaudeArgs(config); + const customEnv = parseCustomEnvVars(config.claudeEnv); + const mergedEnv = { + ...customEnv, + ...(config.env || {}), + }; + + return { + claudeArgs, + promptPath, + env: mergedEnv, + }; +} + +export async function runClaudeCore(config: RunClaudeConfig) { + const claudeArgs = prepareClaudeArgs(config); + + // Parse custom environment variables from claudeEnv + const customEnv = parseCustomEnvVars(config.claudeEnv); + + // Merge with additional env vars passed in config + const mergedEnv = { + ...customEnv, + ...(config.env || {}), + }; + + // Create a named pipe + try { + await unlink(PIPE_PATH); + } catch (e) { + // Ignore if file doesn't exist + } + + // Create the named pipe + await execAsync(`mkfifo "${PIPE_PATH}"`); + + // Log prompt file size + let promptSize = "unknown"; + try { + const stats = await stat(config.promptFile); + promptSize = stats.size.toString(); + } catch (e) { + // Ignore error + } + + console.log(`Prompt file size: ${promptSize} bytes`); + + // Log custom environment variables if any + const totalEnvVars = Object.keys(mergedEnv).length; + if (totalEnvVars > 0) { + const envKeys = Object.keys(mergedEnv).join(", "); + console.log(`Custom environment variables (${totalEnvVars}): ${envKeys}`); + } + + // Output to console + console.log(`Running Claude with prompt from file: ${config.promptFile}`); + + // Start sending prompt to pipe in background + const catProcess = spawn("cat", [config.promptFile], { + stdio: ["ignore", "pipe", "inherit"], + }); + const pipeStream = createWriteStream(PIPE_PATH); + catProcess.stdout.pipe(pipeStream); + + catProcess.on("error", (error) => { + console.error("Error reading prompt file:", error); + pipeStream.destroy(); + }); + + const claudeProcess = spawn("claude", claudeArgs, { + stdio: ["pipe", "pipe", "inherit"], + env: { + ...process.env, + ...mergedEnv, + }, + }); + + // Handle Claude process errors + claudeProcess.on("error", (error) => { + console.error("Error spawning Claude process:", error); + pipeStream.destroy(); + }); + + // Capture output for parsing execution metrics + let output = ""; + claudeProcess.stdout.on("data", (data) => { + const text = data.toString(); + + // Try to parse as JSON and pretty print if it's on a single line + const lines = text.split("\n"); + lines.forEach((line: string, index: number) => { + if (line.trim() === "") return; + + try { + // Check if this line is a JSON object + const parsed = JSON.parse(line); + const prettyJson = JSON.stringify(parsed, null, 2); + process.stdout.write(prettyJson); + if (index < lines.length - 1 || text.endsWith("\n")) { + process.stdout.write("\n"); + } + } catch (e) { + // Not a JSON object, print as is + process.stdout.write(line); + if (index < lines.length - 1 || text.endsWith("\n")) { + process.stdout.write("\n"); + } + } + }); + + output += text; + }); + + // Handle stdout errors + claudeProcess.stdout.on("error", (error) => { + console.error("Error reading Claude stdout:", error); + }); + + // Pipe from named pipe to Claude + const pipeProcess = spawn("cat", [PIPE_PATH]); + pipeProcess.stdout.pipe(claudeProcess.stdin); + + // Handle pipe process errors + pipeProcess.on("error", (error) => { + console.error("Error reading from named pipe:", error); + claudeProcess.kill("SIGTERM"); + }); + + // Wait for Claude to finish with timeout + let timeoutMs = 10 * 60 * 1000; // Default 10 minutes + if (config.timeoutMinutes) { + timeoutMs = parseInt(config.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; + } + const exitCode = await new Promise((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); + } + }); + + claudeProcess.on("error", (error) => { + if (!resolved) { + console.error("Claude process error:", error); + clearTimeout(timeoutId); + resolved = true; + resolve(1); + } + }); + }); + + // Clean up processes + try { + catProcess.kill("SIGTERM"); + } catch (e) { + // Process may already be dead + } + try { + pipeProcess.kill("SIGTERM"); + } catch (e) { + // Process may already be dead + } + + // Clean up pipe file + try { + await unlink(PIPE_PATH); + } catch (e) { + // Ignore errors during cleanup + } + + // Set conclusion based on exit code + if (exitCode === 0) { + // Try to process the output and save execution metrics + try { + await writeFile("output.txt", output); + + // Process output.txt into JSON and save to execution file + const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt"); + await writeFile(EXECUTION_FILE, jsonOutput); + + console.log(`Log saved to ${EXECUTION_FILE}`); + } catch (e) { + core.warning(`Failed to process output for execution metrics: ${e}`); + } + + core.setOutput("conclusion", "success"); + core.setOutput("execution_file", EXECUTION_FILE); + } else { + core.setOutput("conclusion", "failure"); + + // Still try to save execution file if we have output + if (output) { + try { + await writeFile("output.txt", output); + const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt"); + await writeFile(EXECUTION_FILE, jsonOutput); + core.setOutput("execution_file", EXECUTION_FILE); + } catch (e) { + // Ignore errors when processing output during failure + } + } + + process.exit(exitCode); + } +} diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 70e38d7..8f37f4b 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -1,331 +1,44 @@ +#!/usr/bin/env bun + import * as core from "@actions/core"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { unlink, writeFile, stat } from "fs/promises"; -import { createWriteStream } from "fs"; -import { spawn } from "child_process"; +import { preparePrompt } from "./prepare-prompt"; +import { runClaudeCore } from "./run-claude-core"; +export { prepareRunConfig, type ClaudeOptions } from "./run-claude-core"; +import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; +import { validateEnvironmentVariables } from "./validate-env"; -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"]; - -export type ClaudeOptions = { - allowedTools?: string; - disallowedTools?: string; - maxTurns?: string; - mcpConfig?: string; - systemPrompt?: string; - appendSystemPrompt?: string; - claudeEnv?: string; - fallbackModel?: string; - timeoutMinutes?: string; - model?: string; -}; - -type PreparedConfig = { - claudeArgs: string[]; - promptPath: string; - env: Record; -}; - -function parseCustomEnvVars(claudeEnv?: string): Record { - if (!claudeEnv || claudeEnv.trim() === "") { - return {}; - } - - const customEnv: Record = {}; - - // 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]; - - 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}`, - ); - } - } - - // Parse custom environment variables - const customEnv = parseCustomEnvVars(options.claudeEnv); - - return { - claudeArgs, - promptPath, - env: customEnv, - }; -} - -export async function runClaude(promptPath: string, options: ClaudeOptions) { - const config = prepareRunConfig(promptPath, options); - - // Create a named pipe +async function run() { try { - await unlink(PIPE_PATH); - } catch (e) { - // Ignore if file doesn't exist - } + validateEnvironmentVariables(); - // Create the named pipe - await execAsync(`mkfifo "${PIPE_PATH}"`); + await setupClaudeCodeSettings(process.env.INPUT_SETTINGS); - // Log prompt file size - let promptSize = "unknown"; - try { - const stats = await stat(config.promptPath); - promptSize = stats.size.toString(); - } catch (e) { - // Ignore error - } - - console.log(`Prompt file size: ${promptSize} bytes`); - - // Log custom environment variables if any - if (Object.keys(config.env).length > 0) { - const envKeys = Object.keys(config.env).join(", "); - console.log(`Custom environment variables: ${envKeys}`); - } - - // Output to console - console.log(`Running Claude with prompt from file: ${config.promptPath}`); - - // Start sending prompt to pipe in background - const catProcess = spawn("cat", [config.promptPath], { - stdio: ["ignore", "pipe", "inherit"], - }); - const pipeStream = createWriteStream(PIPE_PATH); - catProcess.stdout.pipe(pipeStream); - - catProcess.on("error", (error) => { - console.error("Error reading prompt file:", error); - pipeStream.destroy(); - }); - - const claudeProcess = spawn("claude", config.claudeArgs, { - stdio: ["pipe", "pipe", "inherit"], - env: { - ...process.env, - ...config.env, - }, - }); - - // Handle Claude process errors - claudeProcess.on("error", (error) => { - console.error("Error spawning Claude process:", error); - pipeStream.destroy(); - }); - - // Capture output for parsing execution metrics - let output = ""; - claudeProcess.stdout.on("data", (data) => { - const text = data.toString(); - - // Try to parse as JSON and pretty print if it's on a single line - const lines = text.split("\n"); - lines.forEach((line: string, index: number) => { - if (line.trim() === "") return; - - try { - // Check if this line is a JSON object - const parsed = JSON.parse(line); - const prettyJson = JSON.stringify(parsed, null, 2); - process.stdout.write(prettyJson); - if (index < lines.length - 1 || text.endsWith("\n")) { - process.stdout.write("\n"); - } - } catch (e) { - // Not a JSON object, print as is - process.stdout.write(line); - if (index < lines.length - 1 || text.endsWith("\n")) { - process.stdout.write("\n"); - } - } + const promptConfig = await preparePrompt({ + prompt: process.env.INPUT_PROMPT || "", + promptFile: process.env.INPUT_PROMPT_FILE || "", }); - output += text; - }); - - // Handle stdout errors - claudeProcess.stdout.on("error", (error) => { - console.error("Error reading Claude stdout:", error); - }); - - // Pipe from named pipe to Claude - const pipeProcess = spawn("cat", [PIPE_PATH]); - pipeProcess.stdout.pipe(claudeProcess.stdin); - - // Handle pipe process errors - pipeProcess.on("error", (error) => { - console.error("Error reading from named pipe:", error); - 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; - } - const exitCode = await new Promise((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); - } + await runClaudeCore({ + promptFile: promptConfig.path, + settings: process.env.INPUT_SETTINGS, + allowedTools: process.env.INPUT_ALLOWED_TOOLS, + disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, + maxTurns: process.env.INPUT_MAX_TURNS, + mcpConfig: process.env.INPUT_MCP_CONFIG, + systemPrompt: process.env.INPUT_SYSTEM_PROMPT, + appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT, + claudeEnv: process.env.INPUT_CLAUDE_ENV, + fallbackModel: process.env.INPUT_FALLBACK_MODEL, + model: process.env.ANTHROPIC_MODEL, + timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES, }); - - claudeProcess.on("error", (error) => { - if (!resolved) { - console.error("Claude process error:", error); - clearTimeout(timeoutId); - resolved = true; - resolve(1); - } - }); - }); - - // Clean up processes - try { - catProcess.kill("SIGTERM"); - } catch (e) { - // Process may already be dead - } - try { - pipeProcess.kill("SIGTERM"); - } catch (e) { - // Process may already be dead - } - - // Clean up pipe file - try { - await unlink(PIPE_PATH); - } catch (e) { - // Ignore errors during cleanup - } - - // Set conclusion based on exit code - if (exitCode === 0) { - // Try to process the output and save execution metrics - try { - await writeFile("output.txt", output); - - // Process output.txt into JSON and save to execution file - const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt"); - await writeFile(EXECUTION_FILE, jsonOutput); - - console.log(`Log saved to ${EXECUTION_FILE}`); - } catch (e) { - core.warning(`Failed to process output for execution metrics: ${e}`); - } - - core.setOutput("conclusion", "success"); - core.setOutput("execution_file", EXECUTION_FILE); - } else { + } catch (error) { + core.setFailed(`Action failed with error: ${error}`); core.setOutput("conclusion", "failure"); - - // Still try to save execution file if we have output - if (output) { - try { - await writeFile("output.txt", output); - const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt"); - await writeFile(EXECUTION_FILE, jsonOutput); - core.setOutput("execution_file", EXECUTION_FILE); - } catch (e) { - // Ignore errors when processing output during failure - } - } - - process.exit(exitCode); + process.exit(1); } } + +if (import.meta.main) { + run(); +} diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 5f6d6c7..1bcde0c 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -811,12 +811,18 @@ f. If you are unable to complete certain steps, such as running a linter or test return promptContent; } +export type CreatePromptResult = { + promptFile: string; + allowedTools: string; + disallowedTools: string; +}; + export async function createPrompt( mode: Mode, modeContext: ModeContext, githubData: FetchDataResult, context: ParsedGitHubContext, -) { +): Promise { try { // Prepare the context for prompt generation let claudeCommentId: string = ""; @@ -888,8 +894,17 @@ export async function createPrompt( combinedAllowedTools, ); + // TODO: Remove these environment variable exports once modes are updated to use return values core.exportVariable("ALLOWED_TOOLS", allAllowedTools); core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools); + + const promptFile = `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`; + + return { + promptFile, + allowedTools: allAllowedTools, + disallowedTools: allDisallowedTools, + }; } catch (error) { core.setFailed(`Create prompt failed with error: ${error}`); process.exit(1); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts new file mode 100644 index 0000000..99d98f5 --- /dev/null +++ b/src/entrypoints/run.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env bun + +/** + * Unified entrypoint that combines prepare and run-claude steps + */ + +import * as core from "@actions/core"; +import { setupGitHubToken } from "../github/token"; +import { checkWritePermissions } from "../github/validation/permissions"; +import { createOctokit } from "../github/api/client"; +import { parseGitHubContext, isEntityContext } from "../github/context"; +import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry"; +import type { ModeName } from "../modes/types"; +import { prepare } from "../prepare"; +import { runClaudeCore } from "../../base-action/src/run-claude-core"; +import { validateEnvironmentVariables } from "../../base-action/src/validate-env"; +import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings"; + +async function run() { + try { + // Step 1: Get mode first to determine authentication method + const modeInput = process.env.MODE || DEFAULT_MODE; + + // Validate mode input + if (!isValidMode(modeInput)) { + throw new Error(`Invalid mode: ${modeInput}`); + } + const validatedMode: ModeName = modeInput; + + // Step 2: Setup GitHub token based on mode + let githubToken: string; + if (validatedMode === "experimental-review") { + // For experimental-review mode, use the default GitHub Action token + githubToken = process.env.DEFAULT_WORKFLOW_TOKEN || ""; + if (!githubToken) { + throw new Error( + "DEFAULT_WORKFLOW_TOKEN not found for experimental-review mode", + ); + } + console.log("Using default GitHub Action token for review mode"); + } else { + // For other modes, use the existing token exchange + githubToken = await setupGitHubToken(); + } + const octokit = createOctokit(githubToken); + + // Step 3: Parse GitHub context (once for all operations) + const context = parseGitHubContext(); + + // Step 4: Check write permissions (only for entity contexts) + if (isEntityContext(context)) { + const hasWritePermissions = await checkWritePermissions( + octokit.rest, + context, + ); + if (!hasWritePermissions) { + throw new Error( + "Actor does not have write permissions to the repository", + ); + } + } + + // Step 5: Get mode and check trigger conditions + const mode = getMode(validatedMode, context); + const containsTrigger = mode.shouldTrigger(context); + + // Set output for action.yml to check (in case it's still needed) + core.setOutput("contains_trigger", containsTrigger.toString()); + + if (!containsTrigger) { + console.log("No trigger found, skipping remaining steps"); + return; + } + + // Step 6: Use the modular prepare function + const prepareResult = await prepare({ + context, + octokit, + mode, + githubToken, + }); + + // Step 7: The mode.prepare() call already created the prompt and set up tools + // We need to get the allowed/disallowed tools from environment variables + // TODO: Update Mode interface to return tools from prepare() instead of relying on env vars + const allowedTools = process.env.ALLOWED_TOOLS || ""; + const disallowedTools = process.env.DISALLOWED_TOOLS || ""; + const promptFile = `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`; + + // Step 8: Validate environment and setup Claude settings + validateEnvironmentVariables(); + await setupClaudeCodeSettings(process.env.SETTINGS); + + // Step 9: Run Claude Code + console.log("Running Claude Code..."); + + // Build environment object to pass to Claude + const claudeEnvObject: Record = { + GITHUB_TOKEN: githubToken, + NODE_VERSION: process.env.NODE_VERSION || "18.x", + DETAILED_PERMISSION_MESSAGES: "1", + CLAUDE_CODE_ACTION: "1", + }; + + // Run Claude using the shared core function + try { + await runClaudeCore({ + promptFile, + settings: process.env.SETTINGS, + allowedTools, + disallowedTools, + maxTurns: process.env.MAX_TURNS, + mcpConfig: prepareResult.mcpConfig, + systemPrompt: "", + appendSystemPrompt: "", + claudeEnv: process.env.CLAUDE_ENV, + fallbackModel: process.env.FALLBACK_MODEL, + model: process.env.ANTHROPIC_MODEL || process.env.MODEL, + timeoutMinutes: process.env.TIMEOUT_MINUTES || "30", + env: claudeEnvObject, + }); + } catch (claudeError) { + // The outputs should already be set by runClaudeCore + // Just re-throw to handle in outer catch + throw claudeError; + } + + // Set outputs that were previously set by prepare step + core.setOutput("GITHUB_TOKEN", githubToken); + core.setOutput("mcp_config", prepareResult.mcpConfig); + if (prepareResult.branchInfo.claudeBranch) { + core.setOutput("branch_name", prepareResult.branchInfo.claudeBranch); + core.setOutput("CLAUDE_BRANCH", prepareResult.branchInfo.claudeBranch); + } + core.setOutput("BASE_BRANCH", prepareResult.branchInfo.baseBranch); + if (prepareResult.commentId) { + core.setOutput("claude_comment_id", prepareResult.commentId.toString()); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.setFailed(`Action failed with error: ${errorMessage}`); + // Also output the clean error message for the action to capture + core.setOutput("prepare_error", errorMessage); + core.setOutput("conclusion", "failure"); + process.exit(1); + } +} + +if (import.meta.main) { + run(); +} diff --git a/src/modes/review/index.ts b/src/modes/review/index.ts index e53f8f8..7da99e5 100644 --- a/src/modes/review/index.ts +++ b/src/modes/review/index.ts @@ -242,6 +242,7 @@ This ensures users get value from the review even before checking individual inl claudeBranch: branchInfo.claudeBranch, }); + // TODO: Capture and return the allowed/disallowed tools from createPrompt await createPrompt(reviewMode, modeContext, githubData, context); // Export tool environment variables for review mode diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index f9aabaf..e6f033d 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -98,6 +98,7 @@ export const tagMode: Mode = { claudeBranch: branchInfo.claudeBranch, }); + // TODO: Capture and return the allowed/disallowed tools from createPrompt await createPrompt(tagMode, modeContext, githubData, context); // Get MCP configuration diff --git a/src/prepare/types.ts b/src/prepare/types.ts index c064275..d53e87e 100644 --- a/src/prepare/types.ts +++ b/src/prepare/types.ts @@ -10,6 +10,7 @@ export type PrepareResult = { currentBranch: string; }; mcpConfig: string; + // TODO: Add allowedTools and disallowedTools here once modes are updated }; export type PrepareOptions = {