mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
* 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>
220 lines
6.4 KiB
TypeScript
220 lines
6.4 KiB
TypeScript
import * as core from "@actions/core";
|
|
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
|
|
*/
|
|
function sanitizeSdkOutput(
|
|
message: SDKMessage,
|
|
showFullOutput: boolean,
|
|
): string | null {
|
|
if (showFullOutput) {
|
|
return JSON.stringify(message, null, 2);
|
|
}
|
|
|
|
// System initialization - safe to show
|
|
if (message.type === "system" && message.subtype === "init") {
|
|
return JSON.stringify(
|
|
{
|
|
type: "system",
|
|
subtype: "init",
|
|
message: "Claude Code initialized",
|
|
model: "model" in message ? message.model : "unknown",
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
// Result messages - show sanitized summary
|
|
if (message.type === "result") {
|
|
const resultMsg = message as SDKResultMessage;
|
|
return JSON.stringify(
|
|
{
|
|
type: "result",
|
|
subtype: resultMsg.subtype,
|
|
is_error: resultMsg.is_error,
|
|
duration_ms: resultMsg.duration_ms,
|
|
num_turns: resultMsg.num_turns,
|
|
total_cost_usd: resultMsg.total_cost_usd,
|
|
permission_denials: resultMsg.permission_denials,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
// Suppress other message types in non-full-output mode
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Run Claude using the Agent SDK
|
|
*/
|
|
export async function runClaudeWithSdk(
|
|
promptPath: string,
|
|
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
|
): Promise<void> {
|
|
// Create prompt configuration - may be a string or multi-block message
|
|
const prompt = await createPromptConfig(promptPath, showFullOutput);
|
|
|
|
if (!showFullOutput) {
|
|
console.log(
|
|
"Running Claude Code via SDK (full output hidden for security)...",
|
|
);
|
|
console.log(
|
|
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
|
);
|
|
}
|
|
|
|
console.log(`Running Claude with prompt from file: ${promptPath}`);
|
|
// Log SDK options without env (which could contain sensitive data)
|
|
const { env, ...optionsToLog } = sdkOptions;
|
|
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
|
|
|
|
const messages: SDKMessage[] = [];
|
|
let resultMessage: SDKResultMessage | undefined;
|
|
|
|
try {
|
|
for await (const message of query({ prompt, options: sdkOptions })) {
|
|
messages.push(message);
|
|
|
|
const sanitized = sanitizeSdkOutput(message, showFullOutput);
|
|
if (sanitized) {
|
|
console.log(sanitized);
|
|
}
|
|
|
|
if (message.type === "result") {
|
|
resultMessage = message as SDKResultMessage;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("SDK execution error:", error);
|
|
core.setOutput("conclusion", "failure");
|
|
process.exit(1);
|
|
}
|
|
|
|
// 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);
|
|
} catch (error) {
|
|
core.warning(`Failed to write execution file: ${error}`);
|
|
}
|
|
|
|
if (!resultMessage) {
|
|
core.setOutput("conclusion", "failure");
|
|
core.error("No result message received from Claude");
|
|
process.exit(1);
|
|
}
|
|
|
|
const isSuccess = resultMessage.subtype === "success";
|
|
core.setOutput("conclusion", isSuccess ? "success" : "failure");
|
|
|
|
// Handle structured output
|
|
if (hasJsonSchema) {
|
|
if (
|
|
isSuccess &&
|
|
"structured_output" in resultMessage &&
|
|
resultMessage.structured_output
|
|
) {
|
|
const structuredOutputJson = JSON.stringify(
|
|
resultMessage.structured_output,
|
|
);
|
|
core.setOutput("structured_output", structuredOutputJson);
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (!isSuccess) {
|
|
if ("errors" in resultMessage && resultMessage.errors) {
|
|
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|