From 49cfcf81070eac8de029f52cf67d7214185a2212 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 20 Jan 2026 16:00:23 -0800 Subject: [PATCH] refactor: remove CLI path, use Agent SDK exclusively (#849) * refactor: remove CLI path, use Agent SDK exclusively - Remove CLI-based Claude execution in favor of Agent SDK - Delete prepareRunConfig, parseAndSetSessionId, parseAndSetStructuredOutputs functions - Remove named pipe IPC and sanitizeJsonOutput helper - Remove test-agent-sdk job from test-base-action workflow (SDK is now default) - Delete run-claude.test.ts and structured-output.test.ts (testing removed CLI code) - Update CLAUDE.md to remove named pipe references Co-Authored-By: Claude Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 2 Claude-Permission-Prompts: 1 Claude-Escapes: 0 Claude-Plan: # Plan: Remove Non-Agent SDK Code Path ## Overview Since `use_agent_sdk` defaults to `true`, remove the legacy CLI code path entirely from `base-action/src/run-claude.ts`. ## Files to Modify ### 1. `base-action/src/run-claude.ts` - Main Cleanup **Remove imports:** - `exec` from `child_process` - `promisify` from `util` - `unlink`, `writeFile`, `stat` from `fs/promises` (keep `readFile` - check if needed) - `createWriteStream` from `fs` - `spawn` from `child_process` - `parseShellArgs` from `shell-quote` (still used in `parse-sdk-options.ts`, keep package) **Remove constants:** - `execAsync` - `PIPE_PATH` - `EXECUTION_FILE` (defined in both files, keep in SDK file) - `BASE_ARGS` **Remove types:** - `PreparedConfig` type (lines 85-89) - only used by `prepareRunConfig()` **Remove functions:** - `sanitizeJsonOutput()` (lines 21-68) - `prepareRunConfig()` (lines 91-125) - also remove export - `parseAndSetSessionId()` (lines 131-155) - also remove export - `parseAndSetStructuredOutputs()` (lines 162-197) - also remove export **Simplify `runClaude()`:** - Remove `useAgentSdk` flag check and logging (lines 200-204) - Remove the `if (useAgentSdk)` block, make SDK call direct - Remove entire CLI path (lines 211-438) - Resulting function becomes just: ```typescript export async function runClaude(promptPath: string, options: ClaudeOptions) { const parsedOptions = parseSdkOptions(options); return runClaudeWithSdk(promptPath, parsedOptions); } ``` ### 2. Delete Test Files **`base-action/test/run-claude.test.ts`:** - Delete entire file (only tests `prepareRunConfig()`) **`base-action/test/structured-output.test.ts`:** - Delete entire file (only tests `parseAndSetStructuredOutputs()` and `parseAndSetSessionId()`) ### 3. Workflow Update **`.github/workflows/test-base-action.yml`:** - Remove `test-agent-sdk` job (lines 120-176) - redundant now ### 4. Documentation Update **`base-action/CLAUDE.md`:** - Line 30: Remove "- Named pipes for IPC between prompt input and Claude process" - Line 57: Remove "- Uses `mkfifo` to create named pipes for prompt input" ## Verification 1. Run `bun run typecheck` to ensure no type errors 2. Run `bun test` to ensure remaining tests pass 3. Run `bun run format` to fix any formatting issues * fix: address PR review comments - Add session_id output handling in run-claude-sdk.ts (critical) - Remove unused claudeEnv parameter from ClaudeOptions and index.ts - Update stale CLI path comment in parse-sdk-options.ts Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 0 Claude-Permission-Prompts: 0 Claude-Escapes: 0 Claude-Plan: # Plan: Remove Non-Agent SDK Code Path ## Overview Since `use_agent_sdk` defaults to `true`, remove the legacy CLI code path entirely from `base-action/src/run-claude.ts`. ## Files to Modify ### 1. `base-action/src/run-claude.ts` - Main Cleanup **Remove imports:** - `exec` from `child_process` - `promisify` from `util` - `unlink`, `writeFile`, `stat` from `fs/promises` (keep `readFile` - check if needed) - `createWriteStream` from `fs` - `spawn` from `child_process` - `parseShellArgs` from `shell-quote` (still used in `parse-sdk-options.ts`, keep package) **Remove constants:** - `execAsync` - `PIPE_PATH` - `EXECUTION_FILE` (defined in both files, keep in SDK file) - `BASE_ARGS` **Remove types:** - `PreparedConfig` type (lines 85-89) - only used by `prepareRunConfig()` **Remove functions:** - `sanitizeJsonOutput()` (lines 21-68) - `prepareRunConfig()` (lines 91-125) - also remove export - `parseAndSetSessionId()` (lines 131-155) - also remove export - `parseAndSetStructuredOutputs()` (lines 162-197) - also remove export **Simplify `runClaude()`:** - Remove `useAgentSdk` flag check and logging (lines 200-204) - Remove the `if (useAgentSdk)` block, make SDK call direct - Remove entire CLI path (lines 211-438) - Resulting function becomes just: ```typescript export async function runClaude(promptPath: string, options: ClaudeOptions) { const parsedOptions = parseSdkOptions(options); return runClaudeWithSdk(promptPath, parsedOptions); } ``` ### 2. Delete Test Files **`base-action/test/run-claude.test.ts`:** - Delete entire file (only tests `prepareRunConfig()`) **`base-action/test/structured-output.test.ts`:** - Delete entire file (only tests `parseAndSetStructuredOutputs()` and `parseAndSetSessionId()`) ### 3. Workflow Update **`.github/workflows/test-base-action.yml`:** - Remove `test-agent-sdk` job (lines 120-176) - redundant now ### 4. Documentation Update **`base-action/CLAUDE.md`:** - Line 30: Remove "- Named pipes for IPC between prompt input and Claude process" - Line 57: Remove "- Uses `mkfifo` to create named pipes for prompt input" ## Verification 1. Run `bun run typecheck` to ensure no type errors 2. Run `bun test` to ensure remaining tests pass 3. Run `bun run format` to fix any formatting issues --- .github/workflows/test-base-action.yml | 58 --- base-action/CLAUDE.md | 2 - base-action/src/index.ts | 1 - base-action/src/parse-sdk-options.ts | 2 +- base-action/src/run-claude-sdk.ts | 9 + base-action/src/run-claude.ts | 422 +-------------------- base-action/test/run-claude.test.ts | 96 ----- base-action/test/structured-output.test.ts | 227 ----------- 8 files changed, 12 insertions(+), 805 deletions(-) delete mode 100644 base-action/test/run-claude.test.ts delete mode 100644 base-action/test/structured-output.test.ts diff --git a/.github/workflows/test-base-action.yml b/.github/workflows/test-base-action.yml index 7a9816c..42474fb 100644 --- a/.github/workflows/test-base-action.yml +++ b/.github/workflows/test-base-action.yml @@ -116,61 +116,3 @@ jobs: echo "❌ Execution log file not found" exit 1 fi - - test-agent-sdk: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Test with Agent SDK - id: sdk-test - uses: ./base-action - env: - USE_AGENT_SDK: "true" - with: - prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }} - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - allowed_tools: "LS,Read" - - - name: Verify SDK output - run: | - OUTPUT_FILE="${{ steps.sdk-test.outputs.execution_file }}" - CONCLUSION="${{ steps.sdk-test.outputs.conclusion }}" - - echo "Conclusion: $CONCLUSION" - echo "Output file: $OUTPUT_FILE" - - if [ "$CONCLUSION" = "success" ]; then - echo "✅ Action completed successfully with Agent SDK" - else - echo "❌ Action failed with Agent SDK" - exit 1 - fi - - if [ -f "$OUTPUT_FILE" ]; then - if [ -s "$OUTPUT_FILE" ]; then - echo "✅ Execution log file created successfully with content" - echo "Validating JSON format:" - if jq . "$OUTPUT_FILE" > /dev/null 2>&1; then - echo "✅ Output is valid JSON" - # Verify SDK output contains total_cost_usd (SDK field name) - if jq -e '.[] | select(.type == "result") | .total_cost_usd' "$OUTPUT_FILE" > /dev/null 2>&1; then - echo "✅ SDK output contains total_cost_usd field" - else - echo "❌ SDK output missing total_cost_usd field" - exit 1 - fi - echo "Content preview:" - head -c 500 "$OUTPUT_FILE" - else - echo "❌ Output is not valid JSON" - exit 1 - fi - else - echo "❌ Execution log file is empty" - exit 1 - fi - else - echo "❌ Execution log file not found" - exit 1 - fi diff --git a/base-action/CLAUDE.md b/base-action/CLAUDE.md index 47a9641..8eb1d6d 100644 --- a/base-action/CLAUDE.md +++ b/base-action/CLAUDE.md @@ -27,7 +27,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows. ### Key Design Patterns - Uses Bun runtime for development and execution -- Named pipes for IPC between prompt input and Claude process - JSON streaming output format for execution logs - Composite action pattern to orchestrate multiple steps - Provider-agnostic design supporting Anthropic API, AWS Bedrock, and Google Vertex AI @@ -54,7 +53,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows. ## Important Technical Details -- Uses `mkfifo` to create named pipes for prompt input - Outputs execution logs as JSON to `/tmp/claude-execution-output.json` - Timeout enforcement via `timeout` command wrapper - Strict TypeScript configuration with Bun-specific settings diff --git a/base-action/src/index.ts b/base-action/src/index.ts index fdd1406..b10f1ca 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -36,7 +36,6 @@ async function run() { mcpConfig: process.env.INPUT_MCP_CONFIG, systemPrompt: process.env.INPUT_SYSTEM_PROMPT, appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT, - claudeEnv: process.env.INPUT_CLAUDE_ENV, fallbackModel: process.env.INPUT_FALLBACK_MODEL, model: process.env.ANTHROPIC_MODEL, pathToClaudeCodeExecutable: diff --git a/base-action/src/parse-sdk-options.ts b/base-action/src/parse-sdk-options.ts index 1dc5224..35df281 100644 --- a/base-action/src/parse-sdk-options.ts +++ b/base-action/src/parse-sdk-options.ts @@ -212,7 +212,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { if (process.env.INPUT_ACTION_INPUTS_PRESENT) { env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT; } - // Ensure SDK path uses the same entrypoint as the CLI path + // Set the entrypoint for Claude Code to identify this as the GitHub Action env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action"; // Build system prompt option - default to claude_code preset diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index 64758c6..16ee3c4 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -178,6 +178,15 @@ export async function runClaudeWithSdk( core.warning(`Failed to write execution file: ${error}`); } + // Extract and set session_id from system.init message + const initMessage = messages.find( + (m) => m.type === "system" && "subtype" in m && m.subtype === "init", + ); + if (initMessage && "session_id" in initMessage && initMessage.session_id) { + core.setOutput("session_id", initMessage.session_id); + core.info(`Set session_id: ${initMessage.session_id}`); + } + if (!resultMessage) { core.setOutput("conclusion", "failure"); core.error("No result message received from Claude"); diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index a5485a3..e644cd5 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -1,72 +1,6 @@ -import * as core from "@actions/core"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { unlink, writeFile, stat, readFile } from "fs/promises"; -import { createWriteStream } from "fs"; -import { spawn } from "child_process"; -import { parse as parseShellArgs } from "shell-quote"; import { runClaudeWithSdk } from "./run-claude-sdk"; import { parseSdkOptions } from "./parse-sdk-options"; -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 BASE_ARGS = ["--verbose", "--output-format", "stream-json"]; - -/** - * Sanitizes JSON output to remove sensitive information when full output is disabled - * Returns a safe summary message or null if the message should be completely suppressed - */ -function sanitizeJsonOutput( - jsonObj: any, - showFullOutput: boolean, -): string | null { - if (showFullOutput) { - // In full output mode, return the full JSON - return JSON.stringify(jsonObj, null, 2); - } - - // In non-full-output mode, provide minimal safe output - const type = jsonObj.type; - const subtype = jsonObj.subtype; - - // System initialization - safe to show - if (type === "system" && subtype === "init") { - return JSON.stringify( - { - type: "system", - subtype: "init", - message: "Claude Code initialized", - model: jsonObj.model || "unknown", - }, - null, - 2, - ); - } - - // Result messages - Always show the final result - if (type === "result") { - // These messages contain the final result and should always be visible - return JSON.stringify( - { - type: "result", - subtype: jsonObj.subtype, - is_error: jsonObj.is_error, - duration_ms: jsonObj.duration_ms, - num_turns: jsonObj.num_turns, - total_cost_usd: jsonObj.total_cost_usd, - permission_denials: jsonObj.permission_denials, - }, - null, - 2, - ); - } - - // For any other message types, suppress completely in non-full-output mode - return null; -} - export type ClaudeOptions = { claudeArgs?: string; model?: string; @@ -77,363 +11,11 @@ export type ClaudeOptions = { mcpConfig?: string; systemPrompt?: string; appendSystemPrompt?: string; - claudeEnv?: string; fallbackModel?: string; showFullOutput?: string; }; -type PreparedConfig = { - claudeArgs: string[]; - promptPath: string; - env: Record; -}; - -export function prepareRunConfig( - promptPath: string, - options: ClaudeOptions, -): PreparedConfig { - // Build Claude CLI arguments: - // 1. Prompt flag (always first) - // 2. User's claudeArgs (full control) - // 3. BASE_ARGS (always last, cannot be overridden) - - const claudeArgs = ["-p"]; - - // Parse and add user's custom Claude arguments - if (options.claudeArgs?.trim()) { - const parsed = parseShellArgs(options.claudeArgs); - const customArgs = parsed.filter( - (arg): arg is string => typeof arg === "string", - ); - claudeArgs.push(...customArgs); - } - - // BASE_ARGS are always appended last (cannot be overridden) - claudeArgs.push(...BASE_ARGS); - - const customEnv: Record = {}; - - if (process.env.INPUT_ACTION_INPUTS_PRESENT) { - customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT; - } - - return { - claudeArgs, - promptPath, - env: customEnv, - }; -} - -/** - * Parses session_id from execution file and sets GitHub Action output - * Exported for testing - */ -export async function parseAndSetSessionId( - executionFile: string, -): Promise { - try { - const content = await readFile(executionFile, "utf-8"); - const messages = JSON.parse(content) as { - type: string; - subtype?: string; - session_id?: string; - }[]; - - // Find the system.init message which contains session_id - const initMessage = messages.find( - (m) => m.type === "system" && m.subtype === "init", - ); - - if (initMessage?.session_id) { - core.setOutput("session_id", initMessage.session_id); - core.info(`Set session_id: ${initMessage.session_id}`); - } - } catch (error) { - // Don't fail the action if session_id extraction fails - core.warning(`Failed to extract session_id: ${error}`); - } -} - -/** - * Parses structured_output from execution file and sets GitHub Action outputs - * Only runs if --json-schema was explicitly provided in claude_args - * Exported for testing - */ -export async function parseAndSetStructuredOutputs( - executionFile: string, -): Promise { - try { - const content = await readFile(executionFile, "utf-8"); - const messages = JSON.parse(content) as { - type: string; - structured_output?: Record; - }[]; - - // Search backwards - result is typically last or second-to-last message - const result = messages.findLast( - (m) => m.type === "result" && m.structured_output, - ); - - if (!result?.structured_output) { - throw new Error( - `--json-schema was provided but Claude did not return structured_output.\n` + - `Found ${messages.length} messages. Result exists: ${!!result}\n`, - ); - } - - // Set the complete structured output as a single JSON string - // This works around GitHub Actions limitation that composite actions can't have dynamic outputs - const structuredOutputJson = JSON.stringify(result.structured_output); - core.setOutput("structured_output", structuredOutputJson); - core.info( - `Set structured_output with ${Object.keys(result.structured_output).length} field(s)`, - ); - } catch (error) { - if (error instanceof Error) { - throw error; // Preserve original error and stack trace - } - throw new Error(`Failed to parse structured outputs: ${error}`); - } -} - export async function runClaude(promptPath: string, options: ClaudeOptions) { - // Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI - const useAgentSdk = process.env.USE_AGENT_SDK !== "false"; - console.log( - `Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`, - ); - - if (useAgentSdk) { - const parsedOptions = parseSdkOptions(options); - return runClaudeWithSdk(promptPath, parsedOptions); - } - - const config = prepareRunConfig(promptPath, options); - - // Detect if --json-schema is present in claude args - const hasJsonSchema = options.claudeArgs?.includes("--json-schema") ?? false; - - // 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 - const customEnvKeys = Object.keys(config.env).filter( - (key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT", - ); - if (customEnvKeys.length > 0) { - console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`); - } - - // Log custom arguments if any - if (options.claudeArgs && options.claudeArgs.trim() !== "") { - console.log(`Custom Claude arguments: ${options.claudeArgs}`); - } - - // Output to console - console.log(`Running Claude with prompt from file: ${config.promptPath}`); - console.log(`Full command: claude ${config.claudeArgs.join(" ")}`); - - // 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(); - }); - - // Use custom executable path if provided, otherwise default to "claude" - const claudeExecutable = options.pathToClaudeCodeExecutable || "claude"; - - const claudeProcess = spawn(claudeExecutable, 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(); - }); - - // Determine if full output should be shown - // Show full output if explicitly set to "true" OR if GitHub Actions debug mode is enabled - const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true"; - let showFullOutput = options.showFullOutput === "true" || isDebugMode; - - if (isDebugMode && options.showFullOutput !== "false") { - console.log("Debug mode detected - showing full output"); - showFullOutput = true; - } else if (!showFullOutput) { - console.log("Running Claude Code (full output hidden for security)..."); - console.log( - "Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.", - ); - } - - // Capture output for parsing execution metrics - let output = ""; - claudeProcess.stdout.on("data", (data) => { - const text = data.toString(); - - // Try to parse as JSON and handle based on verbose setting - 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 sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput); - - if (sanitizedOutput) { - process.stdout.write(sanitizedOutput); - if (index < lines.length - 1 || text.endsWith("\n")) { - process.stdout.write("\n"); - } - } - } catch (e) { - // Not a JSON object - if (showFullOutput) { - // In full output mode, print as is - process.stdout.write(line); - if (index < lines.length - 1 || text.endsWith("\n")) { - process.stdout.write("\n"); - } - } - // In non-full-output mode, suppress non-JSON output - } - }); - - 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 - const exitCode = await new Promise((resolve) => { - claudeProcess.on("close", (code) => { - resolve(code || 0); - }); - - claudeProcess.on("error", (error) => { - console.error("Claude process error:", error); - resolve(1); - }); - }); - - // Clean up processes - try { - catProcess.kill("SIGTERM"); - } catch (e) { - // Process may already be dead - } - try { - pipeProcess.kill("SIGTERM"); - } catch (e) { - // Process may already be dead - } - - // Clean up pipe file - try { - await unlink(PIPE_PATH); - } catch (e) { - // Ignore errors during cleanup - } - - // Set conclusion based on exit code - if (exitCode === 0) { - // Try to process the output and save execution metrics - try { - await writeFile("output.txt", output); - - // Process output.txt into JSON and save to execution file - // Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs - const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", { - maxBuffer: 10 * 1024 * 1024, - }); - await writeFile(EXECUTION_FILE, jsonOutput); - - console.log(`Log saved to ${EXECUTION_FILE}`); - } catch (e) { - core.warning(`Failed to process output for execution metrics: ${e}`); - } - - core.setOutput("execution_file", EXECUTION_FILE); - - // Extract and set session_id - await parseAndSetSessionId(EXECUTION_FILE); - - // Parse and set structured outputs only if user provided --json-schema in claude_args - if (hasJsonSchema) { - try { - await parseAndSetStructuredOutputs(EXECUTION_FILE); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - core.setFailed(errorMessage); - core.setOutput("conclusion", "failure"); - process.exit(1); - } - } - - // Set conclusion to success if we reached here - core.setOutput("conclusion", "success"); - } else { - core.setOutput("conclusion", "failure"); - - // Still try to save execution file if we have output - if (output) { - try { - await writeFile("output.txt", output); - // Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs - const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", { - maxBuffer: 10 * 1024 * 1024, - }); - await writeFile(EXECUTION_FILE, jsonOutput); - core.setOutput("execution_file", EXECUTION_FILE); - } catch (e) { - // Ignore errors when processing output during failure - } - } - - process.exit(exitCode); - } + const parsedOptions = parseSdkOptions(options); + return runClaudeWithSdk(promptPath, parsedOptions); } diff --git a/base-action/test/run-claude.test.ts b/base-action/test/run-claude.test.ts deleted file mode 100644 index 10b385f..0000000 --- a/base-action/test/run-claude.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bun - -import { describe, test, expect } from "bun:test"; -import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude"; - -describe("prepareRunConfig", () => { - test("should prepare config with basic arguments", () => { - const options: ClaudeOptions = {}; - const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); - - expect(prepared.claudeArgs).toEqual([ - "-p", - "--verbose", - "--output-format", - "stream-json", - ]); - }); - - test("should include promptPath", () => { - const options: ClaudeOptions = {}; - const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); - - expect(prepared.promptPath).toBe("/tmp/test-prompt.txt"); - }); - - 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"); - }); - - describe("claudeArgs handling", () => { - test("should parse and include custom claude arguments", () => { - const options: ClaudeOptions = { - claudeArgs: "--max-turns 10 --model claude-3-opus-20240229", - }; - const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); - - expect(prepared.claudeArgs).toEqual([ - "-p", - "--max-turns", - "10", - "--model", - "claude-3-opus-20240229", - "--verbose", - "--output-format", - "stream-json", - ]); - }); - - test("should handle empty claudeArgs", () => { - const options: ClaudeOptions = { - claudeArgs: "", - }; - const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); - - expect(prepared.claudeArgs).toEqual([ - "-p", - "--verbose", - "--output-format", - "stream-json", - ]); - }); - - test("should handle claudeArgs with quoted strings", () => { - const options: ClaudeOptions = { - claudeArgs: '--system-prompt "You are a helpful assistant"', - }; - const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); - - expect(prepared.claudeArgs).toEqual([ - "-p", - "--system-prompt", - "You are a helpful assistant", - "--verbose", - "--output-format", - "stream-json", - ]); - }); - - test("should include json-schema flag when provided", () => { - const options: ClaudeOptions = { - claudeArgs: - '--json-schema \'{"type":"object","properties":{"result":{"type":"boolean"}}}\'', - }; - - const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); - - expect(prepared.claudeArgs).toContain("--json-schema"); - expect(prepared.claudeArgs).toContain( - '{"type":"object","properties":{"result":{"type":"boolean"}}}', - ); - }); - }); -}); diff --git a/base-action/test/structured-output.test.ts b/base-action/test/structured-output.test.ts deleted file mode 100644 index 8fde6cb..0000000 --- a/base-action/test/structured-output.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env bun - -import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test"; -import { writeFile, unlink } from "fs/promises"; -import { tmpdir } from "os"; -import { join } from "path"; -import { - parseAndSetStructuredOutputs, - parseAndSetSessionId, -} from "../src/run-claude"; -import * as core from "@actions/core"; - -// Mock execution file path -const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json"); - -// Helper to create mock execution file with structured output -async function createMockExecutionFile( - structuredOutput?: Record, - includeResult: boolean = true, -): Promise { - const messages: any[] = [ - { type: "system", subtype: "init" }, - { type: "turn", content: "test" }, - ]; - - if (includeResult) { - messages.push({ - type: "result", - cost_usd: 0.01, - duration_ms: 1000, - structured_output: structuredOutput, - }); - } - - await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); -} - -// Spy on core functions -let setOutputSpy: any; -let infoSpy: any; -let warningSpy: any; - -beforeEach(() => { - setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); - infoSpy = spyOn(core, "info").mockImplementation(() => {}); - warningSpy = spyOn(core, "warning").mockImplementation(() => {}); -}); - -describe("parseAndSetStructuredOutputs", () => { - afterEach(async () => { - setOutputSpy?.mockRestore(); - infoSpy?.mockRestore(); - warningSpy?.mockRestore(); - try { - await unlink(TEST_EXECUTION_FILE); - } catch { - // Ignore if file doesn't exist - } - }); - - test("should set structured_output with valid data", async () => { - await createMockExecutionFile({ - is_flaky: true, - confidence: 0.85, - summary: "Test looks flaky", - }); - - await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE); - - expect(setOutputSpy).toHaveBeenCalledWith( - "structured_output", - '{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}', - ); - expect(infoSpy).toHaveBeenCalledWith( - "Set structured_output with 3 field(s)", - ); - }); - - test("should handle arrays and nested objects", async () => { - await createMockExecutionFile({ - items: ["a", "b", "c"], - config: { key: "value", nested: { deep: true } }, - }); - - await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE); - - const callArgs = setOutputSpy.mock.calls[0]; - expect(callArgs[0]).toBe("structured_output"); - const parsed = JSON.parse(callArgs[1]); - expect(parsed).toEqual({ - items: ["a", "b", "c"], - config: { key: "value", nested: { deep: true } }, - }); - }); - - test("should handle special characters in field names", async () => { - await createMockExecutionFile({ - "test-result": "passed", - "item.count": 10, - "user@email": "test", - }); - - await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE); - - const callArgs = setOutputSpy.mock.calls[0]; - const parsed = JSON.parse(callArgs[1]); - expect(parsed["test-result"]).toBe("passed"); - expect(parsed["item.count"]).toBe(10); - expect(parsed["user@email"]).toBe("test"); - }); - - test("should throw error when result exists but structured_output is undefined", async () => { - const messages = [ - { type: "system", subtype: "init" }, - { type: "result", cost_usd: 0.01, duration_ms: 1000 }, - ]; - await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); - - await expect( - parseAndSetStructuredOutputs(TEST_EXECUTION_FILE), - ).rejects.toThrow( - "--json-schema was provided but Claude did not return structured_output", - ); - }); - - test("should throw error when no result message exists", async () => { - const messages = [ - { type: "system", subtype: "init" }, - { type: "turn", content: "test" }, - ]; - await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); - - await expect( - parseAndSetStructuredOutputs(TEST_EXECUTION_FILE), - ).rejects.toThrow( - "--json-schema was provided but Claude did not return structured_output", - ); - }); - - test("should throw error with malformed JSON", async () => { - await writeFile(TEST_EXECUTION_FILE, "{ invalid json"); - - await expect( - parseAndSetStructuredOutputs(TEST_EXECUTION_FILE), - ).rejects.toThrow(); - }); - - test("should throw error when file does not exist", async () => { - await expect( - parseAndSetStructuredOutputs("/nonexistent/file.json"), - ).rejects.toThrow(); - }); - - test("should handle empty structured_output object", async () => { - await createMockExecutionFile({}); - - await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE); - - expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}"); - expect(infoSpy).toHaveBeenCalledWith( - "Set structured_output with 0 field(s)", - ); - }); -}); - -describe("parseAndSetSessionId", () => { - afterEach(async () => { - setOutputSpy?.mockRestore(); - infoSpy?.mockRestore(); - warningSpy?.mockRestore(); - try { - await unlink(TEST_EXECUTION_FILE); - } catch { - // Ignore if file doesn't exist - } - }); - - test("should extract session_id from system.init message", async () => { - const messages = [ - { type: "system", subtype: "init", session_id: "test-session-123" }, - { type: "result", cost_usd: 0.01 }, - ]; - await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); - - await parseAndSetSessionId(TEST_EXECUTION_FILE); - - expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123"); - expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123"); - }); - - test("should handle missing session_id gracefully", async () => { - const messages = [ - { type: "system", subtype: "init" }, - { type: "result", cost_usd: 0.01 }, - ]; - await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); - - await parseAndSetSessionId(TEST_EXECUTION_FILE); - - expect(setOutputSpy).not.toHaveBeenCalled(); - }); - - test("should handle missing system.init message gracefully", async () => { - const messages = [{ type: "result", cost_usd: 0.01 }]; - await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); - - await parseAndSetSessionId(TEST_EXECUTION_FILE); - - expect(setOutputSpy).not.toHaveBeenCalled(); - }); - - test("should handle malformed JSON gracefully with warning", async () => { - await writeFile(TEST_EXECUTION_FILE, "{ invalid json"); - - await parseAndSetSessionId(TEST_EXECUTION_FILE); - - expect(setOutputSpy).not.toHaveBeenCalled(); - expect(warningSpy).toHaveBeenCalled(); - }); - - test("should handle non-existent file gracefully with warning", async () => { - await parseAndSetSessionId("/nonexistent/file.json"); - - expect(setOutputSpy).not.toHaveBeenCalled(); - expect(warningSpy).toHaveBeenCalled(); - }); -});