From e8e8fdc051c3b0181ac63f2ae0699bff48fbff69 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 23 Dec 2025 11:32:00 -0800 Subject: [PATCH] feat: merge prepare and run steps into unified entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the two-step architecture (prepare → run) into a single step to eliminate file-based and output-based communication. Changes: - Add base-action/src/lib.ts with exports for main action import - Modify run-claude-sdk.ts to accept prompt string directly and return result - Add generatePromptContent() that returns prompt without file I/O - Update mode prepare() to return promptContent in result - Create src/entrypoints/run.ts as unified entry point - Update action.yml to use single Run Claude Code step - Update output references from steps.prepare to steps.claude-code Benefits: - No file I/O for prompt - data stays in memory - No step output parsing - direct function returns - Simpler debugging - single entry point - Faster execution - no subprocess overhead - Type safety - TypeScript across the boundary 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- action.yml | 102 +++++++--------- base-action/src/lib.ts | 13 +++ base-action/src/run-claude-sdk.ts | 140 ++++++++++++++++++---- base-action/src/run-claude.ts | 4 +- src/create-prompt/index.ts | 132 +++++++++++++-------- src/entrypoints/run.ts | 186 ++++++++++++++++++++++++++++++ src/modes/agent/index.ts | 13 ++- src/modes/tag/index.ts | 20 +++- src/modes/types.ts | 6 + src/prepare/types.ts | 6 + test/modes/agent.test.ts | 1 + 11 files changed, 484 insertions(+), 139 deletions(-) create mode 100644 base-action/src/lib.ts create mode 100644 src/entrypoints/run.ts diff --git a/action.yml b/action.yml index 15a9599..7d959ad 100644 --- a/action.yml +++ b/action.yml @@ -120,10 +120,10 @@ outputs: value: ${{ steps.claude-code.outputs.execution_file }} branch_name: description: "The branch created by Claude Code for this execution" - value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} + value: ${{ steps.claude-code.outputs.CLAUDE_BRANCH }} github_token: description: "The GitHub token used by the action (Claude App token if available)" - value: ${{ steps.prepare.outputs.github_token }} + value: ${{ steps.claude-code.outputs.GITHUB_TOKEN }} structured_output: description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name" value: ${{ steps.claude-code.outputs.structured_output }} @@ -152,44 +152,15 @@ runs: echo "$BUN_DIR" >> "$GITHUB_PATH" - name: Install Dependencies - shell: bash - run: | - cd ${GITHUB_ACTION_PATH} - bun install - - - name: Prepare action - id: prepare - shell: bash - run: | - bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts - env: - MODE: ${{ inputs.mode }} - PROMPT: ${{ inputs.prompt }} - 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 }} - OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} - ALLOWED_BOTS: ${{ inputs.allowed_bots }} - ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }} - GITHUB_RUN_ID: ${{ github.run_id }} - USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} - DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} - USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} - BOT_ID: ${{ inputs.bot_id }} - BOT_NAME: ${{ inputs.bot_name }} - TRACK_PROGRESS: ${{ inputs.track_progress }} - ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} - CLAUDE_ARGS: ${{ inputs.claude_args }} - ALL_INPUTS: ${{ toJson(inputs) }} - - - name: Install Base Action Dependencies - if: steps.prepare.outputs.contains_trigger == 'true' shell: bash env: PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} run: | + # Install main action dependencies + cd ${GITHUB_ACTION_PATH} + bun install + + # Install base-action dependencies echo "Installing base-action dependencies..." cd ${GITHUB_ACTION_PATH}/base-action bun install @@ -223,31 +194,46 @@ runs: echo "$CLAUDE_DIR" >> "$GITHUB_PATH" fi + # Unified step: prepare + setup + run Claude - 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 + bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts env: - # Base-action inputs + # Action configuration CLAUDE_CODE_ACTION: "1" - INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt + MODE: ${{ inputs.mode }} + PROMPT: ${{ inputs.prompt }} + 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 }} + OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} + ALLOWED_BOTS: ${{ inputs.allowed_bots }} + ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }} + GITHUB_RUN_ID: ${{ github.run_id }} + USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} + DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} + USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} + BOT_ID: ${{ inputs.bot_id }} + BOT_NAME: ${{ inputs.bot_name }} + TRACK_PROGRESS: ${{ inputs.track_progress }} + ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} + CLAUDE_ARGS: ${{ inputs.claude_args }} + ALL_INPUTS: ${{ toJson(inputs) }} + + # Base-action inputs INPUT_SETTINGS: ${{ inputs.settings }} - INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }} + INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands - INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }} INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }} INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }} - # Model configuration - GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} - GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} NODE_VERSION: ${{ env.NODE_VERSION }} DETAILED_PERMISSION_MESSAGES: "1" @@ -287,33 +273,33 @@ runs: ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }} - name: Update comment with job link - if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() + if: steps.claude-code.outputs.contains_trigger == 'true' && steps.claude-code.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-code.outputs.claude_comment_id }} GITHUB_RUN_ID: ${{ github.run_id }} - GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} - GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.claude-code.outputs.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.claude-code.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-code.outputs.CLAUDE_BRANCH }} IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }} - BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }} + BASE_BRANCH: ${{ steps.claude-code.outputs.BASE_BRANCH }} CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }} OUTPUT_FILE: ${{ steps.claude-code.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-code.outcome == 'success' }} + PREPARE_ERROR: ${{ steps.claude-code.outputs.prepare_error || '' }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} TRACK_PROGRESS: ${{ inputs.track_progress }} - name: Display Claude Code Report - if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' + if: steps.claude-code.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' shell: bash run: | # Try to format the turns, but if it fails, dump the raw JSON @@ -330,12 +316,12 @@ runs: fi - name: Revoke app token - if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true' + if: always() && inputs.github_token == '' && steps.claude-code.outputs.skipped_due_to_workflow_validation_mismatch != 'true' shell: bash run: | curl -L \ -X DELETE \ -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \ + -H "Authorization: Bearer ${{ steps.claude-code.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/lib.ts b/base-action/src/lib.ts new file mode 100644 index 0000000..967661c --- /dev/null +++ b/base-action/src/lib.ts @@ -0,0 +1,13 @@ +/** + * Library exports for the base-action. + * These functions can be imported directly by the main action + * to avoid file/output-based communication between steps. + */ + +export { runClaudeWithSdk, runClaudeWithSdkFromFile } from "./run-claude-sdk"; +export type { RunClaudeResult, PromptInput } from "./run-claude-sdk"; +export { setupClaudeCodeSettings } from "./setup-claude-code-settings"; +export { installPlugins } from "./install-plugins"; +export { parseSdkOptions } from "./parse-sdk-options"; +export type { ClaudeOptions } from "./run-claude"; +export type { ParsedSdkOptions } from "./parse-sdk-options"; diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index 2bf0b24..e1f05da 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -9,6 +9,18 @@ import type { ParsedSdkOptions } from "./parse-sdk-options"; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; +/** + * Result of running Claude via SDK + */ +export type RunClaudeResult = { + success: boolean; + executionFile: string; + conclusion: "success" | "failure"; + structuredOutput?: string; + sessionId?: string; + error?: string; +}; + /** * Sanitizes SDK output to match CLI sanitization behavior */ @@ -57,13 +69,31 @@ function sanitizeSdkOutput( } /** - * Run Claude using the Agent SDK + * Input for runClaudeWithSdk - either a prompt string or file path + */ +export type PromptInput = + | { type: "string"; prompt: string } + | { type: "file"; promptPath: string }; + +/** + * Run Claude using the Agent SDK. + * + * @param promptInput - Either a direct prompt string or path to prompt file + * @param parsedOptions - Parsed SDK options + * @param options - Additional options + * @param options.setOutputs - Whether to set GitHub Action outputs (default: true for backwards compat) + * @returns Result of the execution */ export async function runClaudeWithSdk( - promptPath: string, + promptInput: PromptInput, { sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions, -): Promise { - const prompt = await readFile(promptPath, "utf-8"); + { setOutputs = true }: { setOutputs?: boolean } = {}, +): Promise { + // Get prompt from string or file + const prompt = + promptInput.type === "string" + ? promptInput.prompt + : await readFile(promptInput.promptPath, "utf-8"); if (!showFullOutput) { console.log( @@ -74,7 +104,13 @@ export async function runClaudeWithSdk( ); } - console.log(`Running Claude with prompt from file: ${promptPath}`); + if (promptInput.type === "file") { + console.log( + `Running Claude with prompt from file: ${promptInput.promptPath}`, + ); + } else { + console.log(`Running Claude with prompt string (${prompt.length} chars)`); + } // Log SDK options without env (which could contain sensitive data) const { env, ...optionsToLog } = sdkOptions; console.log("SDK options:", JSON.stringify(optionsToLog, null, 2)); @@ -97,27 +133,47 @@ export async function runClaudeWithSdk( } } catch (error) { console.error("SDK execution error:", error); - core.setOutput("conclusion", "failure"); - process.exit(1); + if (setOutputs) { + core.setOutput("conclusion", "failure"); + } + return { + success: false, + executionFile: EXECUTION_FILE, + conclusion: "failure", + error: String(error), + }; } // Write execution file try { await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2)); console.log(`Log saved to ${EXECUTION_FILE}`); - core.setOutput("execution_file", EXECUTION_FILE); + if (setOutputs) { + core.setOutput("execution_file", EXECUTION_FILE); + } } catch (error) { core.warning(`Failed to write execution file: ${error}`); } if (!resultMessage) { - core.setOutput("conclusion", "failure"); + if (setOutputs) { + core.setOutput("conclusion", "failure"); + } core.error("No result message received from Claude"); - process.exit(1); + return { + success: false, + executionFile: EXECUTION_FILE, + conclusion: "failure", + error: "No result message received from Claude", + }; } const isSuccess = resultMessage.subtype === "success"; - core.setOutput("conclusion", isSuccess ? "success" : "failure"); + if (setOutputs) { + core.setOutput("conclusion", isSuccess ? "success" : "failure"); + } + + let structuredOutput: string | undefined; // Handle structured output if (hasJsonSchema) { @@ -126,26 +182,64 @@ export async function runClaudeWithSdk( "structured_output" in resultMessage && resultMessage.structured_output ) { - const structuredOutputJson = JSON.stringify( - resultMessage.structured_output, - ); - core.setOutput("structured_output", structuredOutputJson); + structuredOutput = JSON.stringify(resultMessage.structured_output); + if (setOutputs) { + core.setOutput("structured_output", structuredOutput); + } core.info( `Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`, ); } else { - core.setFailed( - `--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`, - ); - core.setOutput("conclusion", "failure"); - process.exit(1); + const errorMsg = `--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`; + if (setOutputs) { + core.setFailed(errorMsg); + core.setOutput("conclusion", "failure"); + } + return { + success: false, + executionFile: EXECUTION_FILE, + conclusion: "failure", + error: errorMsg, + }; } } if (!isSuccess) { - if ("errors" in resultMessage && resultMessage.errors) { - core.error(`Execution failed: ${resultMessage.errors.join(", ")}`); - } + const errors = + "errors" in resultMessage && resultMessage.errors + ? resultMessage.errors.join(", ") + : "Unknown error"; + core.error(`Execution failed: ${errors}`); + return { + success: false, + executionFile: EXECUTION_FILE, + conclusion: "failure", + error: errors, + }; + } + + return { + success: true, + executionFile: EXECUTION_FILE, + conclusion: "success", + structuredOutput, + }; +} + +/** + * Wrapper for backwards compatibility - reads prompt from file path and exits on failure + */ +export async function runClaudeWithSdkFromFile( + promptPath: string, + parsedOptions: ParsedSdkOptions, +): Promise { + const result = await runClaudeWithSdk( + { type: "file", promptPath }, + parsedOptions, + { setOutputs: true }, + ); + + if (!result.success) { process.exit(1); } } diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index a5485a3..e1fe1f8 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -5,7 +5,7 @@ import { unlink, writeFile, stat, readFile } from "fs/promises"; import { createWriteStream } from "fs"; import { spawn } from "child_process"; import { parse as parseShellArgs } from "shell-quote"; -import { runClaudeWithSdk } from "./run-claude-sdk"; +import { runClaudeWithSdkFromFile } from "./run-claude-sdk"; import { parseSdkOptions } from "./parse-sdk-options"; const execAsync = promisify(exec); @@ -205,7 +205,7 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { if (useAgentSdk) { const parsedOptions = parseSdkOptions(options); - return runClaudeWithSdk(promptPath, parsedOptions); + return runClaudeWithSdkFromFile(promptPath, parsedOptions); } const config = prepareRunConfig(promptPath, options); diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 7a62e6e..1829b4d 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -841,6 +841,78 @@ f. If you are unable to complete certain steps, such as running a linter or test return promptContent; } +/** + * Result of generating prompt content + */ +export type PromptResult = { + promptContent: string; + allowedTools: string; + disallowedTools: string; +}; + +/** + * Generate prompt content and tool configurations. + * This function can be used directly without side effects (no file writes, no env vars). + */ +export function generatePromptContent( + mode: Mode, + modeContext: ModeContext, + githubData: FetchDataResult, + context: ParsedGitHubContext, +): PromptResult { + // Prepare the context for prompt generation + let claudeCommentId: string = ""; + if (mode.name === "tag") { + if (!modeContext.commentId) { + throw new Error( + `${mode.name} mode requires a comment ID for prompt generation`, + ); + } + claudeCommentId = modeContext.commentId.toString(); + } + + const preparedContext = prepareContext( + context, + claudeCommentId, + modeContext.baseBranch, + modeContext.claudeBranch, + ); + + // Generate the prompt directly + const promptContent = generatePrompt( + preparedContext, + githubData, + context.inputs.useCommitSigning, + mode, + ); + + // Get mode-specific tools + const modeAllowedTools = mode.getAllowedTools(); + const modeDisallowedTools = mode.getDisallowedTools(); + + const hasActionsReadPermission = false; + const allowedTools = buildAllowedToolsString( + modeAllowedTools, + hasActionsReadPermission, + context.inputs.useCommitSigning, + ); + const disallowedTools = buildDisallowedToolsString( + modeDisallowedTools, + modeAllowedTools, + ); + + return { + promptContent, + allowedTools, + disallowedTools, + }; +} + +/** + * Create prompt and write to file. + * This is the legacy function that writes files and sets environment variables. + * For the unified step, use generatePromptContent() instead. + */ export async function createPrompt( mode: Mode, modeContext: ModeContext, @@ -848,66 +920,30 @@ export async function createPrompt( context: ParsedGitHubContext, ) { try { - // Prepare the context for prompt generation - let claudeCommentId: string = ""; - if (mode.name === "tag") { - if (!modeContext.commentId) { - throw new Error( - `${mode.name} mode requires a comment ID for prompt generation`, - ); - } - claudeCommentId = modeContext.commentId.toString(); - } - - const preparedContext = prepareContext( - context, - claudeCommentId, - modeContext.baseBranch, - modeContext.claudeBranch, - ); - - await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { - recursive: true, - }); - - // Generate the prompt directly - const promptContent = generatePrompt( - preparedContext, - githubData, - context.inputs.useCommitSigning, + const result = generatePromptContent( mode, + modeContext, + githubData, + context, ); // Log the final prompt to console console.log("===== FINAL PROMPT ====="); - console.log(promptContent); + console.log(result.promptContent); console.log("======================="); // Write the prompt file + await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { + recursive: true, + }); await writeFile( `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, - promptContent, + result.promptContent, ); - // Set allowed tools - const hasActionsReadPermission = false; - - // Get mode-specific tools - const modeAllowedTools = mode.getAllowedTools(); - const modeDisallowedTools = mode.getDisallowedTools(); - - const allAllowedTools = buildAllowedToolsString( - modeAllowedTools, - hasActionsReadPermission, - context.inputs.useCommitSigning, - ); - const allDisallowedTools = buildDisallowedToolsString( - modeDisallowedTools, - modeAllowedTools, - ); - - core.exportVariable("ALLOWED_TOOLS", allAllowedTools); - core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools); + // Set environment variables + core.exportVariable("ALLOWED_TOOLS", result.allowedTools); + core.exportVariable("DISALLOWED_TOOLS", result.disallowedTools); } 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..2dcf9ed --- /dev/null +++ b/src/entrypoints/run.ts @@ -0,0 +1,186 @@ +#!/usr/bin/env bun + +/** + * Unified entry point for the Claude action. + * + * This combines the prepare and run phases into a single step, + * passing data directly in-memory instead of via files and outputs. + */ + +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 } from "../modes/registry"; +import { prepare } from "../prepare"; +import { collectActionInputsPresence } from "./collect-inputs"; +import { + runClaudeWithSdk, + setupClaudeCodeSettings, + installPlugins, + parseSdkOptions, +} from "../../base-action/src/lib"; + +async function run() { + try { + // ============================================ + // PHASE 1: PREPARE + // ============================================ + + collectActionInputsPresence(); + + // Parse GitHub context first to enable mode detection + const context = parseGitHubContext(); + + // Auto-detect mode based on context + const mode = getMode(context); + + // Setup GitHub token + const githubToken = await setupGitHubToken(); + const octokit = createOctokit(githubToken); + + // Check write permissions (only for entity contexts) + if (isEntityContext(context)) { + const githubTokenProvided = !!process.env.OVERRIDE_GITHUB_TOKEN; + const hasWritePermissions = await checkWritePermissions( + octokit.rest, + context, + context.inputs.allowedNonWriteUsers, + githubTokenProvided, + ); + if (!hasWritePermissions) { + throw new Error( + "Actor does not have write permissions to the repository", + ); + } + } + + // Check trigger conditions + const containsTrigger = mode.shouldTrigger(context); + + // Debug logging + console.log(`Mode: ${mode.name}`); + console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`); + console.log(`Trigger result: ${containsTrigger}`); + + // Set output for action.yml to check + core.setOutput("contains_trigger", containsTrigger.toString()); + + if (!containsTrigger) { + console.log("No trigger found, skipping remaining steps"); + core.setOutput("GITHUB_TOKEN", githubToken); + return; + } + + // Run mode.prepare() - returns prompt, commentId, branchInfo, etc. + const prepareResult = await prepare({ + context, + octokit, + mode, + githubToken, + }); + + // Set outputs that may be needed by subsequent steps + core.setOutput("GITHUB_TOKEN", githubToken); + if (prepareResult.commentId) { + core.setOutput("claude_comment_id", prepareResult.commentId.toString()); + } + core.setOutput( + "CLAUDE_BRANCH", + prepareResult.branchInfo.claudeBranch || "", + ); + core.setOutput("BASE_BRANCH", prepareResult.branchInfo.baseBranch); + + // Get system prompt from mode if available + let appendSystemPrompt: string | undefined; + if (mode.getSystemPrompt) { + const modeContext = mode.prepareContext(context, { + commentId: prepareResult.commentId, + baseBranch: prepareResult.branchInfo.baseBranch, + claudeBranch: prepareResult.branchInfo.claudeBranch, + }); + appendSystemPrompt = mode.getSystemPrompt(modeContext); + } + + // ============================================ + // PHASE 2: SETUP + // ============================================ + + // Setup Claude Code settings + await setupClaudeCodeSettings( + process.env.INPUT_SETTINGS, + undefined, // homeDir + ); + + // Install Claude Code plugins if specified + await installPlugins( + process.env.INPUT_PLUGIN_MARKETPLACES, + process.env.INPUT_PLUGINS, + process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, + ); + + // ============================================ + // PHASE 3: EXECUTE + // ============================================ + + // Get prompt content from prepare result + const promptContent = prepareResult.promptContent; + if (!promptContent) { + throw new Error("No prompt content generated by prepare phase"); + } + + console.log("===== PROMPT CONTENT ====="); + console.log(`Prompt length: ${promptContent.length} chars`); + console.log("=========================="); + + // Build SDK options from environment and prepare result + const sdkOptions = parseSdkOptions({ + claudeArgs: process.env.INPUT_CLAUDE_ARGS, + allowedTools: + prepareResult.allowedTools || process.env.INPUT_ALLOWED_TOOLS, + disallowedTools: + prepareResult.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: + appendSystemPrompt || process.env.INPUT_APPEND_SYSTEM_PROMPT, + claudeEnv: process.env.INPUT_CLAUDE_ENV, + fallbackModel: process.env.INPUT_FALLBACK_MODEL, + model: process.env.ANTHROPIC_MODEL, + pathToClaudeCodeExecutable: + process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, + showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT, + }); + + // Run Claude with prompt string directly + const execResult = await runClaudeWithSdk( + { type: "string", prompt: promptContent }, + sdkOptions, + { setOutputs: true }, + ); + + // Set additional outputs + core.setOutput("execution_file", execResult.executionFile); + core.setOutput("conclusion", execResult.conclusion); + if (execResult.structuredOutput) { + core.setOutput("structured_output", execResult.structuredOutput); + } + + if (!execResult.success) { + core.setFailed(`Claude execution failed: ${execResult.error}`); + process.exit(1); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.setFailed(`Run step failed with error: ${errorMessage}`); + core.setOutput("prepare_error", errorMessage); + core.setOutput("conclusion", "failure"); + process.exit(1); + } +} + +if (import.meta.main) { + run(); +} diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 4bcd4aa..e02df6c 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -95,16 +95,15 @@ export const agentMode: Mode = { } } - // Create prompt directory - await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { - recursive: true, - }); - - // Write the prompt file - use the user's prompt directly + // Generate prompt content - use the user's prompt directly const promptContent = context.inputs.prompt || `Repository: ${context.repository.owner}/${context.repository.repo}`; + // Also write file for backwards compatibility with current flow + await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { + recursive: true, + }); await writeFile( `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, promptContent, @@ -162,6 +161,8 @@ export const agentMode: Mode = { claudeBranch: claudeBranch, }, mcpConfig: ourMcpConfig, + promptContent, + // Agent mode doesn't use the same allowed/disallowed tools mechanism as tag mode }; }, diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index be7df09..77176a3 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -10,7 +10,11 @@ import { fetchGitHubData, extractTriggerTimestamp, } from "../../github/data/fetcher"; -import { createPrompt, generateDefaultPrompt } from "../../create-prompt"; +import { + createPrompt, + generateDefaultPrompt, + generatePromptContent, +} from "../../create-prompt"; import { isEntityContext } from "../../github/context"; import type { PreparedContext } from "../../create-prompt/types"; import type { FetchDataResult } from "../../github/data/fetcher"; @@ -104,13 +108,22 @@ export const tagMode: Mode = { } } - // Create prompt file + // Create prompt const modeContext = this.prepareContext(context, { commentId, baseBranch: branchInfo.baseBranch, claudeBranch: branchInfo.claudeBranch, }); + // Generate prompt content - returns data instead of writing file + const promptResult = generatePromptContent( + tagMode, + modeContext, + githubData, + context, + ); + + // Also write file for backwards compatibility with current flow await createPrompt(tagMode, modeContext, githubData, context); const userClaudeArgs = process.env.CLAUDE_ARGS || ""; @@ -188,6 +201,9 @@ export const tagMode: Mode = { commentId, branchInfo, mcpConfig: ourMcpConfig, + promptContent: promptResult.promptContent, + allowedTools: promptResult.allowedTools, + disallowedTools: promptResult.disallowedTools, }; }, diff --git a/src/modes/types.ts b/src/modes/types.ts index 1f5069a..451e039 100644 --- a/src/modes/types.ts +++ b/src/modes/types.ts @@ -97,4 +97,10 @@ export type ModeResult = { currentBranch: string; }; mcpConfig: string; + /** Generated prompt content for Claude */ + promptContent?: string; + /** Comma-separated list of allowed tools */ + allowedTools?: string; + /** Comma-separated list of disallowed tools */ + disallowedTools?: string; }; diff --git a/src/prepare/types.ts b/src/prepare/types.ts index c064275..e3e24b9 100644 --- a/src/prepare/types.ts +++ b/src/prepare/types.ts @@ -10,6 +10,12 @@ export type PrepareResult = { currentBranch: string; }; mcpConfig: string; + /** Generated prompt content for Claude */ + promptContent?: string; + /** Comma-separated list of allowed tools */ + allowedTools?: string; + /** Comma-separated list of disallowed tools */ + disallowedTools?: string; }; export type PrepareOptions = { diff --git a/test/modes/agent.test.ts b/test/modes/agent.test.ts index 16e3796..370e4d2 100644 --- a/test/modes/agent.test.ts +++ b/test/modes/agent.test.ts @@ -177,6 +177,7 @@ describe("Agent Mode", () => { claudeBranch: undefined, }, mcpConfig: expect.any(String), + promptContent: expect.any(String), }); // Clean up