Skip to content

Commit b17b541

Browse files
ashwin-antclaude
andauthored
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 <[email protected]> * 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 <[email protected]>
1 parent 7e4bf87 commit b17b541

File tree

4 files changed

+248
-2
lines changed

4 files changed

+248
-2
lines changed

base-action/src/run-claude-sdk.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,81 @@
11
import * as core from "@actions/core";
2-
import { readFile, writeFile } from "fs/promises";
2+
import { readFile, writeFile, access } from "fs/promises";
3+
import { dirname, join } from "path";
34
import { query } from "@anthropic-ai/claude-agent-sdk";
45
import type {
56
SDKMessage,
67
SDKResultMessage,
8+
SDKUserMessage,
79
} from "@anthropic-ai/claude-agent-sdk";
810
import type { ParsedSdkOptions } from "./parse-sdk-options";
911

1012
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
1113

14+
/** Filename for the user request file, written by prompt generation */
15+
const USER_REQUEST_FILENAME = "claude-user-request.txt";
16+
17+
/**
18+
* Check if a file exists
19+
*/
20+
async function fileExists(path: string): Promise<boolean> {
21+
try {
22+
await access(path);
23+
return true;
24+
} catch {
25+
return false;
26+
}
27+
}
28+
29+
/**
30+
* Creates a prompt configuration for the SDK.
31+
* If a user request file exists alongside the prompt file, returns a multi-block
32+
* SDKUserMessage that enables slash command processing in the CLI.
33+
* Otherwise, returns the prompt as a simple string.
34+
*/
35+
async function createPromptConfig(
36+
promptPath: string,
37+
showFullOutput: boolean,
38+
): Promise<string | AsyncIterable<SDKUserMessage>> {
39+
const promptContent = await readFile(promptPath, "utf-8");
40+
41+
// Check for user request file in the same directory
42+
const userRequestPath = join(dirname(promptPath), USER_REQUEST_FILENAME);
43+
const hasUserRequest = await fileExists(userRequestPath);
44+
45+
if (!hasUserRequest) {
46+
// No user request file - use simple string prompt
47+
return promptContent;
48+
}
49+
50+
// User request file exists - create multi-block message
51+
const userRequest = await readFile(userRequestPath, "utf-8");
52+
if (showFullOutput) {
53+
console.log("Using multi-block message with user request:", userRequest);
54+
} else {
55+
console.log("Using multi-block message with user request (content hidden)");
56+
}
57+
58+
// Create an async generator that yields a single multi-block message
59+
// The context/instructions go first, then the user's actual request last
60+
// This allows the CLI to detect and process slash commands in the user request
61+
async function* createMultiBlockMessage(): AsyncGenerator<SDKUserMessage> {
62+
yield {
63+
type: "user",
64+
session_id: "",
65+
message: {
66+
role: "user",
67+
content: [
68+
{ type: "text", text: promptContent }, // Instructions + GitHub context
69+
{ type: "text", text: userRequest }, // User's request (may be a slash command)
70+
],
71+
},
72+
parent_tool_use_id: null,
73+
};
74+
}
75+
76+
return createMultiBlockMessage();
77+
}
78+
1279
/**
1380
* Sanitizes SDK output to match CLI sanitization behavior
1481
*/
@@ -63,7 +130,8 @@ export async function runClaudeWithSdk(
63130
promptPath: string,
64131
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
65132
): Promise<void> {
66-
const prompt = await readFile(promptPath, "utf-8");
133+
// Create prompt configuration - may be a string or multi-block message
134+
const prompt = await createPromptConfig(promptPath, showFullOutput);
67135

68136
if (!showFullOutput) {
69137
console.log(

src/create-prompt/index.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ import type { ParsedGitHubContext } from "../github/context";
2121
import type { CommonFields, PreparedContext, EventData } from "./types";
2222
import { GITHUB_SERVER_URL } from "../github/api/config";
2323
import type { Mode, ModeContext } from "../modes/types";
24+
import { extractUserRequest } from "../utils/extract-user-request";
2425
export type { CommonFields, PreparedContext } from "./types";
2526

27+
/** Filename for the user request file, read by the SDK runner */
28+
const USER_REQUEST_FILENAME = "claude-user-request.txt";
29+
2630
// Tag mode defaults - these tools are needed for tag mode to function
2731
const BASE_ALLOWED_TOOLS = [
2832
"Edit",
@@ -847,6 +851,55 @@ f. If you are unable to complete certain steps, such as running a linter or test
847851
return promptContent;
848852
}
849853

854+
/**
855+
* Extracts the user's request from the prepared context and GitHub data.
856+
*
857+
* This is used to send the user's actual command/request as a separate
858+
* content block, enabling slash command processing in the CLI.
859+
*
860+
* @param context - The prepared context containing event data and trigger phrase
861+
* @param githubData - The fetched GitHub data containing issue/PR body content
862+
* @returns The extracted user request text (e.g., "/review-pr" or "fix this bug"),
863+
* or null for assigned/labeled events without an explicit trigger in the body
864+
*
865+
* @example
866+
* // Comment event: "@claude /review-pr" -> returns "/review-pr"
867+
* // Issue body with "@claude fix this" -> returns "fix this"
868+
* // Issue assigned without @claude in body -> returns null
869+
*/
870+
function extractUserRequestFromContext(
871+
context: PreparedContext,
872+
githubData: FetchDataResult,
873+
): string | null {
874+
const { eventData, triggerPhrase } = context;
875+
876+
// For comment events, extract from comment body
877+
if (
878+
"commentBody" in eventData &&
879+
eventData.commentBody &&
880+
(eventData.eventName === "issue_comment" ||
881+
eventData.eventName === "pull_request_review_comment" ||
882+
eventData.eventName === "pull_request_review")
883+
) {
884+
return extractUserRequest(eventData.commentBody, triggerPhrase);
885+
}
886+
887+
// For issue/PR events triggered by body content, extract from the body
888+
if (githubData.contextData?.body) {
889+
const request = extractUserRequest(
890+
githubData.contextData.body,
891+
triggerPhrase,
892+
);
893+
if (request) {
894+
return request;
895+
}
896+
}
897+
898+
// For assigned/labeled events without explicit trigger in body,
899+
// return null to indicate the full context should be used
900+
return null;
901+
}
902+
850903
export async function createPrompt(
851904
mode: Mode,
852905
modeContext: ModeContext,
@@ -895,6 +948,22 @@ export async function createPrompt(
895948
promptContent,
896949
);
897950

951+
// Extract and write the user request separately for SDK multi-block messaging
952+
// This allows the CLI to process slash commands (e.g., "@claude /review-pr")
953+
const userRequest = extractUserRequestFromContext(
954+
preparedContext,
955+
githubData,
956+
);
957+
if (userRequest) {
958+
await writeFile(
959+
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/${USER_REQUEST_FILENAME}`,
960+
userRequest,
961+
);
962+
console.log("===== USER REQUEST =====");
963+
console.log(userRequest);
964+
console.log("========================");
965+
}
966+
898967
// Set allowed tools
899968
const hasActionsReadPermission = false;
900969

src/utils/extract-user-request.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Extracts the user's request from a trigger comment.
3+
*
4+
* Given a comment like "@claude /review-pr please check the auth module",
5+
* this extracts "/review-pr please check the auth module".
6+
*
7+
* @param commentBody - The full comment body containing the trigger phrase
8+
* @param triggerPhrase - The trigger phrase (e.g., "@claude")
9+
* @returns The user's request (text after the trigger phrase), or null if not found
10+
*/
11+
export function extractUserRequest(
12+
commentBody: string | undefined,
13+
triggerPhrase: string,
14+
): string | null {
15+
if (!commentBody) {
16+
return null;
17+
}
18+
19+
// Use string operations instead of regex for better performance and security
20+
// (avoids potential ReDoS with large comment bodies)
21+
const triggerIndex = commentBody
22+
.toLowerCase()
23+
.indexOf(triggerPhrase.toLowerCase());
24+
if (triggerIndex === -1) {
25+
return null;
26+
}
27+
28+
const afterTrigger = commentBody
29+
.substring(triggerIndex + triggerPhrase.length)
30+
.trim();
31+
return afterTrigger || null;
32+
}

test/extract-user-request.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { extractUserRequest } from "../src/utils/extract-user-request";
3+
4+
describe("extractUserRequest", () => {
5+
test("extracts text after @claude trigger", () => {
6+
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
7+
"/review-pr",
8+
);
9+
});
10+
11+
test("extracts slash command with arguments", () => {
12+
expect(
13+
extractUserRequest(
14+
"@claude /review-pr please check the auth module",
15+
"@claude",
16+
),
17+
).toBe("/review-pr please check the auth module");
18+
});
19+
20+
test("handles trigger phrase with extra whitespace", () => {
21+
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
22+
"/review-pr",
23+
);
24+
});
25+
26+
test("handles trigger phrase at start of multiline comment", () => {
27+
const comment = `@claude /review-pr
28+
Please review this PR carefully.
29+
Focus on security issues.`;
30+
expect(extractUserRequest(comment, "@claude")).toBe(
31+
`/review-pr
32+
Please review this PR carefully.
33+
Focus on security issues.`,
34+
);
35+
});
36+
37+
test("handles trigger phrase in middle of text", () => {
38+
expect(
39+
extractUserRequest("Hey team, @claude can you review this?", "@claude"),
40+
).toBe("can you review this?");
41+
});
42+
43+
test("returns null for empty comment body", () => {
44+
expect(extractUserRequest("", "@claude")).toBeNull();
45+
});
46+
47+
test("returns null for undefined comment body", () => {
48+
expect(extractUserRequest(undefined, "@claude")).toBeNull();
49+
});
50+
51+
test("returns null when trigger phrase not found", () => {
52+
expect(extractUserRequest("Please review this PR", "@claude")).toBeNull();
53+
});
54+
55+
test("returns null when only trigger phrase with no request", () => {
56+
expect(extractUserRequest("@claude", "@claude")).toBeNull();
57+
});
58+
59+
test("handles custom trigger phrase", () => {
60+
expect(extractUserRequest("/claude help me", "/claude")).toBe("help me");
61+
});
62+
63+
test("handles trigger phrase with special regex characters", () => {
64+
expect(
65+
extractUserRequest("@claude[bot] do something", "@claude[bot]"),
66+
).toBe("do something");
67+
});
68+
69+
test("is case insensitive", () => {
70+
expect(extractUserRequest("@CLAUDE /review-pr", "@claude")).toBe(
71+
"/review-pr",
72+
);
73+
expect(extractUserRequest("@Claude /review-pr", "@claude")).toBe(
74+
"/review-pr",
75+
);
76+
});
77+
});

0 commit comments

Comments
 (0)