diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index 2bf0b2478..64758c61d 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -1,14 +1,81 @@ import * as core from "@actions/core"; -import { readFile, writeFile } from "fs/promises"; +import { readFile, writeFile, access } from "fs/promises"; +import { dirname, join } from "path"; import { query } from "@anthropic-ai/claude-agent-sdk"; import type { SDKMessage, SDKResultMessage, + SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import type { ParsedSdkOptions } from "./parse-sdk-options"; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; +/** Filename for the user request file, written by prompt generation */ +const USER_REQUEST_FILENAME = "claude-user-request.txt"; + +/** + * Check if a file exists + */ +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * Creates a prompt configuration for the SDK. + * If a user request file exists alongside the prompt file, returns a multi-block + * SDKUserMessage that enables slash command processing in the CLI. + * Otherwise, returns the prompt as a simple string. + */ +async function createPromptConfig( + promptPath: string, + showFullOutput: boolean, +): Promise> { + const promptContent = await readFile(promptPath, "utf-8"); + + // Check for user request file in the same directory + const userRequestPath = join(dirname(promptPath), USER_REQUEST_FILENAME); + const hasUserRequest = await fileExists(userRequestPath); + + if (!hasUserRequest) { + // No user request file - use simple string prompt + return promptContent; + } + + // User request file exists - create multi-block message + const userRequest = await readFile(userRequestPath, "utf-8"); + if (showFullOutput) { + console.log("Using multi-block message with user request:", userRequest); + } else { + console.log("Using multi-block message with user request (content hidden)"); + } + + // Create an async generator that yields a single multi-block message + // The context/instructions go first, then the user's actual request last + // This allows the CLI to detect and process slash commands in the user request + async function* createMultiBlockMessage(): AsyncGenerator { + yield { + type: "user", + session_id: "", + message: { + role: "user", + content: [ + { type: "text", text: promptContent }, // Instructions + GitHub context + { type: "text", text: userRequest }, // User's request (may be a slash command) + ], + }, + parent_tool_use_id: null, + }; + } + + return createMultiBlockMessage(); +} + /** * Sanitizes SDK output to match CLI sanitization behavior */ @@ -63,7 +130,8 @@ export async function runClaudeWithSdk( promptPath: string, { sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions, ): Promise { - const prompt = await readFile(promptPath, "utf-8"); + // Create prompt configuration - may be a string or multi-block message + const prompt = await createPromptConfig(promptPath, showFullOutput); if (!showFullOutput) { console.log( diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 1b2be9107..66149eac3 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -21,8 +21,12 @@ import type { ParsedGitHubContext } from "../github/context"; import type { CommonFields, PreparedContext, EventData } from "./types"; import { GITHUB_SERVER_URL } from "../github/api/config"; import type { Mode, ModeContext } from "../modes/types"; +import { extractUserRequest } from "../utils/extract-user-request"; export type { CommonFields, PreparedContext } from "./types"; +/** Filename for the user request file, read by the SDK runner */ +const USER_REQUEST_FILENAME = "claude-user-request.txt"; + // Tag mode defaults - these tools are needed for tag mode to function const BASE_ALLOWED_TOOLS = [ "Edit", @@ -847,6 +851,55 @@ f. If you are unable to complete certain steps, such as running a linter or test return promptContent; } +/** + * Extracts the user's request from the prepared context and GitHub data. + * + * This is used to send the user's actual command/request as a separate + * content block, enabling slash command processing in the CLI. + * + * @param context - The prepared context containing event data and trigger phrase + * @param githubData - The fetched GitHub data containing issue/PR body content + * @returns The extracted user request text (e.g., "/review-pr" or "fix this bug"), + * or null for assigned/labeled events without an explicit trigger in the body + * + * @example + * // Comment event: "@claude /review-pr" -> returns "/review-pr" + * // Issue body with "@claude fix this" -> returns "fix this" + * // Issue assigned without @claude in body -> returns null + */ +function extractUserRequestFromContext( + context: PreparedContext, + githubData: FetchDataResult, +): string | null { + const { eventData, triggerPhrase } = context; + + // For comment events, extract from comment body + if ( + "commentBody" in eventData && + eventData.commentBody && + (eventData.eventName === "issue_comment" || + eventData.eventName === "pull_request_review_comment" || + eventData.eventName === "pull_request_review") + ) { + return extractUserRequest(eventData.commentBody, triggerPhrase); + } + + // For issue/PR events triggered by body content, extract from the body + if (githubData.contextData?.body) { + const request = extractUserRequest( + githubData.contextData.body, + triggerPhrase, + ); + if (request) { + return request; + } + } + + // For assigned/labeled events without explicit trigger in body, + // return null to indicate the full context should be used + return null; +} + export async function createPrompt( mode: Mode, modeContext: ModeContext, @@ -895,6 +948,22 @@ export async function createPrompt( promptContent, ); + // Extract and write the user request separately for SDK multi-block messaging + // This allows the CLI to process slash commands (e.g., "@claude /review-pr") + const userRequest = extractUserRequestFromContext( + preparedContext, + githubData, + ); + if (userRequest) { + await writeFile( + `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/${USER_REQUEST_FILENAME}`, + userRequest, + ); + console.log("===== USER REQUEST ====="); + console.log(userRequest); + console.log("========================"); + } + // Set allowed tools const hasActionsReadPermission = false; diff --git a/src/utils/extract-user-request.ts b/src/utils/extract-user-request.ts new file mode 100644 index 000000000..6035a946c --- /dev/null +++ b/src/utils/extract-user-request.ts @@ -0,0 +1,32 @@ +/** + * Extracts the user's request from a trigger comment. + * + * Given a comment like "@claude /review-pr please check the auth module", + * this extracts "/review-pr please check the auth module". + * + * @param commentBody - The full comment body containing the trigger phrase + * @param triggerPhrase - The trigger phrase (e.g., "@claude") + * @returns The user's request (text after the trigger phrase), or null if not found + */ +export function extractUserRequest( + commentBody: string | undefined, + triggerPhrase: string, +): string | null { + if (!commentBody) { + return null; + } + + // Use string operations instead of regex for better performance and security + // (avoids potential ReDoS with large comment bodies) + const triggerIndex = commentBody + .toLowerCase() + .indexOf(triggerPhrase.toLowerCase()); + if (triggerIndex === -1) { + return null; + } + + const afterTrigger = commentBody + .substring(triggerIndex + triggerPhrase.length) + .trim(); + return afterTrigger || null; +} diff --git a/test/extract-user-request.test.ts b/test/extract-user-request.test.ts new file mode 100644 index 000000000..34246a6bf --- /dev/null +++ b/test/extract-user-request.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect } from "bun:test"; +import { extractUserRequest } from "../src/utils/extract-user-request"; + +describe("extractUserRequest", () => { + test("extracts text after @claude trigger", () => { + expect(extractUserRequest("@claude /review-pr", "@claude")).toBe( + "/review-pr", + ); + }); + + test("extracts slash command with arguments", () => { + expect( + extractUserRequest( + "@claude /review-pr please check the auth module", + "@claude", + ), + ).toBe("/review-pr please check the auth module"); + }); + + test("handles trigger phrase with extra whitespace", () => { + expect(extractUserRequest("@claude /review-pr", "@claude")).toBe( + "/review-pr", + ); + }); + + test("handles trigger phrase at start of multiline comment", () => { + const comment = `@claude /review-pr +Please review this PR carefully. +Focus on security issues.`; + expect(extractUserRequest(comment, "@claude")).toBe( + `/review-pr +Please review this PR carefully. +Focus on security issues.`, + ); + }); + + test("handles trigger phrase in middle of text", () => { + expect( + extractUserRequest("Hey team, @claude can you review this?", "@claude"), + ).toBe("can you review this?"); + }); + + test("returns null for empty comment body", () => { + expect(extractUserRequest("", "@claude")).toBeNull(); + }); + + test("returns null for undefined comment body", () => { + expect(extractUserRequest(undefined, "@claude")).toBeNull(); + }); + + test("returns null when trigger phrase not found", () => { + expect(extractUserRequest("Please review this PR", "@claude")).toBeNull(); + }); + + test("returns null when only trigger phrase with no request", () => { + expect(extractUserRequest("@claude", "@claude")).toBeNull(); + }); + + test("handles custom trigger phrase", () => { + expect(extractUserRequest("/claude help me", "/claude")).toBe("help me"); + }); + + test("handles trigger phrase with special regex characters", () => { + expect( + extractUserRequest("@claude[bot] do something", "@claude[bot]"), + ).toBe("do something"); + }); + + test("is case insensitive", () => { + expect(extractUserRequest("@CLAUDE /review-pr", "@claude")).toBe( + "/review-pr", + ); + expect(extractUserRequest("@Claude /review-pr", "@claude")).toBe( + "/review-pr", + ); + }); +});