Update
This commit is contained in:
@@ -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,6 +2,8 @@
|
|||||||
|
|
||||||
GitHub Action for running Claude Code and other agents via Pullfrog.
|
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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+10
-26
@@ -53,10 +53,7 @@ export class ClaudeAgent implements Agent {
|
|||||||
// Use shell execution to properly handle the pipe
|
// Use shell execution to properly handle the pipe
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd: "bash",
|
cmd: "bash",
|
||||||
args: [
|
args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93"],
|
||||||
"-c",
|
|
||||||
"curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93",
|
|
||||||
],
|
|
||||||
env: { ANTHROPIC_API_KEY: this.apiKey },
|
env: { ANTHROPIC_API_KEY: this.apiKey },
|
||||||
timeout: 120000, // 2 minute timeout
|
timeout: 120000, // 2 minute timeout
|
||||||
onStdout: (chunk) => {
|
onStdout: (chunk) => {
|
||||||
@@ -67,9 +64,7 @@ export class ClaudeAgent implements Agent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Error(
|
throw new Error(`Installation failed with exit code ${result.exitCode}: ${result.stderr}`);
|
||||||
`Installation failed with exit code ${result.exitCode}: ${result.stderr}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
core.info("Claude Code installed successfully");
|
core.info("Claude Code installed successfully");
|
||||||
@@ -139,7 +134,7 @@ export class ClaudeAgent implements Agent {
|
|||||||
// throw on non-zero exit code
|
// throw on non-zero exit code
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Error(
|
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
|
// Log run summary
|
||||||
const duration = Date.now() - this.runStats.startTime;
|
const duration = Date.now() - this.runStats.startTime;
|
||||||
core.info(
|
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.");
|
core.info("✅ Task complete.");
|
||||||
@@ -181,8 +176,7 @@ export class ClaudeAgent implements Agent {
|
|||||||
} catch {
|
} catch {
|
||||||
// Group might not have been started, ignore
|
// Group might not have been started, ignore
|
||||||
}
|
}
|
||||||
const errorMessage =
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Failed to execute Claude Code: ${errorMessage}`,
|
error: `Failed to execute Claude Code: ${errorMessage}`,
|
||||||
@@ -233,12 +227,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
|
|||||||
["model", parsedChunk.model],
|
["model", parsedChunk.model],
|
||||||
["cwd", parsedChunk.cwd],
|
["cwd", parsedChunk.cwd],
|
||||||
["permission_mode", parsedChunk.permissionMode],
|
["permission_mode", parsedChunk.permissionMode],
|
||||||
[
|
["tools", parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none"],
|
||||||
"tools",
|
|
||||||
parsedChunk.tools?.length
|
|
||||||
? `${parsedChunk.tools.length} tools`
|
|
||||||
: "none",
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"mcp_servers",
|
"mcp_servers",
|
||||||
parsedChunk.mcp_servers?.length
|
parsedChunk.mcp_servers?.length
|
||||||
@@ -251,7 +240,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
|
|||||||
? `${parsedChunk.slash_commands.length} commands`
|
? `${parsedChunk.slash_commands.length} commands`
|
||||||
: "none",
|
: "none",
|
||||||
],
|
],
|
||||||
]),
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -267,9 +256,7 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
|
|||||||
if (content.type === "text") {
|
if (content.type === "text") {
|
||||||
// Skip empty text content
|
// Skip empty text content
|
||||||
if (content.text.trim()) {
|
if (content.text.trim()) {
|
||||||
core.info(
|
core.info(boxString(content.text.trim(), { title: "Claude Code" }));
|
||||||
boxString(content.text.trim(), { title: "Claude Code" }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (content.type === "tool_use") {
|
} else if (content.type === "tool_use") {
|
||||||
// Track tools used
|
// Track tools used
|
||||||
@@ -379,15 +366,12 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
|
|||||||
|
|
||||||
core.info(
|
core.info(
|
||||||
tableString([
|
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],
|
["Input Tokens", parsedChunk.usage?.input_tokens || 0],
|
||||||
["Output Tokens", parsedChunk.usage?.output_tokens || 0],
|
["Output Tokens", parsedChunk.usage?.output_tokens || 0],
|
||||||
["Duration", `${parsedChunk.duration_ms}ms`],
|
["Duration", `${parsedChunk.duration_ms}ms`],
|
||||||
["Turns", parsedChunk.num_turns || 1],
|
["Turns", parsedChunk.num_turns || 1],
|
||||||
]),
|
])
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
core.error(`❌ Failed: ${parsedChunk.error || "Unknown error"}`);
|
core.error(`❌ Failed: ${parsedChunk.error || "Unknown error"}`);
|
||||||
|
|||||||
@@ -25749,10 +25749,7 @@ var ClaudeAgent = class {
|
|||||||
try {
|
try {
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd: "bash",
|
cmd: "bash",
|
||||||
args: [
|
args: ["-c", "curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93"],
|
||||||
"-c",
|
|
||||||
"curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93"
|
|
||||||
],
|
|
||||||
env: { ANTHROPIC_API_KEY: this.apiKey },
|
env: { ANTHROPIC_API_KEY: this.apiKey },
|
||||||
timeout: 12e4,
|
timeout: 12e4,
|
||||||
// 2 minute timeout
|
// 2 minute timeout
|
||||||
@@ -25761,9 +25758,7 @@ var ClaudeAgent = class {
|
|||||||
onStderr: (chunk) => process.stderr.write(chunk)
|
onStderr: (chunk) => process.stderr.write(chunk)
|
||||||
});
|
});
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Error(
|
throw new Error(`Installation failed with exit code ${result.exitCode}: ${result.stderr}`);
|
||||||
`Installation failed with exit code ${result.exitCode}: ${result.stderr}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
core2.info("Claude Code installed successfully");
|
core2.info("Claude Code installed successfully");
|
||||||
} catch (error2) {
|
} catch (error2) {
|
||||||
@@ -25864,10 +25859,7 @@ function processJSONChunk(chunk, agent) {
|
|||||||
["model", parsedChunk.model],
|
["model", parsedChunk.model],
|
||||||
["cwd", parsedChunk.cwd],
|
["cwd", parsedChunk.cwd],
|
||||||
["permission_mode", parsedChunk.permissionMode],
|
["permission_mode", parsedChunk.permissionMode],
|
||||||
[
|
["tools", parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none"],
|
||||||
"tools",
|
|
||||||
parsedChunk.tools?.length ? `${parsedChunk.tools.length} tools` : "none"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"mcp_servers",
|
"mcp_servers",
|
||||||
parsedChunk.mcp_servers?.length ? `${parsedChunk.mcp_servers.length} servers` : "none"
|
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) {
|
for (const content of parsedChunk.message.content) {
|
||||||
if (content.type === "text") {
|
if (content.type === "text") {
|
||||||
if (content.text.trim()) {
|
if (content.text.trim()) {
|
||||||
core2.info(
|
core2.info(boxString(content.text.trim(), { title: "Claude Code" }));
|
||||||
boxString(content.text.trim(), { title: "Claude Code" })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (content.type === "tool_use") {
|
} else if (content.type === "tool_use") {
|
||||||
if (agent) {
|
if (agent) {
|
||||||
@@ -25958,10 +25948,7 @@ function processJSONChunk(chunk, agent) {
|
|||||||
if (parsedChunk.subtype === "success") {
|
if (parsedChunk.subtype === "success") {
|
||||||
core2.info(
|
core2.info(
|
||||||
tableString([
|
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],
|
["Input Tokens", parsedChunk.usage?.input_tokens || 0],
|
||||||
["Output Tokens", parsedChunk.usage?.output_tokens || 0],
|
["Output Tokens", parsedChunk.usage?.output_tokens || 0],
|
||||||
["Duration", `${parsedChunk.duration_ms}ms`],
|
["Duration", `${parsedChunk.duration_ms}ms`],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ClaudeAgent } from "./agents";
|
|||||||
export const EXPECTED_INPUTS: string[] = [
|
export const EXPECTED_INPUTS: string[] = [
|
||||||
"ANTHROPIC_API_KEY",
|
"ANTHROPIC_API_KEY",
|
||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
"GITHUB_INSTALLATION_TOKEN"
|
"GITHUB_INSTALLATION_TOKEN",
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ExecutionInputs {
|
export interface ExecutionInputs {
|
||||||
@@ -27,7 +27,6 @@ export interface MainResult {
|
|||||||
error?: string | undefined;
|
error?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function main(params: MainParams): Promise<MainResult> {
|
export async function main(params: MainParams): Promise<MainResult> {
|
||||||
try {
|
try {
|
||||||
// Extract inputs from params
|
// Extract inputs from params
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"commander": "^14.0.0",
|
"arg": "^5.0.2",
|
||||||
"esbuild": "^0.25.9",
|
"esbuild": "^0.25.9",
|
||||||
"husky": "^9.0.0",
|
"husky": "^9.0.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { dirname, extname, join, resolve } from "node:path";
|
import { dirname, extname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { Command } from "commander";
|
import arg from "arg";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
import { main } from "./main";
|
import { main } from "./main";
|
||||||
import { runAct } from "./utils/act";
|
import { runAct } from "./utils/act";
|
||||||
@@ -10,151 +10,189 @@ import { setupTestRepo } from "./utils/setup";
|
|||||||
// Load environment variables from .env file
|
// Load environment variables from .env file
|
||||||
config();
|
config();
|
||||||
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
async function loadPrompt(filePath: string): Promise<string> {
|
export async function run(
|
||||||
const ext = extname(filePath).toLowerCase();
|
prompt: string,
|
||||||
|
options: { act?: boolean } = {}
|
||||||
// Try to resolve the file path
|
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
|
||||||
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> {
|
|
||||||
try {
|
try {
|
||||||
// Load the prompt from the specified file
|
|
||||||
const prompt = await loadPrompt(filePath);
|
|
||||||
|
|
||||||
if (options.act) {
|
if (options.act) {
|
||||||
// Use Docker/act to run the action
|
// Use Docker/act to run the action
|
||||||
console.log("🐳 Running with Docker/act...");
|
console.log("🐳 Running with Docker/act...");
|
||||||
runAct(prompt);
|
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 {
|
} else {
|
||||||
// Setup test repository and run directly
|
console.error("❌ Action failed:", result.error);
|
||||||
const tempDir = join(process.cwd(), ".temp");
|
return { success: false, error: result.error || undefined, output: undefined };
|
||||||
setupTestRepo({ tempDir, forceClean: true });
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = (error as Error).message;
|
||||||
|
console.error("❌ Error:", errorMessage);
|
||||||
|
return { success: false, error: errorMessage, output: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Change to the temp directory
|
// CLI execution when run directly
|
||||||
process.chdir(tempDir);
|
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...");
|
if (args["--help"]) {
|
||||||
console.log("─".repeat(50));
|
console.log(`
|
||||||
console.log(`Prompt from ${filePath}:`);
|
Usage: tsx play.ts [file] [options]
|
||||||
console.log(prompt);
|
|
||||||
console.log("─".repeat(50));
|
|
||||||
|
|
||||||
// Set environment variables from our .env for the action to use
|
Test the Pullfrog action with various prompts.
|
||||||
const { EXPECTED_INPUTS } = await import("./main");
|
|
||||||
EXPECTED_INPUTS.forEach((inputName) => {
|
Arguments:
|
||||||
const value = process.env[inputName];
|
file Prompt file to use (.txt, .json, or .ts) [default: fixtures/basic.txt]
|
||||||
if (value) {
|
|
||||||
process.env[`INPUT_${inputName.toLowerCase()}`] = value;
|
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
|
// If it's a string, use it directly
|
||||||
const inputs: any = {
|
if (typeof module.default === "string") {
|
||||||
prompt,
|
prompt = module.default;
|
||||||
anthropic_api_key: process.env.ANTHROPIC_API_KEY || "",
|
} 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;
|
||||||
// Add optional properties only if they exist
|
} else {
|
||||||
if (process.env.GITHUB_TOKEN) {
|
// Otherwise stringify it
|
||||||
inputs.github_token = process.env.GITHUB_TOKEN;
|
prompt = JSON.stringify(module.default, null, 2);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else {
|
break;
|
||||||
console.error("❌ Test failed:", result.error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error("❌ Error:", (error as Error).message);
|
console.error("❌ Error:", (error as Error).message);
|
||||||
process.exit(1);
|
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);
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user