diff --git a/CLAUDE-ACTION.md b/CLAUDE-ACTION.md new file mode 100644 index 0000000..b9291ca --- /dev/null +++ b/CLAUDE-ACTION.md @@ -0,0 +1,299 @@ +# Claude Code Action Architecture & Flow + +This document provides a comprehensive overview of how the Claude Code Action works, from token exchange through post-run cleanup. + +## Overview + +The Claude Code Action is a sophisticated GitHub automation platform that enables Claude to interact with GitHub repositories through secure token exchange, intelligent mode detection, and comprehensive GitHub API integration. + +## High-Level Architecture + +```mermaid +graph TD + Start([GitHub Action Triggered]) --> Setup[Setup Environment
- Install Bun
- Install Dependencies] + + Setup --> ParseContext[Parse GitHub Context
- Extract event data
- Parse inputs] + + ParseContext --> ModeDetection{Mode Detection} + + ModeDetection -->|Has explicit prompt| AgentMode[AGENT MODE
Direct automation] + ModeDetection -->|@claude mention/assignment/label| TagMode[TAG MODE
Interactive response] + ModeDetection -->|No trigger| DefaultAgent[Default to Agent
(won't trigger)] + + %% Token Exchange Branch + AgentMode --> TokenExchange[Token Exchange Process] + TagMode --> TokenExchange + TokenExchange --> TokenMethod{Token Method} + + TokenMethod -->|Custom token provided| UseCustom[Use Custom GitHub Token] + TokenMethod -->|No custom token| OIDC[Generate OIDC Token
core.getIDToken()] + + OIDC --> Exchange[Exchange OIDC for App Token
api.anthropic.com/api/github/github-app-token-exchange] + Exchange --> CreateOctokit[Create Authenticated Octokit Client
REST + GraphQL] + UseCustom --> CreateOctokit + + %% Permission Checks + CreateOctokit --> PermCheck[Check Write Permissions
Only for entity contexts] + PermCheck -->|No permissions| PermFail[❌ Exit: No write access] + PermCheck -->|Has permissions| TriggerCheck{Check Trigger Conditions} + + %% Trigger Validation + TriggerCheck -->|Agent Mode| AgentTrigger{Has explicit prompt?} + TriggerCheck -->|Tag Mode| TagTrigger{Contains @claude mention
or assignment/label?} + + AgentTrigger -->|No prompt| NoTrigger[❌ Skip: No trigger found] + AgentTrigger -->|Has prompt| PrepareAgent[Prepare Agent Mode] + TagTrigger -->|No mention| NoTrigger + TagTrigger -->|Has mention| PrepareTag[Prepare Tag Mode] + + %% Mode-Specific Preparation + PrepareAgent --> AgentPrep[Agent Mode Preparation
- Create prompt file
- Setup MCP servers
- No tracking comment] + PrepareTag --> TagPrep[Tag Mode Preparation
- Create tracking comment
- Setup branches
- Fetch GitHub data
- Setup MCP servers] + + %% Data Fetching (Tag Mode) + TagPrep --> DataFetch[Fetch GitHub Data
GraphQL + REST API] + DataFetch --> FetchWhat{What to fetch?} + + FetchWhat -->|Pull Request| PRData[PR Data:
- Comments & reviews
- Changed files + SHAs
- Commit history
- Author info] + FetchWhat -->|Issue| IssueData[Issue Data:
- Comments
- Issue details
- Author info] + + PRData --> ProcessImages[Process Images
Download & convert to base64] + IssueData --> ProcessImages + ProcessImages --> SetupBranch[Setup Branch
- Create Claude branch
- Configure git auth] + + %% MCP Server Setup + AgentPrep --> MCPSetup[Setup MCP Servers] + SetupBranch --> MCPSetup + + MCPSetup --> MCPServers{MCP Servers} + MCPServers --> GitHubActions[GitHub Actions Server
- Workflow data
- CI results] + MCPServers --> GitHubComments[GitHub Comment Server
- Comment operations] + MCPServers --> GitHubFiles[GitHub File Ops Server
- File operations
- Branch management] + MCPServers --> GitHubInline[GitHub Inline Comment Server
- PR review comments] + + GitHubActions --> PromptGen[Generate Prompt] + GitHubComments --> PromptGen + GitHubFiles --> PromptGen + GitHubInline --> PromptGen + + %% Prompt Generation + PromptGen --> PromptType{Prompt Type} + PromptType -->|Agent Mode| AgentPrompt[Agent Prompt:
- Direct user prompt
- Minimal context] + PromptType -->|Tag Mode| TagPrompt[Tag Prompt:
- Rich GitHub context
- PR/Issue details
- Changed files
- Comments & reviews
- Commit instructions] + + AgentPrompt --> ClaudeRun[Run Claude Code] + TagPrompt --> ClaudeRun + + %% Claude Execution + ClaudeRun --> ClaudeExec[Claude Code Execution
base-action/src/index.ts] + ClaudeExec --> ClaudeArgs[Prepare Claude Args
- Prompt file path
- Custom claude_args
- Output format: stream-json] + + ClaudeArgs --> ClaudeProvider{Provider} + ClaudeProvider -->|Default| AnthropicAPI[Anthropic API
ANTHROPIC_API_KEY] + ClaudeProvider -->|Bedrock| AWSBedrock[AWS Bedrock
OIDC + AWS credentials] + ClaudeProvider -->|Vertex| GCPVertex[GCP Vertex AI
OIDC + GCP credentials] + + AnthropicAPI --> ClaudeProcess[Spawn Claude Process
- Named pipe for input
- Stream JSON output] + AWSBedrock --> ClaudeProcess + GCPVertex --> ClaudeProcess + + ClaudeProcess --> ClaudeTools[Claude Tool Usage
- MCP tools
- File operations
- GitHub API calls
- Bash commands] + + ClaudeTools --> ClaudeOutput[Claude Output Processing
- Capture execution log
- Parse JSON stream
- Extract metrics] + + %% Post-Run Actions + ClaudeOutput --> PostRun{Post-Run Actions} + + PostRun -->|Success| Success[✅ Success Path] + PostRun -->|Failure| Failure[❌ Failure Path] + + Success --> UpdateComment[Update Tracking Comment
- Job run link
- Branch link
- PR link (if created)
- Execution metrics] + Failure --> UpdateComment + + UpdateComment --> BranchCleanup[Branch Cleanup
- Check for changes
- Delete empty branches
- Keep branches with commits] + + BranchCleanup --> FormatReport[Format Execution Report
- Parse conversation turns
- Format tool usage
- Add to GitHub step summary] + + FormatReport --> RevokeToken[Revoke App Token
DELETE /installation/token] + + RevokeToken --> End([Action Complete]) +``` + +## Key Components + +### 1. Token Exchange Process + +The action uses a secure OIDC token exchange system: + +1. **OIDC Token Generation**: `core.getIDToken("claude-code-github-action")` +2. **Token Exchange**: POST to `https://api.anthropic.com/api/github/github-app-token-exchange` +3. **Authentication**: Creates authenticated Octokit clients for GitHub API access + +**Security Benefits:** +- Repository-scoped access +- Time-limited tokens +- Permission-limited (only configured GitHub App permissions) +- Automatic token masking in logs + +### 2. Mode Detection + +The action automatically detects the appropriate execution mode: + +#### **Agent Mode** +- **Trigger**: Explicit `prompt` input provided +- **Use Case**: Direct automation, custom workflows +- **Behavior**: Minimal context, direct execution +- **Tracking**: No tracking comments + +#### **Tag Mode** +- **Trigger**: @claude mentions, issue assignments, or labels +- **Use Case**: Interactive GitHub responses +- **Behavior**: Rich context, comprehensive GitHub data +- **Tracking**: Creates and updates tracking comments + +### 3. Data Fetching (Tag Mode) + +When in Tag Mode, the action fetches comprehensive GitHub context: + +#### **Pull Request Data:** +- Comments and reviews (including inline comments) +- Changed files with SHAs +- Commit history and metadata +- Author information +- File diff data + +#### **Issue Data:** +- Issue details and metadata +- All comments +- Author information +- Labels and assignments + +#### **Image Processing:** +- Downloads images from GitHub +- Converts to base64 for Claude +- Maps original URLs to processed content + +### 4. MCP Server Integration + +The action sets up multiple MCP (Model Context Protocol) servers to provide Claude with GitHub capabilities: + +#### **GitHub Actions Server** +- Access to workflow runs and CI data +- Build status and test results +- Artifact information + +#### **GitHub Comment Server** +- Comment creation and updates +- Issue and PR comment management + +#### **GitHub File Operations Server** +- File reading and writing +- Branch creation and management +- Commit operations + +#### **GitHub Inline Comment Server** +- PR review comment operations +- Line-specific feedback + +### 5. Prompt Generation + +The action generates context-rich prompts based on the detected mode: + +#### **Agent Mode Prompts:** +- Direct user prompt +- Minimal GitHub context +- Focused on specific task + +#### **Tag Mode Prompts:** +- Comprehensive GitHub context +- PR/Issue details and history +- Changed files and diffs +- Comment threads and reviews +- Commit instructions and guidelines + +### 6. Claude Execution + +The action runs Claude Code through multiple provider options: + +#### **Provider Support:** +- **Anthropic API** (default): Direct API access with API key +- **AWS Bedrock**: OIDC authentication with AWS credentials +- **GCP Vertex AI**: OIDC authentication with GCP credentials + +#### **Execution Process:** +1. **Named Pipe Setup**: Creates pipe for prompt input +2. **Process Spawning**: Spawns Claude Code process +3. **Stream Processing**: Captures JSON stream output +4. **Tool Integration**: Enables MCP tools and GitHub operations + +### 7. Post-Run Actions + +After Claude execution, the action performs comprehensive cleanup and reporting: + +#### **Comment Updates:** +- Updates tracking comments with results +- Adds job run links and execution metrics +- Includes branch and PR links when created + +#### **Branch Management:** +- Checks for actual changes in Claude branches +- Deletes empty branches to avoid clutter +- Preserves branches with meaningful commits + +#### **Report Generation:** +- Parses execution logs and conversation turns +- Formats tool usage and results +- Adds formatted report to GitHub step summary + +#### **Security Cleanup:** +- Revokes GitHub App installation token +- Cleans up temporary files and processes + +## Security Considerations + +### **Access Control:** +- Repository-scoped permissions only +- Write access validation for actors +- Bot user controls and allowlists + +### **Token Management:** +- Short-lived installation tokens +- Automatic token revocation after use +- Secure OIDC-based exchange + +### **Permission Boundaries:** +- Limited to configured GitHub App permissions +- No cross-repository access +- Scoped to specific repository operations + +## Integration Points + +### **With Pullfrog:** +The Claude Code Action can be integrated with Pullfrog's workflow system, providing: +- Standardized agent interaction patterns +- Consistent GitHub integration +- Reusable authentication flows +- Common MCP server infrastructure + +### **With GitHub:** +- Native GitHub Actions integration +- Comprehensive API coverage (REST + GraphQL) +- Proper webhook handling +- Standard GitHub UI integration + +## Development Notes + +### **Key Files:** +- `src/entrypoints/prepare.ts`: Main preparation logic +- `src/modes/`: Mode detection and handling +- `src/github/token.ts`: OIDC token exchange +- `src/mcp/`: MCP server implementations +- `base-action/`: Core Claude Code execution + +### **Testing:** +- Unit tests for individual components +- Integration tests for full workflows +- Local testing with `act` tool +- Comprehensive fixture support + +This architecture provides a robust, secure, and extensible foundation for Claude-GitHub integration while maintaining clear separation of concerns and comprehensive error handling. diff --git a/README.md b/README.md index 46ded6e..6179a4b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ GitHub Action for running Claude Code and other agents via Pullfrog. +> **📖 Claude Code Action Architecture**: For a detailed technical overview of how the Claude Code Action works (token exchange, modes, data fetching, execution flow), see [CLAUDE-ACTION.md](./CLAUDE-ACTION.md). + ## Quick Start ```bash diff --git a/agents/claude.ts b/agents/claude.ts index 0fe090e..ead6c2d 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -53,10 +53,7 @@ export class ClaudeAgent implements Agent { // Use shell execution to properly handle the pipe const result = await spawn({ cmd: "bash", - args: [ - "-c", - "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93", - ], + args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93"], env: { ANTHROPIC_API_KEY: this.apiKey }, timeout: 120000, // 2 minute timeout onStdout: (chunk) => { @@ -67,9 +64,7 @@ export class ClaudeAgent implements Agent { }); if (result.exitCode !== 0) { - throw new Error( - `Installation failed with exit code ${result.exitCode}: ${result.stderr}`, - ); + throw new Error(`Installation failed with exit code ${result.exitCode}: ${result.stderr}`); } core.info("Claude Code installed successfully"); @@ -139,7 +134,7 @@ export class ClaudeAgent implements Agent { // throw on non-zero exit code if (result.exitCode !== 0) { throw new Error( - `Command failed with exit code ${result.exitCode}\n\nStdout: ${result.stdout}\n\nStderr: ${result.stderr}`, + `Command failed with exit code ${result.exitCode}\n\nStdout: ${result.stdout}\n\nStderr: ${result.stderr}` ); } @@ -158,7 +153,7 @@ export class ClaudeAgent implements Agent { // Log run summary const duration = Date.now() - this.runStats.startTime; core.info( - `📊 Run Summary: ${this.runStats.toolsUsed} tools used, ${this.runStats.turns} turns, ${duration}ms duration`, + `📊 Run Summary: ${this.runStats.toolsUsed} tools used, ${this.runStats.turns} turns, ${duration}ms duration` ); core.info("✅ Task complete."); @@ -181,8 +176,7 @@ export class ClaudeAgent implements Agent { } catch { // Group might not have been started, ignore } - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; + const errorMessage = error instanceof Error ? error.message : "Unknown error"; return { success: false, error: `Failed to execute Claude Code: ${errorMessage}`, @@ -233,12 +227,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void { ["model", parsedChunk.model], ["cwd", parsedChunk.cwd], ["permission_mode", parsedChunk.permissionMode], - [ - "tools", - parsedChunk.tools?.length - ? `${parsedChunk.tools.length} tools` - : "none", - ], + ["tools", parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none"], [ "mcp_servers", parsedChunk.mcp_servers?.length @@ -251,7 +240,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void { ? `${parsedChunk.slash_commands.length} commands` : "none", ], - ]), + ]) ); } break; @@ -267,9 +256,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void { if (content.type === "text") { // Skip empty text content if (content.text.trim()) { - core.info( - boxString(content.text.trim(), { title: "Claude Code" }), - ); + core.info(boxString(content.text.trim(), { title: "Claude Code" })); } } else if (content.type === "tool_use") { // Track tools used @@ -379,15 +366,12 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void { core.info( tableString([ - [ - "Cost", - `$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`, - ], + ["Cost", `$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`], ["Input Tokens", parsedChunk.usage?.input_tokens || 0], ["Output Tokens", parsedChunk.usage?.output_tokens || 0], ["Duration", `${parsedChunk.duration_ms}ms`], ["Turns", parsedChunk.num_turns || 1], - ]), + ]) ); } else { core.error(`❌ Failed: ${parsedChunk.error || "Unknown error"}`); diff --git a/entry.cjs b/entry.cjs index b05ef72..bf3d116 100755 --- a/entry.cjs +++ b/entry.cjs @@ -25749,10 +25749,7 @@ var ClaudeAgent = class { try { const result = await spawn({ cmd: "bash", - args: [ - "-c", - "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93" - ], + args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93"], env: { ANTHROPIC_API_KEY: this.apiKey }, timeout: 12e4, // 2 minute timeout @@ -25761,9 +25758,7 @@ var ClaudeAgent = class { onStderr: (chunk) => process.stderr.write(chunk) }); if (result.exitCode !== 0) { - throw new Error( - `Installation failed with exit code ${result.exitCode}: ${result.stderr}` - ); + throw new Error(`Installation failed with exit code ${result.exitCode}: ${result.stderr}`); } core2.info("Claude Code installed successfully"); } catch (error2) { @@ -25864,10 +25859,7 @@ function processJSONChunk(chunk, agent) { ["model", parsedChunk.model], ["cwd", parsedChunk.cwd], ["permission_mode", parsedChunk.permissionMode], - [ - "tools", - parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none" - ], + ["tools", parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none"], [ "mcp_servers", parsedChunk.mcp_servers?.length ? `${parsedChunk.mcp_servers.length} servers` : "none" @@ -25888,9 +25880,7 @@ function processJSONChunk(chunk, agent) { for (const content of parsedChunk.message.content) { if (content.type === "text") { if (content.text.trim()) { - core2.info( - boxString(content.text.trim(), { title: "Claude Code" }) - ); + core2.info(boxString(content.text.trim(), { title: "Claude Code" })); } } else if (content.type === "tool_use") { if (agent) { @@ -25958,10 +25948,7 @@ function processJSONChunk(chunk, agent) { if (parsedChunk.subtype === "success") { core2.info( tableString([ - [ - "Cost", - `$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}` - ], + ["Cost", `$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`], ["Input Tokens", parsedChunk.usage?.input_tokens || 0], ["Output Tokens", parsedChunk.usage?.output_tokens || 0], ["Duration", `${parsedChunk.duration_ms}ms`], diff --git a/main.ts b/main.ts index 45876eb..af9141b 100644 --- a/main.ts +++ b/main.ts @@ -4,8 +4,8 @@ import { ClaudeAgent } from "./agents"; // Expected environment variables that should be passed as inputs export const EXPECTED_INPUTS: string[] = [ "ANTHROPIC_API_KEY", - "GITHUB_TOKEN", - "GITHUB_INSTALLATION_TOKEN" + "GITHUB_TOKEN", + "GITHUB_INSTALLATION_TOKEN", ]; export interface ExecutionInputs { @@ -27,7 +27,6 @@ export interface MainResult { error?: string | undefined; } - export async function main(params: MainParams): Promise { try { // Extract inputs from params diff --git a/package.json b/package.json index f767914..94b9c92 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@types/node": "^20.10.0", - "commander": "^14.0.0", + "arg": "^5.0.2", "esbuild": "^0.25.9", "husky": "^9.0.0", "typescript": "^5.3.0", diff --git a/play.ts b/play.ts index 3078186..0b70442 100644 --- a/play.ts +++ b/play.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, extname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { Command } from "commander"; +import arg from "arg"; import { config } from "dotenv"; import { main } from "./main"; import { runAct } from "./utils/act"; @@ -10,151 +10,189 @@ import { setupTestRepo } from "./utils/setup"; // Load environment variables from .env file config(); - const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -async function loadPrompt(filePath: string): Promise { - const ext = extname(filePath).toLowerCase(); - - // Try to resolve the file path - let resolvedPath: string; - - // First try as fixtures path - const fixturesPath = join(__dirname, "fixtures", filePath); - if (existsSync(fixturesPath)) { - resolvedPath = fixturesPath; - } else if (existsSync(filePath)) { - resolvedPath = resolve(filePath); - } else { - throw new Error(`File not found: ${filePath}`); - } - - switch (ext) { - case ".txt": { - // Plain text - pass directly as prompt - return readFileSync(resolvedPath, "utf8").trim(); - } - - case ".json": { - // JSON - stringify and pass as prompt - const content = readFileSync(resolvedPath, "utf8"); - const parsed = JSON.parse(content); - return JSON.stringify(parsed, null, 2); - } - - case ".ts": { - // TypeScript - dynamic import and stringify default export - const fileUrl = pathToFileURL(resolvedPath).href; - const module = await import(fileUrl); - - if (!module.default) { - throw new Error(`TypeScript file ${filePath} must have a default export`); - } - - // If it's a string, use it directly - if (typeof module.default === "string") { - return module.default; - } - - // If it's a MainParams object with a prompt field, extract the prompt - if (typeof module.default === "object" && module.default.prompt) { - return module.default.prompt; - } - - // Otherwise stringify it - return JSON.stringify(module.default, null, 2); - } - - default: - throw new Error(`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`); - } -} - -async function runPlay(filePath: string, options: { act?: boolean }): Promise { +export async function run( + prompt: string, + options: { act?: boolean } = {} +): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> { try { - // Load the prompt from the specified file - const prompt = await loadPrompt(filePath); - if (options.act) { // Use Docker/act to run the action console.log("🐳 Running with Docker/act..."); runAct(prompt); + return { success: true }; + } + + // Setup test repository and run directly + const tempDir = join(process.cwd(), ".temp"); + setupTestRepo({ tempDir, forceClean: true }); + + // Change to the temp directory + const originalCwd = process.cwd(); + process.chdir(tempDir); + + console.log("🚀 Running action with prompt..."); + console.log("─".repeat(50)); + console.log("Prompt:"); + console.log(prompt); + console.log("─".repeat(50)); + + // Set environment variables from our .env for the action to use + const { EXPECTED_INPUTS } = await import("./main"); + EXPECTED_INPUTS.forEach((inputName) => { + const value = process.env[inputName]; + if (value) { + process.env[`INPUT_${inputName.toLowerCase()}`] = value; + } + }); + + // Run main with the new params structure + const inputs: any = { + prompt, + anthropic_api_key: process.env.ANTHROPIC_API_KEY || "", + }; + + // Add optional properties only if they exist + if (process.env.GITHUB_TOKEN) { + inputs.github_token = process.env.GITHUB_TOKEN; + } + + if (process.env.GITHUB_INSTALLATION_TOKEN) { + inputs.github_installation_token = process.env.GITHUB_INSTALLATION_TOKEN; + } + + const result = await main({ + inputs, + env: process.env as Record, + cwd: process.cwd(), + }); + + // Change back to original directory + process.chdir(originalCwd); + + if (result.success) { + console.log("✅ Action completed successfully"); + if (result.output) { + console.log("Output:", result.output); + } + return { success: true, output: result.output || undefined, error: undefined }; } else { - // Setup test repository and run directly - const tempDir = join(process.cwd(), ".temp"); - setupTestRepo({ tempDir, forceClean: true }); + console.error("❌ Action failed:", result.error); + return { success: false, error: result.error || undefined, output: undefined }; + } + } catch (error) { + const errorMessage = (error as Error).message; + console.error("❌ Error:", errorMessage); + return { success: false, error: errorMessage, output: undefined }; + } +} - // Change to the temp directory - process.chdir(tempDir); +// CLI execution when run directly +if (import.meta.url === `file://${process.argv[1]}`) { + const args = arg({ + "--help": Boolean, + "--act": Boolean, + "--raw": String, + "-h": "--help", + }); - console.log("🚀 Running test in .temp directory..."); - console.log("─".repeat(50)); - console.log(`Prompt from ${filePath}:`); - console.log(prompt); - console.log("─".repeat(50)); + if (args["--help"]) { + console.log(` +Usage: tsx play.ts [file] [options] - // Set environment variables from our .env for the action to use - const { EXPECTED_INPUTS } = await import("./main"); - EXPECTED_INPUTS.forEach((inputName) => { - const value = process.env[inputName]; - if (value) { - process.env[`INPUT_${inputName.toLowerCase()}`] = value; +Test the Pullfrog action with various prompts. + +Arguments: + file Prompt file to use (.txt, .json, or .ts) [default: fixtures/basic.txt] + +Options: + --act Use Docker/act to run the action instead of running directly + --raw [prompt] Use raw string as prompt instead of loading from file + -h, --help Show this help message + +Examples: + tsx play.ts # Use default fixture + tsx play.ts fixtures/basic.txt # Use specific text file + tsx play.ts custom.json # Use JSON file + tsx play.ts --act fixtures/test.ts # Use TypeScript file with Docker/act + tsx play.ts --raw "Hello world" # Use raw string as prompt + `); + process.exit(0); + } + + let prompt: string; + + if (args["--raw"]) { + // Use raw prompt string + prompt = args["--raw"]; + } else { + // Load prompt from file + const filePath = args._[0] || "fixtures/basic.txt"; + const ext = extname(filePath).toLowerCase(); + let resolvedPath: string; + + // First try as fixtures path + const fixturesPath = join(__dirname, "fixtures", filePath); + if (existsSync(fixturesPath)) { + resolvedPath = fixturesPath; + } else if (existsSync(filePath)) { + resolvedPath = resolve(filePath); + } else { + throw new Error(`File not found: ${filePath}`); + } + + switch (ext) { + case ".txt": { + // Plain text - pass directly as prompt + prompt = readFileSync(resolvedPath, "utf8").trim(); + break; + } + + case ".json": { + // JSON - stringify and pass as prompt + const content = readFileSync(resolvedPath, "utf8"); + const parsed = JSON.parse(content); + prompt = JSON.stringify(parsed, null, 2); + break; + } + + case ".ts": { + // TypeScript - dynamic import and stringify default export + const fileUrl = pathToFileURL(resolvedPath).href; + const module = await import(fileUrl); + + if (!module.default) { + throw new Error(`TypeScript file ${filePath} must have a default export`); } - }); - // Run main with the new params structure - const inputs: any = { - prompt, - anthropic_api_key: process.env.ANTHROPIC_API_KEY || "", - }; - - // Add optional properties only if they exist - if (process.env.GITHUB_TOKEN) { - inputs.github_token = process.env.GITHUB_TOKEN; - } - - if (process.env.GITHUB_INSTALLATION_TOKEN) { - inputs.github_installation_token = process.env.GITHUB_INSTALLATION_TOKEN; - } - - const result = await main({ - inputs, - env: process.env as Record, - cwd: process.cwd(), - }); - - if (result.success) { - console.log("✅ Test completed successfully"); - if (result.output) { - console.log("Output:", result.output); + // If it's a string, use it directly + if (typeof module.default === "string") { + prompt = module.default; + } else if (typeof module.default === "object" && module.default.prompt) { + // If it's a MainParams object with a prompt field, extract the prompt + prompt = module.default.prompt; + } else { + // Otherwise stringify it + prompt = JSON.stringify(module.default, null, 2); } - } else { - console.error("❌ Test failed:", result.error); - process.exit(1); + break; } + + default: + throw new Error(`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`); + } + } + + try { + const result = await run(prompt, { act: args["--act"] || false }); + + if (!result.success) { + process.exit(1); } } catch (error) { console.error("❌ Error:", (error as Error).message); process.exit(1); } } - -// Set up CLI -const program = new Command(); - -program - .name("play") - .description("Test the Pullfrog action with various prompts") - .version("1.0.0") - .argument("[file]", "Prompt file to use (.txt, .json, or .ts)", "fixtures/basic.txt") - .option("--act", "Use Docker/act to run the action instead of running directly") - .action(async (file: string, options: { act?: boolean }) => { - await runPlay(file, options); - }); - -// Parse arguments and run -program.parseAsync(process.argv).catch((error) => { - console.error("Fatal error:", error); - process.exit(1); -});