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);
-});