Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions base-action/src/run-claude-sdk.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string | AsyncIterable<SDKUserMessage>> {
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<SDKUserMessage> {
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
*/
Expand Down Expand Up @@ -63,7 +130,8 @@ export async function runClaudeWithSdk(
promptPath: string,
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
): Promise<void> {
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(
Expand Down
69 changes: 69 additions & 0 deletions src/create-prompt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down
32 changes: 32 additions & 0 deletions src/utils/extract-user-request.ts
Original file line number Diff line number Diff line change
@@ -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;
}
77 changes: 77 additions & 0 deletions test/extract-user-request.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Loading