mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
feat: integrate Claude Code SDK to replace process spawning (#327)
* feat: integrate Claude Code SDK to replace process spawning - Add @anthropic-ai/claude-code dependency to base-action - Replace mkfifo/cat process spawning with direct SDK usage - Remove global Claude Code installation from action.yml files - Maintain full compatibility with existing options - Add comprehensive tests for SDK integration This change makes the implementation cleaner and more reliable by eliminating the complexity of managing child processes and named pipes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add debugging and bun executable for Claude Code SDK - Add stderr handler to capture CLI errors - Explicitly set bun as the executable for the SDK - This should help diagnose why the CLI is exiting with code 1 * fix: extract mcpServers from parsed MCP config The SDK expects just the servers object, not the wrapper object with mcpServers property. * tsc --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -192,9 +192,6 @@ runs:
|
|||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# Install Claude Code globally
|
|
||||||
bun install -g @anthropic-ai/claude-code@1.0.57
|
|
||||||
|
|
||||||
# Run the base-action
|
# Run the base-action
|
||||||
cd ${GITHUB_ACTION_PATH}/base-action
|
cd ${GITHUB_ACTION_PATH}/base-action
|
||||||
bun install
|
bun install
|
||||||
|
|||||||
@@ -113,10 +113,6 @@ runs:
|
|||||||
cd ${GITHUB_ACTION_PATH}
|
cd ${GITHUB_ACTION_PATH}
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
- name: Install Claude Code
|
|
||||||
shell: bash
|
|
||||||
run: npm install -g @anthropic-ai/claude-code@1.0.58
|
|
||||||
|
|
||||||
- name: Run Claude Code Action
|
- name: Run Claude Code Action
|
||||||
shell: bash
|
shell: bash
|
||||||
id: run_claude
|
id: run_claude
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"name": "@anthropic-ai/claude-code-base-action",
|
"name": "@anthropic-ai/claude-code-base-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
|
"@anthropic-ai/claude-code": "1.0.58",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.12",
|
"@types/bun": "^1.2.12",
|
||||||
@@ -23,8 +24,32 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
|
"@anthropic-ai/claude-code": ["@anthropic-ai/claude-code@1.0.58", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "bin": { "claude": "cli.js" } }, "sha512-XcfqklHSCuBRpVV9vZaAGvdJFAyVKb/UHz2VG9osvn1pRqY7e+HhIOU9X7LeI+c116QhmjglGwe+qz4jOC83CQ=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="],
|
"@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.17.32", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw=="],
|
"@types/node": ["@types/node@20.17.32", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw=="],
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1"
|
"@actions/core": "^1.10.1",
|
||||||
|
"@anthropic-ai/claude-code": "1.0.58"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.12",
|
"@types/bun": "^1.2.12",
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { exec } from "child_process";
|
import { writeFile } from "fs/promises";
|
||||||
import { promisify } from "util";
|
import {
|
||||||
import { unlink, writeFile, stat } from "fs/promises";
|
query,
|
||||||
import { createWriteStream } from "fs";
|
type SDKMessage,
|
||||||
import { spawn } from "child_process";
|
type Options,
|
||||||
|
} from "@anthropic-ai/claude-code";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
|
|
||||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||||
const BASE_ARGS = ["-p", "--verbose", "--output-format", "stream-json"];
|
|
||||||
|
|
||||||
export type ClaudeOptions = {
|
export type ClaudeOptions = {
|
||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
@@ -24,13 +21,7 @@ export type ClaudeOptions = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreparedConfig = {
|
export function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
|
||||||
claudeArgs: string[];
|
|
||||||
promptPath: string;
|
|
||||||
env: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
|
|
||||||
if (!claudeEnv || claudeEnv.trim() === "") {
|
if (!claudeEnv || claudeEnv.trim() === "") {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -62,18 +53,57 @@ function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
|
|||||||
return customEnv;
|
return customEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareRunConfig(
|
export function parseTools(toolsString?: string): string[] | undefined {
|
||||||
promptPath: string,
|
if (!toolsString || toolsString.trim() === "") {
|
||||||
options: ClaudeOptions,
|
return undefined;
|
||||||
): PreparedConfig {
|
}
|
||||||
const claudeArgs = [...BASE_ARGS];
|
return toolsString
|
||||||
|
.split(",")
|
||||||
|
.map((tool) => tool.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMcpConfig(
|
||||||
|
mcpConfigString?: string,
|
||||||
|
): Record<string, any> | undefined {
|
||||||
|
if (!mcpConfigString || mcpConfigString.trim() === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(mcpConfigString);
|
||||||
|
} catch (e) {
|
||||||
|
core.warning(`Failed to parse MCP config: ${e}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||||
|
// Read prompt from file
|
||||||
|
const prompt = await Bun.file(promptPath).text();
|
||||||
|
|
||||||
|
// Parse options
|
||||||
|
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||||
|
|
||||||
|
// Apply custom environment variables
|
||||||
|
for (const [key, value] of Object.entries(customEnv)) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up SDK options
|
||||||
|
const sdkOptions: Options = {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
// Use bun as the executable since we're in a Bun environment
|
||||||
|
executable: "bun",
|
||||||
|
};
|
||||||
|
|
||||||
if (options.allowedTools) {
|
if (options.allowedTools) {
|
||||||
claudeArgs.push("--allowedTools", options.allowedTools);
|
sdkOptions.allowedTools = parseTools(options.allowedTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.disallowedTools) {
|
if (options.disallowedTools) {
|
||||||
claudeArgs.push("--disallowedTools", options.disallowedTools);
|
sdkOptions.disallowedTools = parseTools(options.disallowedTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.maxTurns) {
|
if (options.maxTurns) {
|
||||||
const maxTurnsNum = parseInt(options.maxTurns, 10);
|
const maxTurnsNum = parseInt(options.maxTurns, 10);
|
||||||
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
|
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
|
||||||
@@ -81,23 +111,34 @@ export function prepareRunConfig(
|
|||||||
`maxTurns must be a positive number, got: ${options.maxTurns}`,
|
`maxTurns must be a positive number, got: ${options.maxTurns}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
claudeArgs.push("--max-turns", options.maxTurns);
|
sdkOptions.maxTurns = maxTurnsNum;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.mcpConfig) {
|
if (options.mcpConfig) {
|
||||||
claudeArgs.push("--mcp-config", options.mcpConfig);
|
const mcpConfig = parseMcpConfig(options.mcpConfig);
|
||||||
|
if (mcpConfig?.mcpServers) {
|
||||||
|
sdkOptions.mcpServers = mcpConfig.mcpServers;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.systemPrompt) {
|
if (options.systemPrompt) {
|
||||||
claudeArgs.push("--system-prompt", options.systemPrompt);
|
sdkOptions.customSystemPrompt = options.systemPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.appendSystemPrompt) {
|
if (options.appendSystemPrompt) {
|
||||||
claudeArgs.push("--append-system-prompt", options.appendSystemPrompt);
|
sdkOptions.appendSystemPrompt = options.appendSystemPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.fallbackModel) {
|
if (options.fallbackModel) {
|
||||||
claudeArgs.push("--fallback-model", options.fallbackModel);
|
sdkOptions.fallbackModel = options.fallbackModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.model) {
|
if (options.model) {
|
||||||
claudeArgs.push("--model", options.model);
|
sdkOptions.model = options.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up timeout
|
||||||
|
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
|
||||||
if (options.timeoutMinutes) {
|
if (options.timeoutMinutes) {
|
||||||
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
|
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
|
||||||
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
|
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
|
||||||
@@ -105,126 +146,7 @@ export function prepareRunConfig(
|
|||||||
`timeoutMinutes must be a positive number, got: ${options.timeoutMinutes}`,
|
`timeoutMinutes must be a positive number, got: ${options.timeoutMinutes}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
timeoutMs = timeoutMinutesNum * 60 * 1000;
|
||||||
|
|
||||||
// Parse custom environment variables
|
|
||||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
|
||||||
|
|
||||||
return {
|
|
||||||
claudeArgs,
|
|
||||||
promptPath,
|
|
||||||
env: customEnv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|
||||||
const config = prepareRunConfig(promptPath, options);
|
|
||||||
|
|
||||||
// Create a named pipe
|
|
||||||
try {
|
|
||||||
await unlink(PIPE_PATH);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the named pipe
|
|
||||||
await execAsync(`mkfifo "${PIPE_PATH}"`);
|
|
||||||
|
|
||||||
// Log prompt file size
|
|
||||||
let promptSize = "unknown";
|
|
||||||
try {
|
|
||||||
const stats = await stat(config.promptPath);
|
|
||||||
promptSize = stats.size.toString();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore error
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
|
||||||
|
|
||||||
// Log custom environment variables if any
|
|
||||||
if (Object.keys(config.env).length > 0) {
|
|
||||||
const envKeys = Object.keys(config.env).join(", ");
|
|
||||||
console.log(`Custom environment variables: ${envKeys}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output to console
|
|
||||||
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
|
|
||||||
|
|
||||||
// Start sending prompt to pipe in background
|
|
||||||
const catProcess = spawn("cat", [config.promptPath], {
|
|
||||||
stdio: ["ignore", "pipe", "inherit"],
|
|
||||||
});
|
|
||||||
const pipeStream = createWriteStream(PIPE_PATH);
|
|
||||||
catProcess.stdout.pipe(pipeStream);
|
|
||||||
|
|
||||||
catProcess.on("error", (error) => {
|
|
||||||
console.error("Error reading prompt file:", error);
|
|
||||||
pipeStream.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const claudeProcess = spawn("claude", config.claudeArgs, {
|
|
||||||
stdio: ["pipe", "pipe", "inherit"],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...config.env,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle Claude process errors
|
|
||||||
claudeProcess.on("error", (error) => {
|
|
||||||
console.error("Error spawning Claude process:", error);
|
|
||||||
pipeStream.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Capture output for parsing execution metrics
|
|
||||||
let output = "";
|
|
||||||
claudeProcess.stdout.on("data", (data) => {
|
|
||||||
const text = data.toString();
|
|
||||||
|
|
||||||
// Try to parse as JSON and pretty print if it's on a single line
|
|
||||||
const lines = text.split("\n");
|
|
||||||
lines.forEach((line: string, index: number) => {
|
|
||||||
if (line.trim() === "") return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if this line is a JSON object
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
const prettyJson = JSON.stringify(parsed, null, 2);
|
|
||||||
process.stdout.write(prettyJson);
|
|
||||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Not a JSON object, print as is
|
|
||||||
process.stdout.write(line);
|
|
||||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
output += text;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stdout errors
|
|
||||||
claudeProcess.stdout.on("error", (error) => {
|
|
||||||
console.error("Error reading Claude stdout:", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pipe from named pipe to Claude
|
|
||||||
const pipeProcess = spawn("cat", [PIPE_PATH]);
|
|
||||||
pipeProcess.stdout.pipe(claudeProcess.stdin);
|
|
||||||
|
|
||||||
// Handle pipe process errors
|
|
||||||
pipeProcess.on("error", (error) => {
|
|
||||||
console.error("Error reading from named pipe:", error);
|
|
||||||
claudeProcess.kill("SIGTERM");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for Claude to finish with timeout
|
|
||||||
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
|
|
||||||
if (options.timeoutMinutes) {
|
|
||||||
timeoutMs = parseInt(options.timeoutMinutes, 10) * 60 * 1000;
|
|
||||||
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
|
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
|
||||||
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
|
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
|
||||||
if (isNaN(envTimeout) || envTimeout <= 0) {
|
if (isNaN(envTimeout) || envTimeout <= 0) {
|
||||||
@@ -234,98 +156,76 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|||||||
}
|
}
|
||||||
timeoutMs = envTimeout * 60 * 1000;
|
timeoutMs = envTimeout * 60 * 1000;
|
||||||
}
|
}
|
||||||
const exitCode = await new Promise<number>((resolve) => {
|
|
||||||
let resolved = false;
|
|
||||||
|
|
||||||
// Set a timeout for the process
|
// Create abort controller for timeout
|
||||||
const timeoutId = setTimeout(() => {
|
const abortController = new AbortController();
|
||||||
if (!resolved) {
|
const timeoutId = setTimeout(() => {
|
||||||
console.error(
|
console.error(`Claude process timed out after ${timeoutMs / 1000} seconds`);
|
||||||
`Claude process timed out after ${timeoutMs / 1000} seconds`,
|
abortController.abort();
|
||||||
);
|
}, timeoutMs);
|
||||||
claudeProcess.kill("SIGTERM");
|
|
||||||
// Give it 5 seconds to terminate gracefully, then force kill
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
claudeProcess.kill("SIGKILL");
|
|
||||||
} catch (e) {
|
|
||||||
// Process may already be dead
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
resolved = true;
|
|
||||||
resolve(124); // Standard timeout exit code
|
|
||||||
}
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
claudeProcess.on("close", (code) => {
|
sdkOptions.abortController = abortController;
|
||||||
if (!resolved) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
resolved = true;
|
|
||||||
resolve(code || 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
claudeProcess.on("error", (error) => {
|
// Add stderr handler to capture CLI errors
|
||||||
if (!resolved) {
|
sdkOptions.stderr = (data: string) => {
|
||||||
console.error("Claude process error:", error);
|
console.error("Claude CLI stderr:", data);
|
||||||
clearTimeout(timeoutId);
|
};
|
||||||
resolved = true;
|
|
||||||
resolve(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up processes
|
console.log(`Running Claude with prompt from file: ${promptPath}`);
|
||||||
try {
|
|
||||||
catProcess.kill("SIGTERM");
|
// Log custom environment variables if any
|
||||||
} catch (e) {
|
if (Object.keys(customEnv).length > 0) {
|
||||||
// Process may already be dead
|
const envKeys = Object.keys(customEnv).join(", ");
|
||||||
}
|
console.log(`Custom environment variables: ${envKeys}`);
|
||||||
try {
|
|
||||||
pipeProcess.kill("SIGTERM");
|
|
||||||
} catch (e) {
|
|
||||||
// Process may already be dead
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up pipe file
|
const messages: SDKMessage[] = [];
|
||||||
|
let executionFailed = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await unlink(PIPE_PATH);
|
// Execute the query
|
||||||
} catch (e) {
|
for await (const message of query({
|
||||||
// Ignore errors during cleanup
|
prompt,
|
||||||
}
|
abortController,
|
||||||
|
options: sdkOptions,
|
||||||
|
})) {
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
// Set conclusion based on exit code
|
// Pretty print the message to stdout
|
||||||
if (exitCode === 0) {
|
const prettyJson = JSON.stringify(message, null, 2);
|
||||||
// Try to process the output and save execution metrics
|
console.log(prettyJson);
|
||||||
try {
|
|
||||||
await writeFile("output.txt", output);
|
|
||||||
|
|
||||||
// Process output.txt into JSON and save to execution file
|
// Check if execution failed
|
||||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
|
if (message.type === "result" && message.is_error) {
|
||||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
executionFailed = true;
|
||||||
|
}
|
||||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
|
||||||
} catch (e) {
|
|
||||||
core.warning(`Failed to process output for execution metrics: ${e}`);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during Claude execution:", error);
|
||||||
|
executionFailed = true;
|
||||||
|
|
||||||
core.setOutput("conclusion", "success");
|
// Add error to messages if it's not an abort
|
||||||
|
if (error instanceof Error && error.name !== "AbortError") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save execution output
|
||||||
|
try {
|
||||||
|
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
||||||
|
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
core.setOutput("execution_file", EXECUTION_FILE);
|
||||||
} else {
|
} catch (e) {
|
||||||
|
core.warning(`Failed to save execution file: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set conclusion
|
||||||
|
if (executionFailed) {
|
||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
|
process.exit(1);
|
||||||
// Still try to save execution file if we have output
|
} else {
|
||||||
if (output) {
|
core.setOutput("conclusion", "success");
|
||||||
try {
|
|
||||||
await writeFile("output.txt", output);
|
|
||||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
|
|
||||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors when processing output during failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(exitCode);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,297 +1,260 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { describe, test, expect } from "bun:test";
|
import {
|
||||||
import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude";
|
describe,
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
beforeAll,
|
||||||
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
} from "bun:test";
|
||||||
|
import {
|
||||||
|
runClaude,
|
||||||
|
type ClaudeOptions,
|
||||||
|
parseCustomEnvVars,
|
||||||
|
parseTools,
|
||||||
|
parseMcpConfig,
|
||||||
|
} from "../src/run-claude";
|
||||||
|
import { writeFile, unlink } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
describe("prepareRunConfig", () => {
|
// Since we can't easily mock the SDK, let's focus on testing input validation
|
||||||
test("should prepare config with basic arguments", () => {
|
// and error cases that happen before the SDK is called
|
||||||
const options: ClaudeOptions = {};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs.slice(0, 4)).toEqual([
|
describe("runClaude input validation", () => {
|
||||||
"-p",
|
const testPromptPath = join(
|
||||||
"--verbose",
|
process.env.RUNNER_TEMP || "/tmp",
|
||||||
"--output-format",
|
"test-prompt-claude.txt",
|
||||||
"stream-json",
|
);
|
||||||
]);
|
|
||||||
|
// Create a test prompt file before tests
|
||||||
|
beforeAll(async () => {
|
||||||
|
await writeFile(testPromptPath, "Test prompt content");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should include promptPath", () => {
|
// Clean up after tests
|
||||||
const options: ClaudeOptions = {};
|
afterAll(async () => {
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
try {
|
||||||
|
await unlink(testPromptPath);
|
||||||
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
|
} catch (e) {
|
||||||
});
|
// Ignore if file doesn't exist
|
||||||
|
}
|
||||||
test("should include allowed tools in command arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
allowedTools: "Bash,Read",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--allowedTools");
|
|
||||||
expect(prepared.claudeArgs).toContain("Bash,Read");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include disallowed tools in command arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
disallowedTools: "Bash,Read",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--disallowedTools");
|
|
||||||
expect(prepared.claudeArgs).toContain("Bash,Read");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include max turns in command arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
maxTurns: "5",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--max-turns");
|
|
||||||
expect(prepared.claudeArgs).toContain("5");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include mcp config in command arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
mcpConfig: "/path/to/mcp-config.json",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--mcp-config");
|
|
||||||
expect(prepared.claudeArgs).toContain("/path/to/mcp-config.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include system prompt in command arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
systemPrompt: "You are a senior backend engineer.",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--system-prompt");
|
|
||||||
expect(prepared.claudeArgs).toContain("You are a senior backend engineer.");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include append system prompt in command arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
appendSystemPrompt:
|
|
||||||
"After writing code, be sure to code review yourself.",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--append-system-prompt");
|
|
||||||
expect(prepared.claudeArgs).toContain(
|
|
||||||
"After writing code, be sure to code review yourself.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include fallback model in command arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
fallbackModel: "claude-sonnet-4-20250514",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--fallback-model");
|
|
||||||
expect(prepared.claudeArgs).toContain("claude-sonnet-4-20250514");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should use provided prompt path", () => {
|
|
||||||
const options: ClaudeOptions = {};
|
|
||||||
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not include optional arguments when not set", () => {
|
|
||||||
const options: ClaudeOptions = {};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).not.toContain("--allowedTools");
|
|
||||||
expect(prepared.claudeArgs).not.toContain("--disallowedTools");
|
|
||||||
expect(prepared.claudeArgs).not.toContain("--max-turns");
|
|
||||||
expect(prepared.claudeArgs).not.toContain("--mcp-config");
|
|
||||||
expect(prepared.claudeArgs).not.toContain("--system-prompt");
|
|
||||||
expect(prepared.claudeArgs).not.toContain("--append-system-prompt");
|
|
||||||
expect(prepared.claudeArgs).not.toContain("--fallback-model");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should preserve order of claude arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
allowedTools: "Bash,Read",
|
|
||||||
maxTurns: "3",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toEqual([
|
|
||||||
"-p",
|
|
||||||
"--verbose",
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
"--allowedTools",
|
|
||||||
"Bash,Read",
|
|
||||||
"--max-turns",
|
|
||||||
"3",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should preserve order with all options including fallback model", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
allowedTools: "Bash,Read",
|
|
||||||
disallowedTools: "Write",
|
|
||||||
maxTurns: "3",
|
|
||||||
mcpConfig: "/path/to/config.json",
|
|
||||||
systemPrompt: "You are a helpful assistant",
|
|
||||||
appendSystemPrompt: "Be concise",
|
|
||||||
fallbackModel: "claude-sonnet-4-20250514",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toEqual([
|
|
||||||
"-p",
|
|
||||||
"--verbose",
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
"--allowedTools",
|
|
||||||
"Bash,Read",
|
|
||||||
"--disallowedTools",
|
|
||||||
"Write",
|
|
||||||
"--max-turns",
|
|
||||||
"3",
|
|
||||||
"--mcp-config",
|
|
||||||
"/path/to/config.json",
|
|
||||||
"--system-prompt",
|
|
||||||
"You are a helpful assistant",
|
|
||||||
"--append-system-prompt",
|
|
||||||
"Be concise",
|
|
||||||
"--fallback-model",
|
|
||||||
"claude-sonnet-4-20250514",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("maxTurns validation", () => {
|
describe("maxTurns validation", () => {
|
||||||
test("should accept valid maxTurns value", () => {
|
test("should throw error for non-numeric maxTurns", async () => {
|
||||||
const options: ClaudeOptions = { maxTurns: "5" };
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
expect(prepared.claudeArgs).toContain("--max-turns");
|
|
||||||
expect(prepared.claudeArgs).toContain("5");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error for non-numeric maxTurns", () => {
|
|
||||||
const options: ClaudeOptions = { maxTurns: "abc" };
|
const options: ClaudeOptions = { maxTurns: "abc" };
|
||||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
"maxTurns must be a positive number, got: abc",
|
"maxTurns must be a positive number, got: abc",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error for negative maxTurns", () => {
|
test("should throw error for negative maxTurns", async () => {
|
||||||
const options: ClaudeOptions = { maxTurns: "-1" };
|
const options: ClaudeOptions = { maxTurns: "-1" };
|
||||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
"maxTurns must be a positive number, got: -1",
|
"maxTurns must be a positive number, got: -1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error for zero maxTurns", () => {
|
test("should throw error for zero maxTurns", async () => {
|
||||||
const options: ClaudeOptions = { maxTurns: "0" };
|
const options: ClaudeOptions = { maxTurns: "0" };
|
||||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
"maxTurns must be a positive number, got: 0",
|
"maxTurns must be a positive number, got: 0",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("timeoutMinutes validation", () => {
|
describe("timeoutMinutes validation", () => {
|
||||||
test("should accept valid timeoutMinutes value", () => {
|
test("should throw error for non-numeric timeoutMinutes", async () => {
|
||||||
const options: ClaudeOptions = { timeoutMinutes: "15" };
|
|
||||||
expect(() =>
|
|
||||||
prepareRunConfig("/tmp/test-prompt.txt", options),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error for non-numeric timeoutMinutes", () => {
|
|
||||||
const options: ClaudeOptions = { timeoutMinutes: "abc" };
|
const options: ClaudeOptions = { timeoutMinutes: "abc" };
|
||||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
"timeoutMinutes must be a positive number, got: abc",
|
"timeoutMinutes must be a positive number, got: abc",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error for negative timeoutMinutes", () => {
|
test("should throw error for negative timeoutMinutes", async () => {
|
||||||
const options: ClaudeOptions = { timeoutMinutes: "-5" };
|
const options: ClaudeOptions = { timeoutMinutes: "-5" };
|
||||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
"timeoutMinutes must be a positive number, got: -5",
|
"timeoutMinutes must be a positive number, got: -5",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error for zero timeoutMinutes", () => {
|
test("should throw error for zero timeoutMinutes", async () => {
|
||||||
const options: ClaudeOptions = { timeoutMinutes: "0" };
|
const options: ClaudeOptions = { timeoutMinutes: "0" };
|
||||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
"timeoutMinutes must be a positive number, got: 0",
|
"timeoutMinutes must be a positive number, got: 0",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("custom environment variables", () => {
|
describe("environment variable validation from INPUT_TIMEOUT_MINUTES", () => {
|
||||||
test("should parse empty claudeEnv correctly", () => {
|
const originalEnv = process.env.INPUT_TIMEOUT_MINUTES;
|
||||||
const options: ClaudeOptions = { claudeEnv: "" };
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
afterEach(() => {
|
||||||
expect(prepared.env).toEqual({});
|
// Restore original value
|
||||||
|
if (originalEnv !== undefined) {
|
||||||
|
process.env.INPUT_TIMEOUT_MINUTES = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.INPUT_TIMEOUT_MINUTES;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should parse single environment variable", () => {
|
test("should throw error for invalid INPUT_TIMEOUT_MINUTES", async () => {
|
||||||
const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123" };
|
process.env.INPUT_TIMEOUT_MINUTES = "invalid";
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
expect(prepared.env).toEqual({ API_KEY: "secret123" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should parse multiple environment variables", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeEnv: "API_KEY: secret123\nDEBUG: true\nUSER: testuser",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
expect(prepared.env).toEqual({
|
|
||||||
API_KEY: "secret123",
|
|
||||||
DEBUG: "true",
|
|
||||||
USER: "testuser",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle environment variables with spaces around values", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeEnv: "API_KEY: secret123 \n DEBUG : true ",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
expect(prepared.env).toEqual({
|
|
||||||
API_KEY: "secret123",
|
|
||||||
DEBUG: "true",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip empty lines and comments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeEnv:
|
|
||||||
"API_KEY: secret123\n\n# This is a comment\nDEBUG: true\n# Another comment",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
expect(prepared.env).toEqual({
|
|
||||||
API_KEY: "secret123",
|
|
||||||
DEBUG: "true",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip lines without colons", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeEnv: "API_KEY: secret123\nINVALID_LINE\nDEBUG: true",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
expect(prepared.env).toEqual({
|
|
||||||
API_KEY: "secret123",
|
|
||||||
DEBUG: "true",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle undefined claudeEnv", () => {
|
|
||||||
const options: ClaudeOptions = {};
|
const options: ClaudeOptions = {};
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
expect(prepared.env).toEqual({});
|
"INPUT_TIMEOUT_MINUTES must be a positive number, got: invalid",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw error for zero INPUT_TIMEOUT_MINUTES", async () => {
|
||||||
|
process.env.INPUT_TIMEOUT_MINUTES = "0";
|
||||||
|
const options: ClaudeOptions = {};
|
||||||
|
await expect(runClaude(testPromptPath, options)).rejects.toThrow(
|
||||||
|
"INPUT_TIMEOUT_MINUTES must be a positive number, got: 0",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: We can't easily test the full execution flow without either:
|
||||||
|
// 1. Mocking the SDK (which seems difficult with Bun's current mocking capabilities)
|
||||||
|
// 2. Having a valid API key and actually calling the API (not suitable for unit tests)
|
||||||
|
// 3. Refactoring the code to be more testable (e.g., dependency injection)
|
||||||
|
|
||||||
|
// For now, we're testing what we can: input validation that happens before the SDK call
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseCustomEnvVars", () => {
|
||||||
|
test("should parse empty string correctly", () => {
|
||||||
|
expect(parseCustomEnvVars("")).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse single environment variable", () => {
|
||||||
|
expect(parseCustomEnvVars("API_KEY: secret123")).toEqual({
|
||||||
|
API_KEY: "secret123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse multiple environment variables", () => {
|
||||||
|
const input = "API_KEY: secret123\nDEBUG: true\nUSER: testuser";
|
||||||
|
expect(parseCustomEnvVars(input)).toEqual({
|
||||||
|
API_KEY: "secret123",
|
||||||
|
DEBUG: "true",
|
||||||
|
USER: "testuser",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle environment variables with spaces around values", () => {
|
||||||
|
const input = "API_KEY: secret123 \n DEBUG : true ";
|
||||||
|
expect(parseCustomEnvVars(input)).toEqual({
|
||||||
|
API_KEY: "secret123",
|
||||||
|
DEBUG: "true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip empty lines and comments", () => {
|
||||||
|
const input =
|
||||||
|
"API_KEY: secret123\n\n# This is a comment\nDEBUG: true\n# Another comment";
|
||||||
|
expect(parseCustomEnvVars(input)).toEqual({
|
||||||
|
API_KEY: "secret123",
|
||||||
|
DEBUG: "true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip lines without colons", () => {
|
||||||
|
const input = "API_KEY: secret123\nINVALID_LINE\nDEBUG: true";
|
||||||
|
expect(parseCustomEnvVars(input)).toEqual({
|
||||||
|
API_KEY: "secret123",
|
||||||
|
DEBUG: "true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle undefined input", () => {
|
||||||
|
expect(parseCustomEnvVars(undefined)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle whitespace-only input", () => {
|
||||||
|
expect(parseCustomEnvVars(" \n \t ")).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseTools", () => {
|
||||||
|
test("should return undefined for empty string", () => {
|
||||||
|
expect(parseTools("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined for whitespace-only string", () => {
|
||||||
|
expect(parseTools(" \t ")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined for undefined input", () => {
|
||||||
|
expect(parseTools(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse single tool", () => {
|
||||||
|
expect(parseTools("Bash")).toEqual(["Bash"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse multiple tools", () => {
|
||||||
|
expect(parseTools("Bash,Read,Write")).toEqual(["Bash", "Read", "Write"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should trim whitespace around tools", () => {
|
||||||
|
expect(parseTools(" Bash , Read , Write ")).toEqual([
|
||||||
|
"Bash",
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter out empty tool names", () => {
|
||||||
|
expect(parseTools("Bash,,Read,,,Write")).toEqual(["Bash", "Read", "Write"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseMcpConfig", () => {
|
||||||
|
test("should return undefined for empty string", () => {
|
||||||
|
expect(parseMcpConfig("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined for whitespace-only string", () => {
|
||||||
|
expect(parseMcpConfig(" \t ")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined for undefined input", () => {
|
||||||
|
expect(parseMcpConfig(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse valid JSON", () => {
|
||||||
|
const config = { "test-server": { command: "test", args: ["--test"] } };
|
||||||
|
expect(parseMcpConfig(JSON.stringify(config))).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined for invalid JSON", () => {
|
||||||
|
// Check console warning is logged
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const warnings: string[] = [];
|
||||||
|
console.warn = (msg: string) => warnings.push(msg);
|
||||||
|
|
||||||
|
expect(parseMcpConfig("{ invalid json")).toBeUndefined();
|
||||||
|
|
||||||
|
console.warn = originalWarn;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse complex MCP config", () => {
|
||||||
|
const config = {
|
||||||
|
"github-mcp": {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||||
|
env: {
|
||||||
|
GITHUB_TOKEN: "test-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"filesystem-mcp": {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(parseMcpConfig(JSON.stringify(config))).toEqual(config);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
25
bun.lock
25
bun.lock
@@ -6,6 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
|
"@anthropic-ai/claude-code": "1.0.57",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
@@ -33,8 +34,32 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
|
"@anthropic-ai/claude-code": ["@anthropic-ai/claude-code@1.0.57", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "bin": { "claude": "cli.js" } }, "sha512-zMymGZzjG+JO9iKC5N5pAy8AxyHIMPCL6U3HYCR3vCj5M+Y0s3GAMma6GkvCXWFixRN6KSZItKw3HbQiaIBYlw=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.11.0", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.11.0", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ=="],
|
||||||
|
|
||||||
"@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="],
|
"@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
|
"@anthropic-ai/claude-code": "1.0.57",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user