feat: send user request as separate content block for slash command support (#785)

* feat: send user request as separate content block for slash command support

When in tag mode with the SDK path, extracts the user's request from the
trigger comment (text after @claude) and sends it as a separate content
block. This enables the CLI to process slash commands like "/review-pr".

- Add extract-user-request utility to parse trigger comments
- Write user request to separate file during prompt generation
- Send multi-block SDKUserMessage when user request file exists
- Add tests for the extraction utility

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

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

* fix: address PR feedback

- Fix potential ReDoS vulnerability by using string operations instead of regex
- Remove unused extractUserRequestFromEvent function and tests
- Extract USER_REQUEST_FILENAME to shared constants
- Conditionally log user request based on showFullOutput setting
- Add JSDoc documentation to extractUserRequestFromContext

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ashwin Bhat
2026-01-02 17:57:13 -08:00
committed by GitHub
parent 7e4bf87b1c
commit b17b541bbc
4 changed files with 248 additions and 2 deletions

View File

@@ -1,14 +1,81 @@
import * as core from "@actions/core"; 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 { query } from "@anthropic-ai/claude-agent-sdk";
import type { import type {
SDKMessage, SDKMessage,
SDKResultMessage, SDKResultMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk"; } from "@anthropic-ai/claude-agent-sdk";
import type { ParsedSdkOptions } from "./parse-sdk-options"; import type { ParsedSdkOptions } from "./parse-sdk-options";
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; 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 * Sanitizes SDK output to match CLI sanitization behavior
*/ */
@@ -63,7 +130,8 @@ export async function runClaudeWithSdk(
promptPath: string, promptPath: string,
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions, { sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
): Promise<void> { ): 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) { if (!showFullOutput) {
console.log( console.log(

View File

@@ -21,8 +21,12 @@ import type { ParsedGitHubContext } from "../github/context";
import type { CommonFields, PreparedContext, EventData } from "./types"; import type { CommonFields, PreparedContext, EventData } from "./types";
import { GITHUB_SERVER_URL } from "../github/api/config"; import { GITHUB_SERVER_URL } from "../github/api/config";
import type { Mode, ModeContext } from "../modes/types"; import type { Mode, ModeContext } from "../modes/types";
import { extractUserRequest } from "../utils/extract-user-request";
export type { CommonFields, PreparedContext } from "./types"; 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 // Tag mode defaults - these tools are needed for tag mode to function
const BASE_ALLOWED_TOOLS = [ const BASE_ALLOWED_TOOLS = [
"Edit", "Edit",
@@ -847,6 +851,55 @@ f. If you are unable to complete certain steps, such as running a linter or test
return promptContent; 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( export async function createPrompt(
mode: Mode, mode: Mode,
modeContext: ModeContext, modeContext: ModeContext,
@@ -895,6 +948,22 @@ export async function createPrompt(
promptContent, 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 // Set allowed tools
const hasActionsReadPermission = false; const hasActionsReadPermission = false;

View File

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

View File

@@ -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",
);
});
});