This commit is contained in:
Colin McDonnell
2025-09-16 03:22:12 -07:00
parent 70b365fca1
commit e672deb934
7 changed files with 484 additions and 175 deletions
+299
View File
@@ -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<br/>- Install Bun<br/>- Install Dependencies]
Setup --> ParseContext[Parse GitHub Context<br/>- Extract event data<br/>- Parse inputs]
ParseContext --> ModeDetection{Mode Detection}
ModeDetection -->|Has explicit prompt| AgentMode[AGENT MODE<br/>Direct automation]
ModeDetection -->|@claude mention/assignment/label| TagMode[TAG MODE<br/>Interactive response]
ModeDetection -->|No trigger| DefaultAgent[Default to Agent<br/>(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<br/>core.getIDToken()]
OIDC --> Exchange[Exchange OIDC for App Token<br/>api.anthropic.com/api/github/github-app-token-exchange]
Exchange --> CreateOctokit[Create Authenticated Octokit Client<br/>REST + GraphQL]
UseCustom --> CreateOctokit
%% Permission Checks
CreateOctokit --> PermCheck[Check Write Permissions<br/>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<br/>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<br/>- Create prompt file<br/>- Setup MCP servers<br/>- No tracking comment]
PrepareTag --> TagPrep[Tag Mode Preparation<br/>- Create tracking comment<br/>- Setup branches<br/>- Fetch GitHub data<br/>- Setup MCP servers]
%% Data Fetching (Tag Mode)
TagPrep --> DataFetch[Fetch GitHub Data<br/>GraphQL + REST API]
DataFetch --> FetchWhat{What to fetch?}
FetchWhat -->|Pull Request| PRData[PR Data:<br/>- Comments & reviews<br/>- Changed files + SHAs<br/>- Commit history<br/>- Author info]
FetchWhat -->|Issue| IssueData[Issue Data:<br/>- Comments<br/>- Issue details<br/>- Author info]
PRData --> ProcessImages[Process Images<br/>Download & convert to base64]
IssueData --> ProcessImages
ProcessImages --> SetupBranch[Setup Branch<br/>- Create Claude branch<br/>- Configure git auth]
%% MCP Server Setup
AgentPrep --> MCPSetup[Setup MCP Servers]
SetupBranch --> MCPSetup
MCPSetup --> MCPServers{MCP Servers}
MCPServers --> GitHubActions[GitHub Actions Server<br/>- Workflow data<br/>- CI results]
MCPServers --> GitHubComments[GitHub Comment Server<br/>- Comment operations]
MCPServers --> GitHubFiles[GitHub File Ops Server<br/>- File operations<br/>- Branch management]
MCPServers --> GitHubInline[GitHub Inline Comment Server<br/>- 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:<br/>- Direct user prompt<br/>- Minimal context]
PromptType -->|Tag Mode| TagPrompt[Tag Prompt:<br/>- Rich GitHub context<br/>- PR/Issue details<br/>- Changed files<br/>- Comments & reviews<br/>- Commit instructions]
AgentPrompt --> ClaudeRun[Run Claude Code]
TagPrompt --> ClaudeRun
%% Claude Execution
ClaudeRun --> ClaudeExec[Claude Code Execution<br/>base-action/src/index.ts]
ClaudeExec --> ClaudeArgs[Prepare Claude Args<br/>- Prompt file path<br/>- Custom claude_args<br/>- Output format: stream-json]
ClaudeArgs --> ClaudeProvider{Provider}
ClaudeProvider -->|Default| AnthropicAPI[Anthropic API<br/>ANTHROPIC_API_KEY]
ClaudeProvider -->|Bedrock| AWSBedrock[AWS Bedrock<br/>OIDC + AWS credentials]
ClaudeProvider -->|Vertex| GCPVertex[GCP Vertex AI<br/>OIDC + GCP credentials]
AnthropicAPI --> ClaudeProcess[Spawn Claude Process<br/>- Named pipe for input<br/>- Stream JSON output]
AWSBedrock --> ClaudeProcess
GCPVertex --> ClaudeProcess
ClaudeProcess --> ClaudeTools[Claude Tool Usage<br/>- MCP tools<br/>- File operations<br/>- GitHub API calls<br/>- Bash commands]
ClaudeTools --> ClaudeOutput[Claude Output Processing<br/>- Capture execution log<br/>- Parse JSON stream<br/>- 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<br/>- Job run link<br/>- Branch link<br/>- PR link (if created)<br/>- Execution metrics]
Failure --> UpdateComment
UpdateComment --> BranchCleanup[Branch Cleanup<br/>- Check for changes<br/>- Delete empty branches<br/>- Keep branches with commits]
BranchCleanup --> FormatReport[Format Execution Report<br/>- Parse conversation turns<br/>- Format tool usage<br/>- Add to GitHub step summary]
FormatReport --> RevokeToken[Revoke App Token<br/>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.
+2
View File
@@ -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
+10 -26
View File
@@ -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"}`);
+5 -18
View File
@@ -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`],
+2 -3
View File
@@ -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<MainResult> {
try {
// Extract inputs from params
+1 -1
View File
@@ -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",
+165 -127
View File
@@ -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<string> {
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<void> {
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<string, string>,
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<string, string>,
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);
});