Compare commits

..

19 Commits

Author SHA1 Message Date
ssalbdivad a1f87ce118 unify installation token logic 2025-10-09 17:14:34 -04:00
ssalbdivad 3e7122611c use GITHUB_REPOSITORY for context 2025-10-09 17:04:03 -04:00
ssalbdivad 9459803aaa cleanup comments 2025-10-09 16:33:11 -04:00
ssalbdivad f74a75cfac generate installation token for each play run 2025-10-09 16:23:36 -04:00
David Blass 16e04e7152 bump version 2025-10-08 16:49:12 -04:00
David Blass 522779ef54 bump version 2025-10-08 16:47:03 -04:00
David Blass d3d2dad025 no-frozen-lockfile 2025-10-08 16:46:52 -04:00
David Blass 66bf86f081 bump version 2025-10-08 16:42:55 -04:00
David Blass 608322f026 use frozen lockfile in ci 2025-10-08 16:42:09 -04:00
David Blass e3a3d416fb bump version 2025-10-08 16:33:04 -04:00
David Blass f0e339f5c2 big 2025-10-08 16:21:10 -04:00
David Blass 87d32763e9 fix 2025-10-08 15:19:02 -04:00
ssalbdivad 671334f37d embarrassing 2025-10-08 14:07:28 -04:00
David Blass 267ed3686f bump version 2025-09-24 13:52:37 -04:00
David Blass ff1226a824 fix input type 2025-09-24 13:52:18 -04:00
ssalbdivad e13c5eed00 cleanup, add InstallationToken type 2025-09-23 12:48:35 -04:00
Colin McDonnell f2a1c3c1bb Update 2025-09-16 03:22:50 -07:00
Colin McDonnell e672deb934 Update 2025-09-16 03:22:12 -07:00
Colin McDonnell 70b365fca1 Clean up msgs 2025-09-10 15:01:45 -07:00
23 changed files with 2112 additions and 691 deletions
+299
View File
@@ -0,0 +1,299 @@
# Claude Code Action Architecture & Flow
This document provides a comprehensive overview of how the official (Anthropic) 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
+25 -114
View File
@@ -1,8 +1,9 @@
import { access, constants } from "node:fs/promises";
import * as core from "@actions/core";
import { boxString, tableString } from "../utils";
import { spawn } from "../utils/subprocess";
import type { Agent, AgentConfig, AgentResult } from "./types";
import { createMcpConfig } from "../mcp/config.ts";
import { spawn } from "../utils/subprocess.ts";
import { boxString, tableString } from "../utils/table.ts";
import type { Agent, AgentConfig, AgentResult } from "./types.ts";
/**
* Claude Code agent implementation
@@ -15,14 +16,12 @@ export class ClaudeAgent implements Agent {
startTime: 0,
};
// $: ExecaMethod;
constructor(config: AgentConfig) {
if (!config.apiKey) {
throw new Error("Claude agent requires an API key");
}
this.apiKey = config.apiKey;
// Removed execa dependency - using spawn utility instead
}
/**
@@ -42,7 +41,6 @@ export class ClaudeAgent implements Agent {
* Install Claude Code CLI
*/
async install(): Promise<void> {
// Check if Claude Code is already installed
if (await this.isClaudeInstalled()) {
core.info("Claude Code is already installed, skipping installation");
return;
@@ -50,26 +48,17 @@ export class ClaudeAgent implements Agent {
core.info("Installing Claude Code...");
try {
// 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) => {
// no logs
// process.stdout.write(chunk)
},
onStdout: () => {},
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}`);
}
core.info("Claude Code installed successfully");
@@ -83,14 +72,10 @@ export class ClaudeAgent implements Agent {
*/
async execute(prompt: string): Promise<AgentResult> {
core.info("Running Claude Code...");
// printTable([[prompt]]);
try {
// Execute Claude Code with the prompt directly using proper headless mode
// core.info(`Executing Claude Code with prompt: ${prompt.substring(0, 100)}...`);
const claudePath = `${process.env.HOME}/.local/bin/claude`;
// console.log("Using Claude Code from:", claudePath);
console.log(boxString(prompt, { title: "Prompt" }));
const args = [
"--print",
@@ -98,16 +83,23 @@ export class ClaudeAgent implements Agent {
"stream-json",
"--verbose",
"--permission-mode",
"acceptEdits",
"bypassPermissions",
];
if (!process.env.GITHUB_INSTALLATION_TOKEN) {
throw new Error("GITHUB_INSTALLATION_TOKEN is required for GitHub integration");
}
const mcpConfig = createMcpConfig(process.env.GITHUB_INSTALLATION_TOKEN);
console.log("📋 MCP Config:", mcpConfig);
args.push("--mcp-config", mcpConfig);
const env = {
ANTHROPIC_API_KEY: this.apiKey,
};
// Start a collapsible log group for streaming output
core.startGroup("🔄 Run details");
// Initialize run statistics
this.runStats = {
toolsUsed: 0,
turns: 0,
@@ -117,7 +109,6 @@ export class ClaudeAgent implements Agent {
const finalResult = "";
const totalCost = 0;
// run Claude Code with the prompt
const result = await spawn({
cmd: claudePath,
args,
@@ -125,40 +116,24 @@ export class ClaudeAgent implements Agent {
input: prompt,
timeout: 10 * 60 * 1000, // 10 minutes
onStdout: (_chunk) => {
// console.log(chunk);
processJSONChunk(_chunk, this);
},
onStderr: (_chunk) => {
if (_chunk.trim()) {
// core.warning(`[warn] ${chunk}`);
processJSONChunk(_chunk, this);
}
},
});
// 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}`
);
}
// Process the complete buffered stdout to extract final results
// if (result.stdout.trim()) {
// const lines = result.stdout.trim().split("\n");
// for (const line of lines) {
// if (line.trim()) {
// const chunkResult = processJsonChunk(line);
// if (chunkResult.finalResult) finalResult = chunkResult.finalResult;
// if (chunkResult.totalCost) totalCost = chunkResult.totalCost;
// }
// }
// }
// 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.");
@@ -175,14 +150,11 @@ export class ClaudeAgent implements Agent {
},
};
} catch (error: any) {
// Ensure group is closed even if error occurs before group is started
try {
core.endGroup();
} 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}`,
@@ -191,34 +163,11 @@ export class ClaudeAgent implements Agent {
}
}
/**
* Process a JSON chunk line and extract result data
*/
// function processJsonChunk(line: string): { finalResult?: string; totalCost?: number } {
// try {
// const chunk = JSON.parse(line.trim());
// processJSONChunk(chunk);
// // Collect final result and cost data
// if (chunk.type === "result" && chunk.result) {
// return {
// finalResult: chunk.result,
// totalCost: chunk.total_cost_usd || 0,
// };
// }
// return {};
// } catch {
// core.debug(`Failed to parse JSON line: ${line}`);
// return {};
// }
// }
/**
* Pretty print a JSON chunk based on its type
*/
function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
try {
// Parse the JSON string first
console.log(chunk);
const parsedChunk = JSON.parse(chunk.trim());
@@ -226,19 +175,12 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
case "system":
if (parsedChunk.subtype === "init") {
core.info(`🚀 Starting Claude Code session...`);
// core.info(`📁 Working directory: ${parsedChunk.cwd}`);
// core.info(`🔑 Permission mode: ${parsedChunk.permissionMode}`);
core.info(
tableString([
["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,48 +193,38 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
? `${parsedChunk.slash_commands.length} commands`
: "none",
],
]),
])
);
}
break;
case "assistant":
if (parsedChunk.message?.content) {
// Track turns
if (agent) {
agent.runStats.turns++;
}
for (const content of parsedChunk.message.content) {
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
if (agent) {
agent.runStats.toolsUsed++;
}
// Enhanced tool usage logging
const toolName = content.name;
// const toolId = content.id;
core.info(`${toolName}`);
// Log tool-specific details based on tool type
if (content.input) {
const input = content.input;
// Common tool input fields
if (input.description) {
core.info(` └─ ${input.description}`);
}
// Tool-specific input fields
if (input.command) {
core.info(` └─ command: ${input.command}`);
}
@@ -321,7 +253,6 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
core.info(` └─ url: ${input.url}`);
}
// For multi-edit or complex operations
if (input.edits && Array.isArray(input.edits)) {
core.info(` └─ edits: ${input.edits.length} changes`);
input.edits.forEach((edit: any, index: number) => {
@@ -331,19 +262,15 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
});
}
// For task operations
if (input.task) {
core.info(` └─ task: ${input.task}`);
}
// For bash operations with specific details
if (input.bash_command) {
core.info(` └─ bash_command: ${input.bash_command}`);
}
}
// Log tool ID for debugging
// core.debug(` 🔗 Tool ID: ${toolId}`);
}
}
}
@@ -356,9 +283,6 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
if (content.is_error) {
core.warning(`❌ Tool error: ${content.content}`);
} else {
// Enhanced tool result logging
const _resultContent = content.content.trim();
// do nothing for now. usually useless in headless more.
}
}
}
@@ -367,27 +291,15 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
case "result":
if (parsedChunk.subtype === "success") {
// Claude already prints something almost identical to this, so skip for now
// if (parsedChunk.result) {
// core.info(
// boxString(parsedChunk.result.trim(), {
// title: "🤖 Claude Code",
// maxWidth: 70,
// }),
// );
// }
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"}`);
@@ -395,7 +307,6 @@ function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
break;
default:
// Log unknown chunk types for debugging
core.debug(`📦 Unknown chunk type: ${parsedChunk.type}`);
break;
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./claude";
export * from "./types";
+233 -99
View File
@@ -19661,10 +19661,10 @@ var require_core = __commonJS({
(0, command_1.issueCommand)("set-env", { name }, convertedVal);
}
exports2.exportVariable = exportVariable;
function setSecret(secret) {
function setSecret2(secret) {
(0, command_1.issueCommand)("add-mask", {}, secret);
}
exports2.setSecret = setSecret;
exports2.setSecret = setSecret2;
function addPath(inputPath) {
const filePath = process.env["GITHUB_PATH"] || "";
if (filePath) {
@@ -25497,51 +25497,30 @@ var require_src = __commonJS({
var core4 = __toESM(require_core(), 1);
// main.ts
var core3 = __toESM(require_core(), 1);
var core2 = __toESM(require_core(), 1);
// agents/claude.ts
var import_promises = require("node:fs/promises");
var core2 = __toESM(require_core(), 1);
// utils/github.ts
var core = __toESM(require_core(), 1);
async function setupGitHubInstallationToken() {
const inputToken = core.getInput("github_installation_token");
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
const existingToken = inputToken || envToken;
if (existingToken) {
core.info("Using provided GitHub installation token");
return existingToken;
}
core.info("No cached installation token found, generating OIDC token...");
try {
const oidcToken = await core.getIDToken("pullfrog-api");
core.info("OIDC token generated successfully");
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json"
// mcp/config.ts
var actionPath = process.env.GITHUB_ACTION_PATH || process.cwd();
function createMcpConfig(githubInstallationToken) {
return JSON.stringify(
{
mcpServers: {
minimal_github_comment: {
command: "node",
args: [`${actionPath}/mcp/server.ts`],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken
}
}
}
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
);
}
const tokenData = await tokenResponse.json();
core.info(
`Installation token obtained for ${tokenData.owner}/${tokenData.repository || "all repositories"}`
);
process.env.GITHUB_INSTALLATION_TOKEN = tokenData.token;
return tokenData.token;
} catch (error2) {
throw new Error(
`Failed to setup GitHub installation token: ${error2 instanceof Error ? error2.message : "Unknown error"}`
);
}
},
null,
2
);
}
// utils/subprocess.ts
@@ -25599,7 +25578,7 @@ async function spawn(options) {
durationMs
});
});
child.on("error", (error2) => {
child.on("error", (_error) => {
const durationMs = Date.now() - startTime;
if (timeoutId) {
clearTimeout(timeoutId);
@@ -25628,8 +25607,8 @@ function tableString(rows, options) {
return lineIndex === 0 || options?.title && lineIndex === 1 || lineIndex === rowCount;
}
} = options || {};
if (options?.title) {
rows.unshift([options.title]);
if (title) {
rows.unshift([title]);
}
const tableOutput = (0, import_table.table)(rows, {
drawHorizontalLine,
@@ -25717,7 +25696,6 @@ var ClaudeAgent = class {
turns: 0,
startTime: 0
};
// $: ExecaMethod;
constructor(config) {
if (!config.apiKey) {
throw new Error("Claude agent requires an API key");
@@ -25741,30 +25719,25 @@ var ClaudeAgent = class {
*/
async install() {
if (await this.isClaudeInstalled()) {
core2.info("Claude Code is already installed, skipping installation");
core.info("Claude Code is already installed, skipping installation");
return;
}
core2.info("Installing Claude Code...");
core.info("Installing Claude Code...");
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
onStdout: (chunk) => {
onStdout: () => {
},
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");
core.info("Claude Code installed successfully");
} catch (error2) {
throw new Error(`Failed to install Claude Code: ${error2}`);
}
@@ -25773,7 +25746,7 @@ var ClaudeAgent = class {
* Execute Claude Code with the given prompt
*/
async execute(prompt) {
core2.info("Running Claude Code...");
core.info("Running Claude Code...");
try {
const claudePath = `${process.env.HOME}/.local/bin/claude`;
console.log(boxString(prompt, { title: "Prompt" }));
@@ -25783,12 +25756,18 @@ var ClaudeAgent = class {
"stream-json",
"--verbose",
"--permission-mode",
"acceptEdits"
"bypassPermissions"
];
if (!process.env.GITHUB_INSTALLATION_TOKEN) {
throw new Error("GITHUB_INSTALLATION_TOKEN is required for GitHub integration");
}
const mcpConfig = createMcpConfig(process.env.GITHUB_INSTALLATION_TOKEN);
console.log("\u{1F4CB} MCP Config:", mcpConfig);
args.push("--mcp-config", mcpConfig);
const env = {
ANTHROPIC_API_KEY: this.apiKey
};
core2.startGroup("\u{1F504} Run details");
core.startGroup("\u{1F504} Run details");
this.runStats = {
toolsUsed: 0,
turns: 0,
@@ -25822,11 +25801,11 @@ Stderr: ${result.stderr}`
);
}
const duration = Date.now() - this.runStats.startTime;
core2.info(
core.info(
`\u{1F4CA} Run Summary: ${this.runStats.toolsUsed} tools used, ${this.runStats.turns} turns, ${duration}ms duration`
);
core2.info("\u2705 Task complete.");
core2.endGroup();
core.info("\u2705 Task complete.");
core.endGroup();
return {
success: true,
output: finalResult,
@@ -25839,7 +25818,7 @@ Stderr: ${result.stderr}`
};
} catch (error2) {
try {
core2.endGroup();
core.endGroup();
} catch {
}
const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
@@ -25857,16 +25836,13 @@ function processJSONChunk(chunk, agent) {
switch (parsedChunk.type) {
case "system":
if (parsedChunk.subtype === "init") {
core2.info(`\u{1F680} Starting Claude Code session...`);
core2.info(
core.info(`\u{1F680} Starting Claude Code session...`);
core.info(
tableString([
["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"
@@ -25887,53 +25863,51 @@ 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" })
);
core.info(boxString(content.text.trim(), { title: "Claude Code" }));
}
} else if (content.type === "tool_use") {
if (agent) {
agent.runStats.toolsUsed++;
}
const toolName = content.name;
core2.info(`\u2192 ${toolName}`);
core.info(`\u2192 ${toolName}`);
if (content.input) {
const input = content.input;
if (input.description) {
core2.info(` \u2514\u2500 ${input.description}`);
core.info(` \u2514\u2500 ${input.description}`);
}
if (input.command) {
core2.info(` \u2514\u2500 command: ${input.command}`);
core.info(` \u2514\u2500 command: ${input.command}`);
}
if (input.file_path) {
core2.info(` \u2514\u2500 file: ${input.file_path}`);
core.info(` \u2514\u2500 file: ${input.file_path}`);
}
if (input.content) {
const contentPreview = input.content.length > 100 ? `${input.content.substring(0, 100)}...` : input.content;
core2.info(` \u2514\u2500 content: ${contentPreview}`);
core.info(` \u2514\u2500 content: ${contentPreview}`);
}
if (input.query) {
core2.info(` \u2514\u2500 query: ${input.query}`);
core.info(` \u2514\u2500 query: ${input.query}`);
}
if (input.pattern) {
core2.info(` \u2514\u2500 pattern: ${input.pattern}`);
core.info(` \u2514\u2500 pattern: ${input.pattern}`);
}
if (input.url) {
core2.info(` \u2514\u2500 url: ${input.url}`);
core.info(` \u2514\u2500 url: ${input.url}`);
}
if (input.edits && Array.isArray(input.edits)) {
core2.info(` \u2514\u2500 edits: ${input.edits.length} changes`);
core.info(` \u2514\u2500 edits: ${input.edits.length} changes`);
input.edits.forEach((edit, index) => {
if (edit.file_path) {
core2.info(` ${index + 1}. ${edit.file_path}`);
core.info(` ${index + 1}. ${edit.file_path}`);
}
});
}
if (input.task) {
core2.info(` \u2514\u2500 task: ${input.task}`);
core.info(` \u2514\u2500 task: ${input.task}`);
}
if (input.bash_command) {
core2.info(` \u2514\u2500 bash_command: ${input.bash_command}`);
core.info(` \u2514\u2500 bash_command: ${input.bash_command}`);
}
}
}
@@ -25945,9 +25919,8 @@ function processJSONChunk(chunk, agent) {
for (const content of parsedChunk.message.content) {
if (content.type === "tool_result") {
if (content.is_error) {
core2.warning(`\u274C Tool error: ${content.content}`);
core.warning(`\u274C Tool error: ${content.content}`);
} else {
const _resultContent = content.content.trim();
}
}
}
@@ -25955,12 +25928,9 @@ function processJSONChunk(chunk, agent) {
break;
case "result":
if (parsedChunk.subtype === "success") {
core2.info(
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`],
@@ -25968,16 +25938,16 @@ function processJSONChunk(chunk, agent) {
])
);
} else {
core2.error(`\u274C Failed: ${parsedChunk.error || "Unknown error"}`);
core.error(`\u274C Failed: ${parsedChunk.error || "Unknown error"}`);
}
break;
default:
core2.debug(`\u{1F4E6} Unknown chunk type: ${parsedChunk.type}`);
core.debug(`\u{1F4E6} Unknown chunk type: ${parsedChunk.type}`);
break;
}
} catch (error2) {
core2.debug(`Failed to parse chunk: ${error2}`);
core2.debug(`Raw chunk: ${chunk.substring(0, 200)}...`);
core.debug(`Failed to parse chunk: ${error2}`);
core.debug(`Raw chunk: ${chunk.substring(0, 200)}...`);
}
}
@@ -25989,7 +25959,7 @@ async function main(params) {
process.chdir(cwd);
}
Object.assign(process.env, env);
core3.info(`\u2192 Starting agent run with Claude Code`);
core2.info(`\u2192 Starting agent run with Claude Code`);
const agent = new ClaudeAgent({ apiKey: inputs.anthropic_api_key });
await agent.install();
const result = await agent.execute(inputs.prompt);
@@ -26013,17 +25983,181 @@ async function main(params) {
}
}
// utils/github.ts
var import_node_crypto = require("node:crypto");
var core3 = __toESM(require_core(), 1);
// utils/repo-context.ts
function resolveRepoContext() {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
// utils/github.ts
function checkExistingToken() {
const inputToken = core3.getInput("github_installation_token");
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
return inputToken || envToken || null;
}
function isGitHubActionsEnvironment() {
return Boolean(process.env.GITHUB_ACTIONS);
}
async function acquireTokenViaOIDC() {
core3.info("Generating OIDC token...");
const oidcToken = await core3.getIDToken("pullfrog-api");
core3.info("OIDC token generated successfully");
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
core3.info("Exchanging OIDC token for installation token...");
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json"
}
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
);
}
const tokenData = await tokenResponse.json();
core3.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
return tokenData.token;
}
var base64UrlEncode = (str) => {
return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
};
var generateJWT = (appId, privateKey) => {
const now = Math.floor(Date.now() / 1e3);
const payload = {
iat: now - 60,
exp: now + 5 * 60,
iss: appId
};
const header = {
alg: "RS256",
typ: "JWT"
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signaturePart = `${encodedHeader}.${encodedPayload}`;
const signature = (0, import_node_crypto.createSign)("RSA-SHA256").update(signaturePart).sign(privateKey, "base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
return `${signaturePart}.${signature}`;
};
var githubRequest = async (path, options = {}) => {
const { method = "GET", headers = {}, body } = options;
const url = `https://api.github.com${path}`;
const requestHeaders = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Pullfrog-Installation-Token-Generator/1.0",
...headers
};
const response = await fetch(url, {
method,
headers: requestHeaders,
...body && { body }
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`GitHub API request failed: ${response.status} ${response.statusText}
${errorText}`
);
}
return response.json();
};
var checkRepositoryAccess = async (token, repoOwner, repoName) => {
try {
const response = await githubRequest("/installation/repositories", {
headers: { Authorization: `token ${token}` }
});
return response.repositories.some(
(repo) => repo.owner.login === repoOwner && repo.name === repoName
);
} catch {
return false;
}
};
var createInstallationToken = async (jwt, installationId) => {
const response = await githubRequest(
`/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: { Authorization: `Bearer ${jwt}` }
}
);
return response.token;
};
var findInstallationId = async (jwt, repoOwner, repoName) => {
const installations = await githubRequest("/app/installations", {
headers: { Authorization: `Bearer ${jwt}` }
});
for (const installation of installations) {
try {
const tempToken = await createInstallationToken(jwt, installation.id);
const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName);
if (hasAccess) {
return installation.id;
}
} catch {
}
}
throw new Error(
`No installation found with access to ${repoOwner}/${repoName}. Ensure the GitHub App is installed on the target repository.`
);
};
async function acquireTokenViaGitHubApp() {
const repoContext = resolveRepoContext();
const config = {
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n"),
repoOwner: repoContext.owner,
repoName: repoContext.name
};
const jwt = generateJWT(config.appId, config.privateKey);
const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
const token = await createInstallationToken(jwt, installationId);
return token;
}
async function acquireNewToken() {
if (isGitHubActionsEnvironment()) {
return await acquireTokenViaOIDC();
} else {
return await acquireTokenViaGitHubApp();
}
}
async function setupGitHubInstallationToken() {
const existingToken = checkExistingToken();
if (existingToken) {
core3.setSecret(existingToken);
core3.info("Using provided GitHub installation token");
return existingToken;
}
const token = await acquireNewToken();
core3.setSecret(token);
process.env.GITHUB_INSTALLATION_TOKEN = token;
return token;
}
// entry.ts
async function run() {
try {
const prompt = core4.getInput("prompt", { required: true });
const anthropicApiKey = core4.getInput("anthropic_api_key");
const anthropic_api_key = core4.getInput("anthropic_api_key");
if (!prompt) {
throw new Error("prompt is required");
}
const inputs = {
prompt,
anthropic_api_key: anthropicApiKey
anthropic_api_key
};
const githubToken = core4.getInput("github_token") || process.env.GITHUB_TOKEN;
if (githubToken) {
+7 -14
View File
@@ -6,26 +6,23 @@
*/
import * as core from "@actions/core";
import { main } from "./main";
import { setupGitHubInstallationToken } from "./utils";
import { type ExecutionInputs, type MainParams, main } from "./main.ts";
import { setupGitHubInstallationToken } from "./utils/github.ts";
async function run(): Promise<void> {
try {
// Get inputs from GitHub Actions
const prompt = core.getInput("prompt", { required: true });
const anthropicApiKey = core.getInput("anthropic_api_key");
const anthropic_api_key = core.getInput("anthropic_api_key");
if (!prompt) {
throw new Error("prompt is required");
}
// Create params object with new structure
const inputs: any = {
const inputs: ExecutionInputs = {
prompt,
anthropic_api_key: anthropicApiKey,
anthropic_api_key,
};
// Add optional properties only if they exist
const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN;
if (githubToken) {
inputs.github_token = githubToken;
@@ -36,20 +33,17 @@ async function run(): Promise<void> {
if (githubInstallationToken) {
inputs.github_installation_token = githubInstallationToken;
} else {
// Setup GitHub installation token
await setupGitHubInstallationToken();
}
const params = {
const params: MainParams = {
inputs,
env: {} as Record<string, string>,
env: {},
cwd: process.cwd(),
};
// Run the main function
const result = await main(params);
// TODO: Set outputs
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
@@ -60,7 +54,6 @@ async function run(): Promise<void> {
}
}
// Run the action
run().catch((error) => {
console.error("Action failed:", error);
process.exit(1);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { MainParams } from "../main";
import type { MainParams } from "../main.ts";
const testParams = {
inputs: {
+3 -1
View File
@@ -1 +1,3 @@
Print the list of tools available. Then create a new file called test.txt with the content "Hello from Pullfrog!".
Use the MCP GitHub comment tool to add a comment containing your best frog joke to GitHub issue https://github.com/pullfrogai/scratch/issues/2.
Do not use the gh cli. If the mcp tool does not work, bail.
+3 -3
View File
@@ -3,11 +3,11 @@
* This exports the main function for programmatic usage
*/
export { ClaudeAgent } from "./agents";
export type { Agent, AgentConfig, AgentResult } from "./agents/types";
export { ClaudeAgent } from "./agents/claude.ts";
export type { Agent, AgentConfig, AgentResult } from "./agents/types.ts";
export {
type ExecutionInputs,
type MainParams,
type MainResult,
main,
} from "./main";
} from "./main.ts";
+3 -10
View File
@@ -1,11 +1,10 @@
import * as core from "@actions/core";
import { ClaudeAgent } from "./agents";
import { ClaudeAgent } from "./agents/claude.ts";
// 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,27 +26,21 @@ export interface MainResult {
error?: string | undefined;
}
export async function main(params: MainParams): Promise<MainResult> {
try {
// Extract inputs from params
const { inputs, env, cwd } = params;
// Set working directory if different from current
if (cwd !== process.cwd()) {
process.chdir(cwd);
}
// Set environment variables
Object.assign(process.env, env);
core.info(`→ Starting agent run with Claude Code`);
// Create and install the Claude agent
const agent = new ClaudeAgent({ apiKey: inputs.anthropic_api_key });
await agent.install();
// Execute the agent with the prompt
const result = await agent.execute(inputs.prompt);
if (!result.success) {
+22
View File
@@ -0,0 +1,22 @@
/**
* Simple MCP configuration helper for adding our minimal GitHub comment server
*/
const actionPath = process.env.GITHUB_ACTION_PATH || process.cwd();
export function createMcpConfig(githubInstallationToken: string) {
return JSON.stringify(
{
mcpServers: {
minimal_github_comment: {
command: "node",
args: [`${actionPath}/mcp/server.ts`],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
},
},
},
},
null,
2
);
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// Minimal GitHub Issue Comment MCP Server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Octokit } from "@octokit/rest";
import { type } from "arktype";
import { z } from "zod";
import { resolveRepoContext } from "../utils/repo-context.ts";
const server = new McpServer({
name: "Minimal GitHub Issue Comment Server",
version: "0.0.1",
});
// Define the schema for creating issue comments
const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
});
server.tool(
"create_issue_comment",
"Create a comment on a GitHub issue",
{
issueNumber: z.number().describe("the issue number to comment on"),
body: z.string().describe("the comment body content"),
},
async ({ issueNumber, body }) => {
try {
Comment.assert({ issueNumber, body });
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
if (!githubInstallationToken) {
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
}
// Resolve repository context from environment
const repoContext = resolveRepoContext();
const octokit = new Octokit({
auth: githubInstallationToken,
});
const result = await octokit.rest.issues.createComment({
owner: repoContext.owner,
repo: repoContext.name,
issue_number: issueNumber,
body: body,
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
},
null,
2
),
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error creating comment: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
}
);
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("exit", () => {
server.close();
});
}
runServer().catch(console.error);
+11 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.8",
"version": "0.0.15",
"type": "module",
"files": [
"index.js",
@@ -22,21 +22,28 @@
"build:npm": "zshy",
"build:dev": "node esbuild.config.js",
"prepare": "husky",
"play": "tsx play.ts"
"play": "node play.ts",
"upDeps": "pnpm up --latest",
"createLockfile": "pnpm --ignore-workspace install"
},
"dependencies": {
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.17.5",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"arktype": "^2.1.22",
"dotenv": "^17.2.2",
"execa": "^9.6.0",
"table": "^6.9.0"
},
"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",
"zshy": "^0.4.1"
"zshy": "^0.4.1",
"zod": "^3.24.4"
},
"repository": {
"type": "git",
+152 -125
View File
@@ -1,160 +1,187 @@
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";
import { setupTestRepo } from "./utils/setup";
import { main } from "./main.ts";
import { runAct } from "./utils/act.ts";
import { setupGitHubInstallationToken } from "./utils/github.ts";
import { setupTestRepo } from "./utils/setup.ts";
// 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();
export async function run(
prompt: string,
options: { act?: boolean } = {}
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
try {
if (options.act) {
console.log("🐳 Running with Docker/act...");
runAct(prompt);
return { success: true };
}
case ".json": {
// JSON - stringify and pass as prompt
const content = readFileSync(resolvedPath, "utf8");
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
const tempDir = join(process.cwd(), ".temp");
setupTestRepo({ tempDir, forceClean: true });
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));
const { EXPECTED_INPUTS } = await import("./main.ts");
EXPECTED_INPUTS.forEach((inputName) => {
const value = process.env[inputName];
if (value) {
process.env[`INPUT_${inputName.toLowerCase()}`] = value;
}
});
const inputs: any = {
prompt,
anthropic_api_key: process.env.ANTHROPIC_API_KEY || "",
};
if (process.env.GITHUB_TOKEN) {
inputs.github_token = process.env.GITHUB_TOKEN;
}
case ".ts": {
// TypeScript - dynamic import and stringify default export
const fileUrl = pathToFileURL(resolvedPath).href;
const module = await import(fileUrl);
console.log("🔑 Setting up GitHub installation token...");
const installationToken = await setupGitHubInstallationToken();
inputs.github_installation_token = installationToken;
console.log("✅ GitHub installation token setup successfully");
if (!module.default) {
throw new Error(`TypeScript file ${filePath} must have a default export`);
const envWithToken = {
...process.env,
GITHUB_INSTALLATION_TOKEN: installationToken,
} as Record<string, string>;
const result = await main({
inputs,
env: envWithToken,
cwd: process.cwd(),
});
process.chdir(originalCwd);
if (result.success) {
console.log("✅ Action completed successfully");
if (result.output) {
console.log("Output:", result.output);
}
// 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);
return { success: true, output: result.output || undefined, error: undefined };
} else {
console.error("❌ Action failed:", result.error);
return { success: false, error: result.error || undefined, output: undefined };
}
default:
throw new Error(`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`);
} catch (error) {
const errorMessage = (error as Error).message;
console.error("❌ Error:", errorMessage);
return { success: false, error: errorMessage, output: undefined };
}
}
async function runPlay(filePath: string, options: { act?: boolean }): Promise<void> {
try {
// Load the prompt from the specified file
const prompt = await loadPrompt(filePath);
if (import.meta.url === `file://${process.argv[1]}`) {
const args = arg({
"--help": Boolean,
"--act": Boolean,
"--raw": String,
"-h": "--help",
});
if (options.act) {
// Use Docker/act to run the action
console.log("🐳 Running with Docker/act...");
runAct(prompt);
if (args["--help"]) {
console.log(`
Usage: tsx play.ts [file] [options]
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"]) {
prompt = args["--raw"];
} else {
const filePath = args._[0] || "fixtures/basic.txt";
const ext = extname(filePath).toLowerCase();
let resolvedPath: string;
const fixturesPath = join(__dirname, "fixtures", filePath);
if (existsSync(fixturesPath)) {
resolvedPath = fixturesPath;
} else if (existsSync(filePath)) {
resolvedPath = resolve(filePath);
} else {
// Setup test repository and run directly
const tempDir = join(process.cwd(), ".temp");
setupTestRepo({ tempDir, forceClean: true });
throw new Error(`File not found: ${filePath}`);
}
// Change to the temp directory
process.chdir(tempDir);
switch (ext) {
case ".txt": {
prompt = readFileSync(resolvedPath, "utf8").trim();
break;
}
console.log("🚀 Running test in .temp directory...");
console.log("─".repeat(50));
console.log(`Prompt from ${filePath}:`);
console.log(prompt);
console.log("─".repeat(50));
case ".json": {
const content = readFileSync(resolvedPath, "utf8");
const parsed = JSON.parse(content);
prompt = JSON.stringify(parsed, null, 2);
break;
}
// 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;
case ".ts": {
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 (typeof module.default === "string") {
prompt = module.default;
} else if (typeof module.default === "object" && module.default.prompt) {
prompt = module.default.prompt;
} else {
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);
});
+972 -167
View File
File diff suppressed because it is too large Load Diff
+17 -40
View File
@@ -1,45 +1,22 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
"outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true
"module": "NodeNext",
"target": "ESNext",
"moduleResolution": "NodeNext",
"lib": ["ESNext"],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedSideEffectImports": true,
"declaration": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"stripInternal": true,
"moduleDetection": "force"
}
}
+5 -18
View File
@@ -3,36 +3,30 @@ import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
import { buildAction, setupTestRepo } from "./setup";
import { buildAction, setupTestRepo } from "./setup.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Environment variables that should be passed as secrets to the workflow
const tempDir = join(__dirname, "..", ".temp");
const actionPath = join(__dirname, "..");
const envPath = join(__dirname, "..", "..", ".env");
const ENV_VARS = ["ANTHROPIC_API_KEY", "GITHUB_INSTALLATION_TOKEN"];
export function runAct(prompt: string): void {
const tempDir = join(__dirname, "..", ".temp");
const actionPath = join(__dirname, "..");
const envPath = join(__dirname, "..", "..", ".env");
// Setup test repository
setupTestRepo({ tempDir });
// Load environment variables
config({ path: envPath });
// Build action bundles
buildAction(actionPath);
const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
// Create minimal dist for act (avoids pnpm symlink issues)
const distPath = join(actionPath, ".act-dist");
console.log("📦 Creating minimal distribution for act...");
execSync(`rm -rf "${distPath}" && mkdir -p "${distPath}"`, { shell: "/bin/bash" });
// Copy only necessary files (bundled, no node_modules needed)
["action.yml", "entry.cjs", "index.cjs", "package.json"].forEach((file) => {
const src = join(actionPath, file);
if (existsSync(src)) {
@@ -41,8 +35,6 @@ export function runAct(prompt: string): void {
});
try {
// Build the act command with input directly
// Properly escape the prompt for shell
const escapedPrompt = prompt.replace(/'/g, "'\\''");
const actCommandParts = [
@@ -56,14 +48,12 @@ export function runAct(prompt: string): void {
`pullfrog/action@v0=${distPath}`, // Use minimal dist without symlinks
];
// Add environment variables as secrets that will be available to the workflow
ENV_VARS.forEach((key) => {
if (process.env[key]) {
actCommandParts.push("-s", key);
}
});
// We only need the specific ENV_VARS, no need to add other variables
const actCommand = actCommandParts.join(" ");
@@ -73,15 +63,12 @@ export function runAct(prompt: string): void {
console.log("─".repeat(50));
console.log("");
// Execute act
execSync(actCommand, {
stdio: "inherit",
cwd: join(__dirname, "..", ".."),
});
// Clean up
execSync(`rm -rf "${distPath}"`);
} catch (error) {
// Clean up on error
execSync(`rm -rf "${distPath}"`);
console.error("❌ Act execution failed:", (error as Error).message);
process.exit(1);
+240 -44
View File
@@ -1,56 +1,252 @@
import { createSign } from "node:crypto";
import * as core from "@actions/core";
import { resolveRepoContext } from "./repo-context.ts";
export interface InstallationToken {
token: string;
expires_at: string;
installation_id: number;
repository: string;
ref: string;
runner_environment: string;
owner?: string;
}
interface GitHubAppConfig {
appId: string;
privateKey: string;
repoOwner: string;
repoName: string;
}
interface Installation {
id: number;
account: {
login: string;
type: string;
};
}
interface Repository {
owner: {
login: string;
};
name: string;
}
interface InstallationTokenResponse {
token: string;
expires_at: string;
}
interface RepositoriesResponse {
repositories: Repository[];
}
function checkExistingToken(): string | null {
const inputToken = core.getInput("github_installation_token");
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
return inputToken || envToken || null;
}
function isGitHubActionsEnvironment(): boolean {
return Boolean(process.env.GITHUB_ACTIONS);
}
async function acquireTokenViaOIDC(): Promise<string> {
core.info("Generating OIDC token...");
const oidcToken = await core.getIDToken("pullfrog-api");
core.info("OIDC token generated successfully");
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
core.info("Exchanging OIDC token for installation token...");
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
);
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
core.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
return tokenData.token;
}
const base64UrlEncode = (str: string): string => {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
const generateJWT = (appId: string, privateKey: string): string => {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now - 60,
exp: now + 5 * 60,
iss: appId,
};
const header = {
alg: "RS256",
typ: "JWT",
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signaturePart = `${encodedHeader}.${encodedPayload}`;
const signature = createSign("RSA-SHA256")
.update(signaturePart)
.sign(privateKey, "base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return `${signaturePart}.${signature}`;
};
const githubRequest = async <T>(
path: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
} = {}
): Promise<T> => {
const { method = "GET", headers = {}, body } = options;
const url = `https://api.github.com${path}`;
const requestHeaders = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Pullfrog-Installation-Token-Generator/1.0",
...headers,
};
const response = await fetch(url, {
method,
headers: requestHeaders,
...(body && { body }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}`
);
}
return response.json() as T;
};
const checkRepositoryAccess = async (
token: string,
repoOwner: string,
repoName: string
): Promise<boolean> => {
try {
const response = await githubRequest<RepositoriesResponse>("/installation/repositories", {
headers: { Authorization: `token ${token}` },
});
return response.repositories.some(
(repo) => repo.owner.login === repoOwner && repo.name === repoName
);
} catch {
return false;
}
};
const createInstallationToken = async (jwt: string, installationId: number): Promise<string> => {
const response = await githubRequest<InstallationTokenResponse>(
`/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: { Authorization: `Bearer ${jwt}` },
}
);
return response.token;
};
const findInstallationId = async (
jwt: string,
repoOwner: string,
repoName: string
): Promise<number> => {
const installations = await githubRequest<Installation[]>("/app/installations", {
headers: { Authorization: `Bearer ${jwt}` },
});
for (const installation of installations) {
try {
const tempToken = await createInstallationToken(jwt, installation.id);
const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName);
if (hasAccess) {
return installation.id;
}
} catch {}
}
throw new Error(
`No installation found with access to ${repoOwner}/${repoName}. ` +
"Ensure the GitHub App is installed on the target repository."
);
};
async function acquireTokenViaGitHubApp(): Promise<string> {
const repoContext = resolveRepoContext();
const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
repoOwner: repoContext.owner,
repoName: repoContext.name,
};
const jwt = generateJWT(config.appId, config.privateKey);
const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
const token = await createInstallationToken(jwt, installationId);
return token;
}
async function acquireNewToken(): Promise<string> {
if (isGitHubActionsEnvironment()) {
return await acquireTokenViaOIDC();
} else {
return await acquireTokenViaGitHubApp();
}
}
/**
* Setup GitHub installation token for the action
*/
export async function setupGitHubInstallationToken(): Promise<string> {
// Check if we have an installation token from inputs or environment
const inputToken = core.getInput("github_installation_token");
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
const existingToken = inputToken || envToken;
const existingToken = checkExistingToken();
if (existingToken) {
core.setSecret(existingToken);
core.info("Using provided GitHub installation token");
return existingToken;
}
core.info("No cached installation token found, generating OIDC token...");
try {
// Generate OIDC token for our API
const oidcToken = await core.getIDToken("pullfrog-api");
core.info("OIDC token generated successfully");
// Exchange OIDC token for installation token
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
);
}
const tokenData = await tokenResponse.json();
core.info(
`Installation token obtained for ${tokenData.owner}/${tokenData.repository || "all repositories"}`
);
// Set the token as an environment variable for this run
process.env.GITHUB_INSTALLATION_TOKEN = tokenData.token;
return tokenData.token;
} catch (error) {
throw new Error(
`Failed to setup GitHub installation token: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
const token = await acquireNewToken();
core.setSecret(token);
process.env.GITHUB_INSTALLATION_TOKEN = token;
return token;
}
-5
View File
@@ -1,5 +0,0 @@
export * from "./files";
export * from "./github";
export * from "./setup";
export * from "./subprocess";
export * from "./table";
+22
View File
@@ -0,0 +1,22 @@
export interface RepoContext {
owner: string;
name: string;
}
/**
* Resolve repository context from GITHUB_REPOSITORY environment variable.
* Throws if not available.
*/
export function resolveRepoContext(): RepoContext {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error('GITHUB_REPOSITORY environment variable is required');
}
const [owner, name] = githubRepo.split('/');
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
-2
View File
@@ -17,13 +17,11 @@ export function setupTestRepo(options: SetupOptions): void {
forceClean = false,
} = options;
// Handle existing temp directory
if (existsSync(tempDir)) {
if (forceClean) {
console.log("🗑️ Removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
// Clone the repository
console.log("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
} else {
+1 -10
View File
@@ -28,13 +28,11 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
let stderrBuffer = "";
return new Promise((resolve, reject) => {
// Spawn the child process
const child = nodeSpawn(cmd, args, {
env: env ? { ...process.env, ...env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
});
// Set up timeout if specified
let timeoutId: NodeJS.Timeout | undefined;
let isTimedOut = false;
@@ -43,7 +41,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
isTimedOut = true;
child.kill("SIGTERM");
// If SIGTERM doesn't work, use SIGKILL after 5 seconds
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
@@ -52,7 +49,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
}, timeout);
}
// Handle stdout streaming
if (child.stdout) {
child.stdout.on("data", (data: Buffer) => {
const chunk = data.toString();
@@ -61,7 +57,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
});
}
// Handle stderr streaming
if (child.stderr) {
child.stderr.on("data", (data: Buffer) => {
const chunk = data.toString();
@@ -70,7 +65,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
});
}
// Handle process completion
child.on("close", (exitCode) => {
const durationMs = Date.now() - startTime;
@@ -91,15 +85,13 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
});
});
// Handle process errors
child.on("error", (error) => {
child.on("error", (_error) => {
const durationMs = Date.now() - startTime;
if (timeoutId) {
clearTimeout(timeoutId);
}
// Still return buffered output even on error
resolve({
stdout: stdoutBuffer,
stderr: stderrBuffer,
@@ -108,7 +100,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
});
});
// Send input if provided
if (input && child.stdin) {
child.stdin.write(input);
child.stdin.end();
+2 -32
View File
@@ -22,8 +22,8 @@ export function tableString(
},
} = options || {};
if (options?.title) {
rows.unshift([options.title]);
if (title) {
rows.unshift([title]);
}
const tableOutput = table(rows, {
@@ -140,33 +140,3 @@ export function boxString(
return result;
}
// /**
// * Create a simple two-column table for displaying key-value pairs
// * @param data - Array of [key, value] pairs
// * @param title - Optional table title
// * @param indent - Optional indentation string
// */
// export function printKeyValueTable(
// data: [string, string][],
// title?: string,
// indent?: string
// ): void {
// const rows: string[][] = [["Key", "Value"], ...data];
// const options: Parameters<typeof printTable>[1] = {};
// if (title !== undefined) options.title = title;
// if (indent !== undefined) options.indent = indent;
// printTable(rows, options);
// }
// /**
// * Create a path resolution table (specific use case)
// * @param pathData - Array of [location, resolvedPath] pairs
// * @param indent - Optional indentation string
// */
// export function printPathTable(pathData: [string, string][], indent?: string): void {
// const rows: string[][] = [["Location", "Resolved path"], ...pathData];
// const options: Parameters<typeof printTable>[1] = {};
// if (indent !== undefined) options.indent = indent;
// printTable(rows, options);
// }