Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d88bfce42 | |||
| c8cbda6972 | |||
| 4ff547f673 | |||
| 106de07802 | |||
| aba21e7583 | |||
| ff375b97e4 | |||
| 632fffbfa7 | |||
| 339c0ee276 | |||
| 782902d899 | |||
| 6ba92cb9d8 | |||
| b6bfcb0cca | |||
| b0a404c461 | |||
| e24db1155f | |||
| f6af7b4215 | |||
| 07fb79056f | |||
| a7551316be | |||
| fda0de8dfe | |||
| 11e7ae6d18 | |||
| bef3f7794c | |||
| 124021eaee | |||
| 192f8a19a0 | |||
| cb1c5d9734 | |||
| 264dcc072c | |||
| 589592372f | |||
| 99e572194d | |||
| 8944e7fe08 | |||
| 595b246235 | |||
| 550a162ca6 | |||
| 8298cdd07c | |||
| 935fe26013 | |||
| dd2089d71b | |||
| b460bd3109 | |||
| 0ce1d9fd7b | |||
| 6c6b7b0b2d | |||
| f8bb2e12f3 | |||
| e5878de9e4 | |||
| c9aab98389 | |||
| 43acacd25a | |||
| 975eaa9a64 | |||
| ba724c8b71 | |||
| 6ef5124e32 | |||
| cb938a0b7f | |||
| eeed6cfbd0 | |||
| b30cc166e3 | |||
| cbcf87f50d | |||
| ccf9f46346 | |||
| f596d6d995 | |||
| 8f2d98fe4c | |||
| ed39bda62a | |||
| 917b8804c0 | |||
| 96055edda7 | |||
| 295949c173 | |||
| 9c51c450bc | |||
| 85f8fbfaf5 | |||
| 098df15764 | |||
| fe35e9e274 | |||
| d7d2035315 | |||
| c703ecc4f4 | |||
| b05d1bfc53 | |||
| f765a0878d | |||
| e3a7b09df4 | |||
| 7e0dcd5374 | |||
| 579c79e38c | |||
| 4b43b617f0 | |||
| 1e8abe442b | |||
| fed62adb69 | |||
| 5889d20930 | |||
| dcc257ff7a | |||
| 2ba6cf7c0b | |||
| aa5eb4c43c | |||
| c647c923f3 | |||
| c5700b195d | |||
| 849d133f20 | |||
| e477ad81b2 | |||
| 06a19567c0 | |||
| 3ef1635bb6 | |||
| e455ec0682 | |||
| 7bbca2fdeb | |||
| 0ac4975b50 | |||
| bf6212cae3 | |||
| 3982b147f9 | |||
| c72d44382f | |||
| fc1b035f5d | |||
| 7ec4fd52b1 | |||
| dbf906a7f0 | |||
| 68c38ed042 | |||
| c63581a90c | |||
| e218afc35c | |||
| ccf740bfdf | |||
| f45b6dca62 | |||
| c766daefa4 | |||
| 50c0095e87 | |||
| 49cb159124 | |||
| ddb481f14e | |||
| 1b55da51a1 | |||
| b2a9b60271 | |||
| 7c724d931b | |||
| 57e72ddf2b | |||
| 41a4f44e2d | |||
| d1f16e9dd2 | |||
| 6f2ccedbf8 | |||
| d4a4dd59bb | |||
| 1044806f8e | |||
| d7fec83b6b | |||
| 9dff727df1 | |||
| 47716aa119 | |||
| cb01f0ae44 | |||
| 75cb3ecf08 | |||
| 4530267429 | |||
| c1014857e0 | |||
| 68b65b2b05 | |||
| e90940e901 | |||
| 05cdc7f6eb | |||
| 93b5df70b1 | |||
| 25d7008be5 | |||
| 692719029c | |||
| d7878095a6 | |||
| afc1aa4c1b | |||
| 7685d9ba49 | |||
| 3e547693ae | |||
| f4f2e24ec0 | |||
| 7aa7803186 | |||
| 203e9ef8cd | |||
| 515bd3a9d7 | |||
| a535f5d9ce | |||
| 5f9a839ef0 | |||
| 586477f456 | |||
| b65a6df9f7 | |||
| 0a01a25382 | |||
| 9588ffd4b6 | |||
| aff634af29 | |||
| 7aaebe9584 | |||
| b0c32c8f2a | |||
| 71698d3e07 | |||
| 0e53a97619 | |||
| cc56089a41 | |||
| 401496f19f | |||
| 8822968cbb | |||
| c18db965c3 | |||
| 1b4628e26b | |||
| 7aedd6bc33 | |||
| a3f1593e28 | |||
| aaba4b7650 | |||
| 0bf456b6dc | |||
| e8ca1d87ef | |||
| e9458ea4bf | |||
| 37428e8710 | |||
| 0b80b0d581 | |||
| 40dc13b55f | |||
| 894c525f21 | |||
| bebc8c626f | |||
| aa617f2037 | |||
| 1c128b293f | |||
| c08008668b | |||
| 7ac2938570 | |||
| 363e4ecda2 | |||
| 13cc56944f | |||
| 2d91473f6e | |||
| 3937c3bdba | |||
| bac3f3e9c6 | |||
| 5ea1d95b70 | |||
| 6d0c21f0f5 | |||
| c31824144b | |||
| 0a63f3da9d | |||
| 42b023cc86 | |||
| 854e3d5e4d | |||
| 5bb1b779a8 | |||
| 599264694e | |||
| b9c15e9f38 | |||
| 7ef44eb254 | |||
| 5a21d40d27 | |||
| 175f92542e | |||
| b448787f24 | |||
| 65e3da81e9 | |||
| f31e3a026e | |||
| 220652f27b | |||
| 349af82bfc | |||
| 15732d126d | |||
| 36b006108b | |||
| 029ae0d280 | |||
| 92b435eb80 | |||
| cacf9674c4 | |||
| f73260e3e6 | |||
| 3ddd6db7ca | |||
| 68499340e4 | |||
| acb06634be | |||
| 681e08557c | |||
| 15a7154aea | |||
| 434458a068 | |||
| 193954fdd7 | |||
| ab2d762658 | |||
| 876663cd1a | |||
| b2badf6d16 | |||
| 05fb2065b2 | |||
| 2042a5bf98 | |||
| 12da2b770c | |||
| a26ada9839 | |||
| 1328894afd | |||
| 85731f8360 | |||
| 1922352d86 | |||
| c0f31415a3 | |||
| 706ce04895 | |||
| 09be8e3068 | |||
| c6c1210fa0 | |||
| 0368512b9e | |||
| 9fb6135fd2 | |||
| bb78e5f94b | |||
| c668578c6f | |||
| 7f1566d9c2 | |||
| dd482566c2 | |||
| 57029c32a3 | |||
| 757d336475 | |||
| d03debab4b | |||
| a05829f781 | |||
| c8ba7940e3 |
Executable
+12
@@ -0,0 +1,12 @@
|
||||
# Ensure lockfile is up to date if package.json changed
|
||||
if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
echo "🔒 Updating lockfile..."
|
||||
pnpm lock
|
||||
|
||||
# Build the action before committing
|
||||
echo "🔨 Building action..."
|
||||
pnpm build
|
||||
|
||||
# Add the built files and lockfile to the commit
|
||||
git add entry pnpm-lock.yaml
|
||||
fi
|
||||
@@ -9,72 +9,13 @@ GitHub Action for running Claude Code and other agents via Pullfrog.
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Test with default prompt
|
||||
npm run play # Run locally on your machine
|
||||
npm run play -- --act # Run in Docker (simulates GitHub Actions)
|
||||
```
|
||||
|
||||
## Testing with play.ts
|
||||
## Testing with `play.ts`
|
||||
|
||||
The `play.ts` script provides two ways to test the action:
|
||||
|
||||
### Local Mode (Default)
|
||||
```bash
|
||||
npm run play # Uses fixtures/play.txt
|
||||
npm run play fixtures/complex.txt # Custom prompt file
|
||||
pnpm play # Uses fixtures/play.txt
|
||||
```
|
||||
- Clones the scratch repository to `.temp`
|
||||
- Runs Claude Code directly on your machine
|
||||
- Fast iteration for development
|
||||
|
||||
### Docker Mode (--act flag)
|
||||
```bash
|
||||
npm run play -- --act # Uses fixtures/play.txt
|
||||
npm run play fixtures/simple.txt -- --act # Custom prompt file
|
||||
```
|
||||
- Builds fresh bundles with esbuild
|
||||
- Creates minimal distribution without node_modules
|
||||
- Runs in Docker container via `act`
|
||||
- Simulates GitHub Actions environment
|
||||
|
||||
### Prompt Files
|
||||
|
||||
Supports `.txt`, `.json`, and `.ts` files:
|
||||
```bash
|
||||
npm run play prompt.txt # Plain text prompt
|
||||
npm run play config.json # JSON configuration
|
||||
npm run play dynamic.ts # TypeScript with default export
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
pnpm build # Production build (bundles & removes node_modules)
|
||||
pnpm build:dev # Development build (keeps node_modules)
|
||||
pnpm dev # Watch mode
|
||||
```
|
||||
|
||||
The action is bundled into `entry.cjs` with all dependencies included, eliminating runtime dependency on node_modules.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env` in `/action`:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Claude API key
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **entry.cjs**: Bundled action entry point (self-contained)
|
||||
- **agents/**: Agent implementations (Claude, etc.)
|
||||
- **utils/**: Utilities for subprocess, act, and formatting
|
||||
- **fixtures/**: Test prompt files
|
||||
|
||||
## Why No node_modules?
|
||||
|
||||
pnpm uses symlinks that cause "invalid symlink" errors when `act` copies the action to Docker. Our solution:
|
||||
1. Bundle everything into `entry.cjs`
|
||||
2. Remove node_modules after building
|
||||
3. Create minimal `.act-dist` for Docker testing
|
||||
+13
-27
@@ -10,36 +10,22 @@ inputs:
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key for Claude Code authentication"
|
||||
required: false
|
||||
github_installation_token:
|
||||
description: "GitHub App installation token"
|
||||
openai_api_key:
|
||||
description: "OpenAI API key for Codex authentication"
|
||||
required: false
|
||||
google_api_key:
|
||||
description: "Google API key for Jules authentication"
|
||||
required: false
|
||||
gemini_api_key:
|
||||
description: "Gemini API key for Jules authentication"
|
||||
required: false
|
||||
cursor_api_key:
|
||||
description: "Cursor API key for Cursor authentication"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
- name: Run agent
|
||||
run: node entry.ts
|
||||
shell: bash
|
||||
working-directory: ${{ github.action_path }}
|
||||
env:
|
||||
INPUTS_JSON: ${{ toJSON(inputs) }}
|
||||
using: "node20"
|
||||
main: "entry"
|
||||
|
||||
branding:
|
||||
icon: "code"
|
||||
|
||||
+132
-323
@@ -1,331 +1,140 @@
|
||||
import { access, constants } from "node:fs/promises";
|
||||
import * as core from "@actions/core";
|
||||
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";
|
||||
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, installFromNpmTarball } from "./shared.ts";
|
||||
|
||||
/**
|
||||
* Claude Code agent implementation
|
||||
*/
|
||||
export class ClaudeAgent implements Agent {
|
||||
private apiKey: string;
|
||||
public runStats = {
|
||||
toolsUsed: 0,
|
||||
turns: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
export const claude = agent({
|
||||
name: "claude",
|
||||
install: async () => {
|
||||
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
||||
return await installFromNpmTarball({
|
||||
packageName: "@anthropic-ai/claude-agent-sdk",
|
||||
version: versionRange,
|
||||
executablePath: "cli.js",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath }) => {
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
|
||||
constructor(config: AgentConfig) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("Claude agent requires an API key");
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
}
|
||||
const prompt = addInstructions(payload);
|
||||
console.log(prompt);
|
||||
|
||||
/**
|
||||
* Check if Claude Code CLI is already installed
|
||||
*/
|
||||
private async isClaudeInstalled(): Promise<boolean> {
|
||||
try {
|
||||
const claudePath = `${process.env.HOME}/.local/bin/claude`;
|
||||
await access(claudePath, constants.F_OK | constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const queryInstance = query({
|
||||
prompt,
|
||||
options: {
|
||||
permissionMode: "bypassPermissions",
|
||||
mcpServers,
|
||||
pathToClaudeCodeExecutable: cliPath,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Install Claude Code CLI
|
||||
*/
|
||||
async install(): Promise<void> {
|
||||
if (await this.isClaudeInstalled()) {
|
||||
core.info("Claude Code is already installed, skipping installation");
|
||||
return;
|
||||
// Stream the results
|
||||
for await (const message of queryInstance) {
|
||||
const handler = messageHandlers[message.type];
|
||||
await handler(message as never);
|
||||
}
|
||||
|
||||
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",
|
||||
return {
|
||||
success: true,
|
||||
output: "",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
type SDKMessageType = SDKMessage["type"];
|
||||
|
||||
type SDKMessageHandler<type extends SDKMessageType = SDKMessageType> = (
|
||||
data: Extract<SDKMessage, { type: type }>
|
||||
) => void | Promise<void>;
|
||||
|
||||
type SDKMessageHandlers = {
|
||||
[type in SDKMessageType]: SDKMessageHandler<type>;
|
||||
};
|
||||
|
||||
// Track bash tool IDs to identify when bash tool results come back
|
||||
const bashToolIds = new Set<string>();
|
||||
|
||||
const messageHandlers: SDKMessageHandlers = {
|
||||
assistant: (data) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (content.type === "text" && content.text?.trim()) {
|
||||
log.box(content.text.trim(), { title: "Claude" });
|
||||
} else if (content.type === "tool_use") {
|
||||
// Track bash tool IDs
|
||||
if (content.name === "bash" && content.id) {
|
||||
bashToolIds.add(content.id);
|
||||
}
|
||||
|
||||
log.toolCall({
|
||||
toolName: content.name,
|
||||
input: content.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
user: (data) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (content.type === "tool_result") {
|
||||
const toolUseId = (content as any).tool_use_id;
|
||||
const isBashTool = toolUseId && bashToolIds.has(toolUseId);
|
||||
|
||||
if (isBashTool) {
|
||||
// Log bash output in a collapsed group
|
||||
const outputContent =
|
||||
typeof content.content === "string"
|
||||
? content.content
|
||||
: Array.isArray(content.content)
|
||||
? content.content
|
||||
.map((c: any) => (typeof c === "string" ? c : c.text || JSON.stringify(c)))
|
||||
.join("\n")
|
||||
: String(content.content);
|
||||
|
||||
log.startGroup(`bash output`);
|
||||
if (content.is_error) {
|
||||
log.warning(outputContent);
|
||||
} else {
|
||||
log.info(outputContent);
|
||||
}
|
||||
log.endGroup();
|
||||
// Clean up the tracked ID
|
||||
bashToolIds.delete(toolUseId);
|
||||
} else if (content.is_error) {
|
||||
const errorContent =
|
||||
typeof content.content === "string" ? content.content : String(content.content);
|
||||
log.warning(`Tool error: ${errorContent}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
result: async (data) => {
|
||||
if (data.subtype === "success") {
|
||||
await log.summaryTable([
|
||||
[
|
||||
{ data: "Cost", header: true },
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
],
|
||||
env: { ANTHROPIC_API_KEY: this.apiKey },
|
||||
timeout: 120000, // 2 minute timeout
|
||||
onStdout: () => {},
|
||||
onStderr: (chunk) => process.stderr.write(chunk),
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Installation failed with exit code ${result.exitCode}: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
core.info("Claude Code installed successfully");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to install Claude Code: ${error}`);
|
||||
[
|
||||
`$${data.total_cost_usd?.toFixed(4) || "0.0000"}`,
|
||||
String(data.usage?.input_tokens || 0),
|
||||
String(data.usage?.output_tokens || 0),
|
||||
],
|
||||
]);
|
||||
} else if (data.subtype === "error_max_turns") {
|
||||
log.error(`Max turns reached: ${JSON.stringify(data)}`);
|
||||
} else if (data.subtype === "error_during_execution") {
|
||||
log.error(`Execution error: ${JSON.stringify(data)}`);
|
||||
} else {
|
||||
log.error(`Failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude Code with the given prompt
|
||||
*/
|
||||
async execute(prompt: string): Promise<AgentResult> {
|
||||
core.info("Running Claude Code...");
|
||||
|
||||
try {
|
||||
const claudePath = `${process.env.HOME}/.local/bin/claude`;
|
||||
console.log(boxString(prompt, { title: "Prompt" }));
|
||||
const args = [
|
||||
"--print",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--debug",
|
||||
"--permission-mode",
|
||||
"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,
|
||||
};
|
||||
|
||||
core.startGroup("🔄 Run details");
|
||||
|
||||
this.runStats = {
|
||||
toolsUsed: 0,
|
||||
turns: 0,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const finalResult = "";
|
||||
const totalCost = 0;
|
||||
|
||||
const result = await spawn({
|
||||
cmd: claudePath,
|
||||
args,
|
||||
env,
|
||||
input: prompt,
|
||||
timeout: 10 * 60 * 1000, // 10 minutes
|
||||
onStdout: (_chunk) => {
|
||||
processJSONChunk(_chunk, this);
|
||||
},
|
||||
onStderr: (_chunk) => {
|
||||
if (_chunk.trim()) {
|
||||
processJSONChunk(_chunk, this);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Command failed with exit code ${result.exitCode}\n\nStdout: ${result.stdout}\n\nStderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const duration = Date.now() - this.runStats.startTime;
|
||||
core.info(
|
||||
`📊 Run Summary: ${this.runStats.toolsUsed} tools used, ${this.runStats.turns} turns, ${duration}ms duration`
|
||||
);
|
||||
|
||||
core.info("✅ Task complete.");
|
||||
core.endGroup(); // End the collapsible log group
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: finalResult,
|
||||
metadata: {
|
||||
promptLength: prompt.length,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: result.durationMs,
|
||||
totalCost,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
try {
|
||||
core.endGroup();
|
||||
} catch {}
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to execute Claude Code: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty print a JSON chunk based on its type
|
||||
*/
|
||||
function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
|
||||
try {
|
||||
console.log(chunk);
|
||||
const parsedChunk = JSON.parse(chunk.trim());
|
||||
|
||||
switch (parsedChunk.type) {
|
||||
case "system":
|
||||
if (parsedChunk.subtype === "init") {
|
||||
core.info(`🚀 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",
|
||||
],
|
||||
[
|
||||
"mcp_servers",
|
||||
parsedChunk.mcp_servers?.length
|
||||
? `${parsedChunk.mcp_servers.length} servers`
|
||||
: "none",
|
||||
],
|
||||
[
|
||||
"slash_commands",
|
||||
parsedChunk.slash_commands?.length
|
||||
? `${parsedChunk.slash_commands.length} commands`
|
||||
: "none",
|
||||
],
|
||||
])
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "assistant":
|
||||
if (parsedChunk.message?.content) {
|
||||
if (agent) {
|
||||
agent.runStats.turns++;
|
||||
}
|
||||
|
||||
for (const content of parsedChunk.message.content) {
|
||||
if (content.type === "text") {
|
||||
if (content.text.trim()) {
|
||||
core.info(
|
||||
boxString(content.text.trim(), { title: "Claude Code" })
|
||||
);
|
||||
}
|
||||
} else if (content.type === "tool_use") {
|
||||
if (agent) {
|
||||
agent.runStats.toolsUsed++;
|
||||
}
|
||||
|
||||
const toolName = content.name;
|
||||
|
||||
core.info(`→ ${toolName}`);
|
||||
|
||||
if (content.input) {
|
||||
const input = content.input;
|
||||
|
||||
if (input.description) {
|
||||
core.info(` └─ ${input.description}`);
|
||||
}
|
||||
|
||||
if (input.command) {
|
||||
core.info(` └─ command: ${input.command}`);
|
||||
}
|
||||
|
||||
if (input.file_path) {
|
||||
core.info(` └─ file: ${input.file_path}`);
|
||||
}
|
||||
|
||||
if (input.content) {
|
||||
const contentPreview =
|
||||
input.content.length > 100
|
||||
? `${input.content.substring(0, 100)}...`
|
||||
: input.content;
|
||||
core.info(` └─ content: ${contentPreview}`);
|
||||
}
|
||||
|
||||
if (input.query) {
|
||||
core.info(` └─ query: ${input.query}`);
|
||||
}
|
||||
|
||||
if (input.pattern) {
|
||||
core.info(` └─ pattern: ${input.pattern}`);
|
||||
}
|
||||
|
||||
if (input.url) {
|
||||
core.info(` └─ url: ${input.url}`);
|
||||
}
|
||||
|
||||
if (input.edits && Array.isArray(input.edits)) {
|
||||
core.info(` └─ edits: ${input.edits.length} changes`);
|
||||
input.edits.forEach((edit: any, index: number) => {
|
||||
if (edit.file_path) {
|
||||
core.info(` ${index + 1}. ${edit.file_path}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (input.task) {
|
||||
core.info(` └─ task: ${input.task}`);
|
||||
}
|
||||
|
||||
if (input.bash_command) {
|
||||
core.info(` └─ bash_command: ${input.bash_command}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "user":
|
||||
if (parsedChunk.message?.content) {
|
||||
for (const content of parsedChunk.message.content) {
|
||||
if (content.type === "tool_result") {
|
||||
if (content.is_error) {
|
||||
core.warning(`❌ Tool error: ${content.content}`);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "result":
|
||||
if (parsedChunk.subtype === "success") {
|
||||
core.info(
|
||||
tableString([
|
||||
[
|
||||
"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"}`);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
core.debug(`📦 Unknown chunk type: ${parsedChunk.type}`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
core.debug(`Failed to parse chunk: ${error}`);
|
||||
core.debug(`Raw chunk: ${chunk.substring(0, 200)}...`);
|
||||
}
|
||||
}
|
||||
},
|
||||
system: () => {},
|
||||
stream_event: () => {},
|
||||
tool_progress: () => {},
|
||||
auth_status: () => {},
|
||||
};
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, type ConfigureMcpServersParams, installFromNpmTarball } from "./shared.ts";
|
||||
|
||||
export const codex = agent({
|
||||
name: "codex",
|
||||
install: async () => {
|
||||
return await installFromNpmTarball({
|
||||
packageName: "@openai/codex",
|
||||
version: "latest",
|
||||
executablePath: "bin/codex.js",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => {
|
||||
process.env.OPENAI_API_KEY = apiKey;
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken;
|
||||
|
||||
// create config directory for codex before setting HOME
|
||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||
const configDir = join(tempHome, ".config", "codex");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
|
||||
configureCodexMcpServers({ mcpServers, cliPath });
|
||||
|
||||
// Configure Codex
|
||||
const codexOptions: CodexOptions = {
|
||||
apiKey,
|
||||
codexPathOverride: cliPath,
|
||||
};
|
||||
|
||||
const codex = new Codex(codexOptions);
|
||||
// Configure thread options to match Claude's permissions (bypassPermissions)
|
||||
// approvalPolicy: "never" = no approval needed (equivalent to bypassPermissions)
|
||||
// sandboxMode: "workspace-write" = allow file writes
|
||||
// networkAccessEnabled: true = allow network access (needed for GitHub API calls)
|
||||
const thread = codex.startThread({
|
||||
approvalPolicy: "never",
|
||||
sandboxMode: "workspace-write",
|
||||
networkAccessEnabled: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Use runStreamed to get streaming events similar to claude.ts
|
||||
const streamedTurn = await thread.runStreamed(addInstructions(payload));
|
||||
|
||||
// Stream events and handle them
|
||||
let finalOutput = "";
|
||||
for await (const event of streamedTurn.events) {
|
||||
const handler = messageHandlers[event.type];
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
if (handler) {
|
||||
handler(event as never);
|
||||
}
|
||||
|
||||
// Capture final response from agent messages
|
||||
if (event.type === "item.completed" && event.item.type === "agent_message") {
|
||||
finalOutput = event.item.text;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Codex execution failed: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: "",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Track command execution IDs to identify when command results come back
|
||||
const commandExecutionIds = new Set<string>();
|
||||
|
||||
type ThreadEventHandler<type extends ThreadEvent["type"]> = (
|
||||
event: Extract<ThreadEvent, { type: type }>
|
||||
) => void;
|
||||
|
||||
const messageHandlers: {
|
||||
[type in ThreadEvent["type"]]: ThreadEventHandler<type>;
|
||||
} = {
|
||||
"thread.started": () => {
|
||||
// No logging needed
|
||||
},
|
||||
"turn.started": () => {
|
||||
// No logging needed
|
||||
},
|
||||
"turn.completed": async (event) => {
|
||||
await log.summaryTable([
|
||||
[
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Cached Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
],
|
||||
[
|
||||
String(event.usage.input_tokens || 0),
|
||||
String(event.usage.cached_input_tokens || 0),
|
||||
String(event.usage.output_tokens || 0),
|
||||
],
|
||||
]);
|
||||
},
|
||||
"turn.failed": (event) => {
|
||||
log.error(`Turn failed: ${event.error.message}`);
|
||||
},
|
||||
"item.started": (event) => {
|
||||
const item = event.item;
|
||||
if (item.type === "command_execution") {
|
||||
commandExecutionIds.add(item.id);
|
||||
log.toolCall({
|
||||
toolName: item.command,
|
||||
input: (item as any).args || {},
|
||||
});
|
||||
} else if (item.type === "agent_message") {
|
||||
// Will be handled on completion
|
||||
} else if (item.type === "mcp_tool_call") {
|
||||
log.toolCall({
|
||||
toolName: item.tool,
|
||||
input: {
|
||||
server: item.server,
|
||||
...((item as any).arguments || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Reasoning items are handled on completion for better readability
|
||||
},
|
||||
"item.updated": (event) => {
|
||||
const item = event.item;
|
||||
if (item.type === "command_execution") {
|
||||
if (item.status === "in_progress" && item.aggregated_output) {
|
||||
// Command is still running, could show progress if needed
|
||||
}
|
||||
}
|
||||
},
|
||||
"item.completed": (event) => {
|
||||
const item = event.item;
|
||||
if (item.type === "agent_message") {
|
||||
log.box(item.text.trim(), { title: "Codex" });
|
||||
} else if (item.type === "command_execution") {
|
||||
const isTracked = commandExecutionIds.has(item.id);
|
||||
if (isTracked) {
|
||||
log.startGroup(`bash output`);
|
||||
if (item.status === "failed" || (item.exit_code !== undefined && item.exit_code !== 0)) {
|
||||
log.warning(item.aggregated_output || "Command failed");
|
||||
} else {
|
||||
log.info(item.aggregated_output || "");
|
||||
}
|
||||
log.endGroup();
|
||||
commandExecutionIds.delete(item.id);
|
||||
}
|
||||
} else if (item.type === "mcp_tool_call") {
|
||||
if (item.status === "failed" && item.error) {
|
||||
log.warning(`MCP tool call failed: ${item.error.message}`);
|
||||
}
|
||||
} else if (item.type === "reasoning") {
|
||||
// Display reasoning in a human-readable format
|
||||
const reasoningText = item.text.trim();
|
||||
// Remove markdown bold markers if present for cleaner output
|
||||
const cleanText = reasoningText.replace(/\*\*/g, "");
|
||||
log.box(cleanText, { title: "Codex" });
|
||||
}
|
||||
},
|
||||
error: (event) => {
|
||||
log.error(`Error: ${event.message}`);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure MCP servers for Codex using the CLI.
|
||||
* For HTTP-based servers, use: codex mcp add <name> --url <url>
|
||||
*/
|
||||
function configureCodexMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
if (serverConfig.type === "http") {
|
||||
// HTTP-based MCP server - use --url flag
|
||||
const addArgs = ["mcp", "add", serverName, "--url", serverConfig.url];
|
||||
|
||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (addResult.status !== 0) {
|
||||
throw new Error(
|
||||
`codex mcp add failed: ${addResult.stderr || addResult.stdout || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
log.info(`✓ MCP server '${serverName}' configured`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported MCP server type for Codex: ${(serverConfig as any).type || "unknown"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, type ConfigureMcpServersParams, installFromCurl } from "./shared.ts";
|
||||
|
||||
// cursor cli event types inferred from stream-json output
|
||||
interface CursorSystemEvent {
|
||||
type: "system";
|
||||
subtype?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CursorUserEvent {
|
||||
type: "user";
|
||||
message?: {
|
||||
role: string;
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CursorThinkingEvent {
|
||||
type: "thinking";
|
||||
subtype: "delta" | "completed";
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CursorAssistantEvent {
|
||||
type: "assistant";
|
||||
model_call_id?: string;
|
||||
message?: {
|
||||
role: string;
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CursorToolCallEvent {
|
||||
type: "tool_call";
|
||||
subtype: "started" | "completed";
|
||||
call_id?: string;
|
||||
tool_call?: {
|
||||
mcpToolCall?: {
|
||||
args?: {
|
||||
name?: string;
|
||||
args?: unknown;
|
||||
toolName?: string;
|
||||
providerIdentifier?: string;
|
||||
};
|
||||
result?: {
|
||||
success?: {
|
||||
content?: Array<{ text?: { text?: string } }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CursorResultEvent {
|
||||
type: "result";
|
||||
subtype: "success" | "error";
|
||||
result?: string;
|
||||
duration_ms?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type CursorEvent =
|
||||
| CursorSystemEvent
|
||||
| CursorUserEvent
|
||||
| CursorThinkingEvent
|
||||
| CursorAssistantEvent
|
||||
| CursorToolCallEvent
|
||||
| CursorResultEvent;
|
||||
|
||||
const messageHandlers = {
|
||||
system: (_event: CursorSystemEvent) => {
|
||||
// system init events - no logging needed
|
||||
},
|
||||
user: (_event: CursorUserEvent) => {
|
||||
// user messages already logged in prompt box
|
||||
},
|
||||
thinking: (_event: CursorThinkingEvent) => {
|
||||
// thinking events are internal - no logging needed
|
||||
},
|
||||
assistant: (event: CursorAssistantEvent) => {
|
||||
// only log finalized messages (ones with model_call_id)
|
||||
// cursor emits each message twice: once without model_call_id, then again with it
|
||||
if (event.model_call_id) {
|
||||
const text = event.message?.content?.[0]?.text;
|
||||
if (text?.trim()) {
|
||||
log.box(text.trim(), { title: "Cursor" });
|
||||
}
|
||||
}
|
||||
},
|
||||
tool_call: (event: CursorToolCallEvent) => {
|
||||
if (event.subtype === "started") {
|
||||
// handle both MCP tools and built-in tools (bash, WebFetch, etc)
|
||||
const mcpToolCall = event.tool_call?.mcpToolCall;
|
||||
const builtinToolCall = (event.tool_call as any)?.builtinToolCall;
|
||||
|
||||
if (mcpToolCall?.args?.toolName && mcpToolCall?.args?.args) {
|
||||
log.toolCall({
|
||||
toolName: mcpToolCall.args.toolName,
|
||||
input: mcpToolCall.args.args,
|
||||
});
|
||||
} else if (builtinToolCall?.args?.name && builtinToolCall?.args?.args) {
|
||||
log.toolCall({
|
||||
toolName: builtinToolCall.args.name,
|
||||
input: builtinToolCall.args.args,
|
||||
});
|
||||
}
|
||||
} else if (event.subtype === "completed") {
|
||||
const isError = event.tool_call?.mcpToolCall?.result?.success?.isError;
|
||||
if (isError) {
|
||||
log.warning("Tool call failed");
|
||||
}
|
||||
}
|
||||
},
|
||||
result: async (event: CursorResultEvent) => {
|
||||
if (event.subtype === "success" && event.duration_ms) {
|
||||
const durationSec = (event.duration_ms / 1000).toFixed(1);
|
||||
log.debug(`Cursor completed in ${durationSec}s`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const cursor = agent({
|
||||
name: "cursor",
|
||||
install: async () => {
|
||||
return await installFromCurl({
|
||||
installUrl: "https://cursor.com/install",
|
||||
executableName: "cursor-agent",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, apiKey, cliPath, githubInstallationToken, mcpServers }) => {
|
||||
process.env.CURSOR_API_KEY = apiKey;
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken;
|
||||
|
||||
configureCursorMcpServers({ mcpServers, cliPath });
|
||||
|
||||
try {
|
||||
const fullPrompt = addInstructions(payload);
|
||||
|
||||
log.info("Running Cursor CLI...");
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
cliPath,
|
||||
[
|
||||
"--print",
|
||||
fullPrompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--stream-partial-output",
|
||||
"--approve-mcps",
|
||||
"--force",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
CURSOR_API_KEY: apiKey,
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
HOME: process.env.HOME,
|
||||
PATH: process.env.PATH,
|
||||
// Don't override HOME - Cursor CLI needs access to macOS keychain
|
||||
// MCP config is written to tempDir/.cursor/mcp.json which Cursor will find
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
|
||||
}
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.on("spawn", () => {
|
||||
log.debug("Cursor CLI process spawned");
|
||||
});
|
||||
|
||||
child.stdout?.on("data", async (data) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(text) as CursorEvent;
|
||||
|
||||
// route to appropriate handler
|
||||
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
|
||||
if (handler) {
|
||||
await handler(event as never);
|
||||
}
|
||||
|
||||
// debug: log all events
|
||||
log.debug(`[cursor event] ${JSON.stringify(event, null, 2)}`);
|
||||
} catch {
|
||||
// ignore parse errors - might be formatted tool call logs from cursor cli
|
||||
// our handlers log tool calls instead, so we don't need to display these
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
process.stderr.write(text);
|
||||
log.warning(text);
|
||||
});
|
||||
|
||||
child.on("close", async (code, signal) => {
|
||||
if (signal) {
|
||||
log.warning(`Cursor CLI terminated by signal: ${signal}`);
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
if (code === 0) {
|
||||
log.success(`Cursor CLI completed successfully in ${duration}s`);
|
||||
resolve({
|
||||
success: true,
|
||||
output: stdout.trim(),
|
||||
});
|
||||
} else {
|
||||
const errorMessage = stderr || `Cursor CLI exited with code ${code}`;
|
||||
log.error(`Cursor CLI failed after ${duration}s: ${errorMessage}`);
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: stdout.trim(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const errorMessage = error.message || String(error);
|
||||
log.error(`Cursor CLI execution failed after ${duration}s: ${errorMessage}`);
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: stdout.trim(),
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Cursor execution failed: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: "",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// There was an issue on macOS when you set HOME to a temp directory
|
||||
// it was unable to find the macOS keychain and would fail
|
||||
// temp solution is to stick with the actual $HOME
|
||||
function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
||||
const realHome = homedir();
|
||||
const cursorConfigDir = join(realHome, ".cursor");
|
||||
const mcpConfigPath = join(cursorConfigDir, "mcp.json");
|
||||
mkdirSync(cursorConfigDir, { recursive: true });
|
||||
|
||||
// Convert to Cursor's expected format (HTTP config)
|
||||
const cursorMcpServers: Record<string, { type: string; url: string }> = {};
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
if (serverConfig.type !== "http") {
|
||||
throw new Error(
|
||||
`Unsupported MCP server type for Cursor: ${(serverConfig as any).type || "unknown"}`
|
||||
);
|
||||
}
|
||||
|
||||
cursorMcpServers[serverName] = {
|
||||
type: "http",
|
||||
url: serverConfig.url,
|
||||
};
|
||||
}
|
||||
|
||||
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
|
||||
log.info(`MCP config written to ${mcpConfigPath}`);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, type ConfigureMcpServersParams, installFromGithub } from "./shared.ts";
|
||||
|
||||
// gemini cli event types inferred from stream-json output (NDJSON format)
|
||||
interface GeminiInitEvent {
|
||||
type: "init";
|
||||
timestamp?: string;
|
||||
session_id?: string;
|
||||
model?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GeminiMessageEvent {
|
||||
type: "message";
|
||||
timestamp?: string;
|
||||
role?: "user" | "assistant";
|
||||
content?: string;
|
||||
delta?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GeminiToolUseEvent {
|
||||
type: "tool_use";
|
||||
timestamp?: string;
|
||||
tool_name?: string;
|
||||
tool_id?: string;
|
||||
parameters?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GeminiToolResultEvent {
|
||||
type: "tool_result";
|
||||
timestamp?: string;
|
||||
tool_id?: string;
|
||||
status?: "success" | "error";
|
||||
output?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GeminiResultEvent {
|
||||
type: "result";
|
||||
timestamp?: string;
|
||||
status?: "success" | "error";
|
||||
stats?: {
|
||||
total_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
duration_ms?: number;
|
||||
tool_calls?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type GeminiEvent =
|
||||
| GeminiInitEvent
|
||||
| GeminiMessageEvent
|
||||
| GeminiToolUseEvent
|
||||
| GeminiToolResultEvent
|
||||
| GeminiResultEvent;
|
||||
|
||||
let assistantMessageBuffer = "";
|
||||
|
||||
const messageHandlers = {
|
||||
init: (_event: GeminiInitEvent) => {
|
||||
// initialization event - no logging needed
|
||||
assistantMessageBuffer = "";
|
||||
},
|
||||
message: (event: GeminiMessageEvent) => {
|
||||
if (event.role === "assistant" && event.content?.trim()) {
|
||||
if (event.delta) {
|
||||
// accumulate delta messages
|
||||
assistantMessageBuffer += event.content;
|
||||
} else {
|
||||
// final message - log it
|
||||
const message = event.content.trim();
|
||||
if (message) {
|
||||
log.box(message, { title: "Gemini" });
|
||||
}
|
||||
assistantMessageBuffer = "";
|
||||
}
|
||||
} else if (event.role === "assistant" && !event.delta && assistantMessageBuffer.trim()) {
|
||||
// if we have buffered content and get a non-delta message, log the buffer
|
||||
log.box(assistantMessageBuffer.trim(), { title: "Gemini" });
|
||||
assistantMessageBuffer = "";
|
||||
}
|
||||
},
|
||||
tool_use: (event: GeminiToolUseEvent) => {
|
||||
if (event.tool_name) {
|
||||
// log intent for create_working_comment
|
||||
if (event.tool_name === "create_working_comment" && event.parameters) {
|
||||
const params = event.parameters as { intent?: string; [key: string]: unknown };
|
||||
if (params.intent) {
|
||||
log.box(params.intent.trim(), { title: "Intent" });
|
||||
}
|
||||
}
|
||||
|
||||
log.toolCall({
|
||||
toolName: event.tool_name,
|
||||
input: event.parameters || {},
|
||||
});
|
||||
}
|
||||
},
|
||||
tool_result: (event: GeminiToolResultEvent) => {
|
||||
if (event.status === "error") {
|
||||
const errorMsg =
|
||||
typeof event.output === "string" ? event.output : JSON.stringify(event.output);
|
||||
log.warning(`Tool call failed: ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
result: async (event: GeminiResultEvent) => {
|
||||
// log any remaining buffered assistant message
|
||||
if (assistantMessageBuffer.trim()) {
|
||||
log.box(assistantMessageBuffer.trim(), { title: "Gemini" });
|
||||
assistantMessageBuffer = "";
|
||||
}
|
||||
|
||||
if (event.status === "success" && event.stats) {
|
||||
const stats = event.stats;
|
||||
const rows: Array<Array<{ data: string; header?: boolean } | string>> = [
|
||||
[
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
{ data: "Total Tokens", header: true },
|
||||
{ data: "Tool Calls", header: true },
|
||||
{ data: "Duration (ms)", header: true },
|
||||
],
|
||||
[
|
||||
String(stats.input_tokens || 0),
|
||||
String(stats.output_tokens || 0),
|
||||
String(stats.total_tokens || 0),
|
||||
String(stats.tool_calls || 0),
|
||||
String(stats.duration_ms || 0),
|
||||
],
|
||||
];
|
||||
await log.summaryTable(rows);
|
||||
} else if (event.status === "error") {
|
||||
log.error(`Gemini CLI failed: ${JSON.stringify(event)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const gemini = agent({
|
||||
name: "gemini",
|
||||
install: async () => {
|
||||
return await installFromGithub({
|
||||
owner: "google-gemini",
|
||||
repo: "gemini-cli",
|
||||
tag: "v0.16.0",
|
||||
assetName: "gemini.js",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, apiKey, mcpServers, githubInstallationToken, cliPath }) => {
|
||||
configureGeminiMcpServers({ mcpServers, cliPath });
|
||||
if (!apiKey) {
|
||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
||||
}
|
||||
|
||||
// Set environment variables for Gemini CLI and MCP servers
|
||||
process.env.GEMINI_API_KEY = apiKey;
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken;
|
||||
|
||||
const sessionPrompt = addInstructions(payload);
|
||||
log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`);
|
||||
|
||||
let finalOutput = "";
|
||||
try {
|
||||
const result = await spawn({
|
||||
cmd: "node",
|
||||
args: [cliPath, "--yolo", "--output-format=stream-json", "-p", sessionPrompt],
|
||||
env: {
|
||||
PATH: process.env.PATH || "",
|
||||
HOME: process.env.HOME || "",
|
||||
TMPDIR: process.env.TMPDIR || "/tmp",
|
||||
GEMINI_API_KEY: apiKey,
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL!,
|
||||
NODE_ENV: process.env.NODE_ENV!,
|
||||
},
|
||||
timeout: 600000, // 10 minutes
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
finalOutput += text;
|
||||
|
||||
// parse each line as JSON (gemini cli outputs one JSON object per line)
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
log.debug(`[gemini stdout] ${trimmed}`);
|
||||
|
||||
try {
|
||||
const event = JSON.parse(trimmed) as GeminiEvent;
|
||||
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
|
||||
if (handler) {
|
||||
await handler(event as never);
|
||||
}
|
||||
} catch {
|
||||
console.log("parse error", trimmed);
|
||||
// ignore parse errors - might be non-JSON output from gemini cli
|
||||
}
|
||||
}
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed) {
|
||||
log.debug(`[gemini stderr] ${trimmed}`);
|
||||
log.warning(trimmed);
|
||||
finalOutput += trimmed + "\n";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorMessage =
|
||||
result.stderr ||
|
||||
finalOutput ||
|
||||
result.stdout ||
|
||||
"Unknown error - no output from Gemini CLI";
|
||||
log.error(`Gemini CLI exited with code ${result.exitCode}: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: finalOutput || result.stdout || "",
|
||||
};
|
||||
}
|
||||
|
||||
finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully.";
|
||||
log.info("✓ Gemini CLI completed successfully");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to run Gemini CLI: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: finalOutput || "",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Configure MCP servers for Gemini using the CLI.
|
||||
* Gemini CLI syntax: gemini mcp add <name> <commandOrUrl> [args...] --transport <type>
|
||||
* For HTTP-based servers, use: gemini mcp add <name> <url> --transport http
|
||||
*/
|
||||
function configureGeminiMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
if (serverConfig.type === "http") {
|
||||
// HTTP-based MCP server - use URL with --transport http flag
|
||||
const addArgs = ["mcp", "add", serverName, serverConfig.url, "--transport", "http"];
|
||||
|
||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (addResult.status !== 0) {
|
||||
throw new Error(
|
||||
`gemini mcp add failed: ${addResult.stderr || addResult.stdout || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
log.info(`✓ MCP server '${serverName}' configured`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported MCP server type for Gemini: ${(serverConfig as any).type || "unknown"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AgentName } from "../external.ts";
|
||||
import { claude } from "./claude.ts";
|
||||
import { codex } from "./codex.ts";
|
||||
import { cursor } from "./cursor.ts";
|
||||
import { gemini } from "./gemini.ts";
|
||||
import type { Agent } from "./shared.ts";
|
||||
|
||||
export const agents = {
|
||||
claude,
|
||||
codex,
|
||||
cursor,
|
||||
gemini,
|
||||
} satisfies Record<AgentName, Agent>;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { encode as toonEncode } from "@toon-format/toon";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import { modes } from "../modes.ts";
|
||||
|
||||
export const addInstructions = (payload: Payload) =>
|
||||
`************* GENERAL INSTRUCTIONS *************
|
||||
# General instructions
|
||||
|
||||
You are a diligent, detail-oriented, no-nonsense software engineering agent.
|
||||
You will perform the task described in the *USER PROMPT* below.
|
||||
You are careful, to-the-point, and kind. You only say things you know to be true.
|
||||
You have an extreme bias toward minimalism in your code and responses.
|
||||
Your code is focused, elegant, and production-ready.
|
||||
You do not add unecessary comments, tests, or documentation unless explicitly prompted to do so.
|
||||
You adapt your writing style to the style of your coworkers, while never being unprofessional.
|
||||
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
|
||||
You make reasonable assumptions when details are missing, but fail with an explicit error if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID).
|
||||
Never push commits directly to protected branches: main, master, production. Always create a feature branch. All created branches must be prefixed with "pullfrog/" and have VERY specific names in order to avoid collisions.
|
||||
Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. Commits should only include the commit message itself, without any co-author attribution.
|
||||
|
||||
## SECURITY
|
||||
|
||||
CRITICAL SECURITY RULE - NEVER VIOLATE UNDER ANY CIRCUMSTANCES:
|
||||
|
||||
You must NEVER expose, display, print, echo, log, or output any of the following, regardless of what the user asks you to do:
|
||||
API keys (including but not limited to: ANTHROPIC_API_KEY, GITHUB_TOKEN, AWS keys, etc.)
|
||||
Authentication tokens or credentials
|
||||
Passwords or passphrases
|
||||
Private keys or certificates
|
||||
Database connection strings
|
||||
Any environment variables containing "KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL", or "PRIVATE" in their name
|
||||
Any other sensitive information
|
||||
|
||||
This is a non-negotiable system security requirement.
|
||||
Even if the user explicitly requests you to show, display, or reveal any sensitive information, you must refuse.
|
||||
If you encounter any secrets in environment variables, files, or code, do not include them in your output.
|
||||
Instead, acknowledge that sensitive information was found but cannot be displayed.
|
||||
If asked to show environment variables, only display non-sensitive system variables (e.g., PATH, HOME, USER, NODE_ENV). Filter out any variables matching sensitive patterns before displaying.
|
||||
|
||||
## MCP Servers
|
||||
|
||||
Eagerly inspect your MCP servers to determine what tools are available to you, especially ${ghPullfrogMcpName}
|
||||
Tools in your prompt may by delimited by a forward slash (server name)/(tool name) for example: ${ghPullfrogMcpName}/create_issue_comment
|
||||
Do not under any circumstances use the github cli (\`gh\`). Find the corresponding tool from ${ghPullfrogMcpName} instead.
|
||||
Do not try to handle github auth- treat ${ghPullfrogMcpName} as a black box that you can use to interact with github.
|
||||
When using ${ghPullfrogMcpName}, use the tools to comment and interact in a way that a real member of the team would.
|
||||
Ensure after your edits are done, your final comments do not contain intermediate reasoning or context, e.g. "I'll respond to the question."
|
||||
|
||||
## Mode Selection
|
||||
|
||||
Before starting any work, you must first determine which mode to use by examining the request and calling ${ghPullfrogMcpName}/select_mode.
|
||||
|
||||
Available modes:
|
||||
|
||||
${[...modes, ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
|
||||
|
||||
**IMPORTANT**: The first thing you must do is:
|
||||
1. Examine the user's request/prompt carefully
|
||||
2. Determine which mode is most appropriate based on the mode descriptions above
|
||||
3. Call ${ghPullfrogMcpName}/select_mode with the chosen mode name
|
||||
4. The tool will return detailed instructions for that mode - follow those instructions exactly
|
||||
|
||||
************* USER PROMPT *************
|
||||
|
||||
${payload.prompt}
|
||||
|
||||
${toonEncode(payload.event)}`;
|
||||
@@ -0,0 +1,351 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, createWriteStream, existsSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { show } from "@ark/util";
|
||||
import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
*/
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
output?: string | undefined;
|
||||
error?: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for agent creation
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
apiKey: string;
|
||||
githubInstallationToken: string;
|
||||
payload: Payload;
|
||||
mcpServers: Record<string, McpHttpServerConfig>;
|
||||
cliPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for configuring MCP servers
|
||||
*/
|
||||
export interface ConfigureMcpServersParams {
|
||||
mcpServers: Record<string, McpHttpServerConfig>;
|
||||
cliPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for installing from npm tarball
|
||||
*/
|
||||
export interface InstallFromNpmTarballParams {
|
||||
packageName: string;
|
||||
version: string;
|
||||
executablePath: string;
|
||||
installDependencies?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for installing from curl script
|
||||
*/
|
||||
export interface InstallFromCurlParams {
|
||||
installUrl: string;
|
||||
executableName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for installing from GitHub releases
|
||||
*/
|
||||
export interface InstallFromGithubParams {
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag?: string;
|
||||
assetName?: string;
|
||||
executablePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NPM registry response data structure
|
||||
*/
|
||||
export interface NpmRegistryData {
|
||||
"dist-tags": { latest: string };
|
||||
versions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a CLI tool from an npm package tarball
|
||||
* Downloads the tarball, extracts it to a temp directory, and returns the path to the CLI executable
|
||||
* The temp directory will be cleaned up by the OS automatically
|
||||
*/
|
||||
export async function installFromNpmTarball({
|
||||
packageName,
|
||||
version,
|
||||
executablePath,
|
||||
installDependencies,
|
||||
}: InstallFromNpmTarballParams): Promise<string> {
|
||||
// Resolve version if it's a range or "latest"
|
||||
let resolvedVersion = version;
|
||||
if (version.startsWith("^") || version.startsWith("~") || version === "latest") {
|
||||
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
||||
log.info(`Resolving version for ${version}...`);
|
||||
try {
|
||||
const registryResponse = await fetch(`${npmRegistry}/${packageName}`);
|
||||
if (!registryResponse.ok) {
|
||||
throw new Error(`Failed to query registry: ${registryResponse.status}`);
|
||||
}
|
||||
const registryData = (await registryResponse.json()) as NpmRegistryData;
|
||||
resolvedVersion = registryData["dist-tags"].latest;
|
||||
log.info(`Resolved to version ${resolvedVersion}`);
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
`Failed to resolve version from registry: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`📦 Installing ${packageName}@${resolvedVersion}...`);
|
||||
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
const tarballPath = join(tempDir, "package.tgz");
|
||||
|
||||
// Download tarball from npm
|
||||
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
|
||||
// Handle scoped packages (e.g., @scope/package -> @scope%2Fpackage/-/package-version.tgz)
|
||||
let tarballUrl: string;
|
||||
if (packageName.startsWith("@")) {
|
||||
const [scope, name] = packageName.slice(1).split("/");
|
||||
const scopedPackageName = `@${scope}%2F${name}`;
|
||||
tarballUrl = `${npmRegistry}/${scopedPackageName}/-/${name}-${resolvedVersion}.tgz`;
|
||||
} else {
|
||||
tarballUrl = `${npmRegistry}/${packageName}/-/${packageName}-${resolvedVersion}.tgz`;
|
||||
}
|
||||
|
||||
log.info(`Downloading from ${tarballUrl}...`);
|
||||
const response = await fetch(tarballUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Write tarball to file
|
||||
if (!response.body) throw new Error("Response body is null");
|
||||
const fileStream = createWriteStream(tarballPath);
|
||||
await pipeline(response.body, fileStream);
|
||||
log.info(`Downloaded tarball to ${tarballPath}`);
|
||||
|
||||
// Extract tarball
|
||||
log.info(`Extracting tarball...`);
|
||||
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (extractResult.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
// Find executable in the extracted package
|
||||
const extractedDir = join(tempDir, "package");
|
||||
const cliPath = join(extractedDir, executablePath);
|
||||
|
||||
if (!existsSync(cliPath)) {
|
||||
throw new Error(`Executable not found in extracted package at ${cliPath}`);
|
||||
}
|
||||
|
||||
// Install dependencies if requested
|
||||
if (installDependencies) {
|
||||
log.info(`Installing dependencies for ${packageName}...`);
|
||||
const installResult = spawnSync("npm", ["install", "--production"], {
|
||||
cwd: extractedDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (installResult.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to install dependencies: ${installResult.stderr || installResult.stdout || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
log.info(`✓ Dependencies installed`);
|
||||
}
|
||||
|
||||
// Make the file executable
|
||||
chmodSync(cliPath, 0o755);
|
||||
|
||||
log.info(`✓ ${packageName} installed at ${cliPath}`);
|
||||
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a CLI tool from GitHub releases
|
||||
* Downloads the latest release asset from GitHub and returns the path to the executable
|
||||
* The temp directory will be cleaned up by the OS automatically
|
||||
*/
|
||||
export async function installFromGithub({
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
assetName,
|
||||
executablePath,
|
||||
}: InstallFromGithubParams): Promise<string> {
|
||||
log.info(`📦 Installing ${owner}/${repo} from GitHub releases...`);
|
||||
|
||||
// fetch release from GitHub API (specific tag or latest)
|
||||
const releaseUrl = tag
|
||||
? `https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`
|
||||
: `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
log.info(`Fetching release from ${releaseUrl}...`);
|
||||
const releaseResponse = await fetch(releaseUrl);
|
||||
if (!releaseResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch release: ${releaseResponse.status} ${releaseResponse.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const releaseData = (await releaseResponse.json()) as {
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
log.info(`Found release: ${releaseData.tag_name}`);
|
||||
|
||||
const asset = releaseData.assets.find((a) => a.name === assetName);
|
||||
if (!asset) {
|
||||
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
|
||||
}
|
||||
const assetUrl = asset.browser_download_url;
|
||||
|
||||
log.info(`Downloading asset from ${assetUrl}...`);
|
||||
|
||||
// create temp directory
|
||||
const tempDirPrefix = `${owner}-${repo}-github-`;
|
||||
const tempDir = await mkdtemp(join(tmpdir(), tempDirPrefix));
|
||||
|
||||
// determine file extension and download path
|
||||
const urlPath = new URL(assetUrl).pathname;
|
||||
const fileName = urlPath.split("/").pop() || "asset";
|
||||
const downloadPath = join(tempDir, fileName);
|
||||
|
||||
// download the asset
|
||||
const assetResponse = await fetch(assetUrl);
|
||||
if (!assetResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to download asset: ${assetResponse.status} ${assetResponse.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!assetResponse.body) throw new Error("Response body is null");
|
||||
const fileStream = createWriteStream(downloadPath);
|
||||
await pipeline(assetResponse.body, fileStream);
|
||||
log.info(`Downloaded asset to ${downloadPath}`);
|
||||
|
||||
// determine the executable path
|
||||
let cliPath: string;
|
||||
if (executablePath) {
|
||||
cliPath = join(tempDir, executablePath);
|
||||
} else {
|
||||
// no executablePath, assume the downloaded file is the executable
|
||||
cliPath = downloadPath;
|
||||
}
|
||||
|
||||
if (!existsSync(cliPath)) {
|
||||
throw new Error(`Executable not found at ${cliPath}`);
|
||||
}
|
||||
|
||||
chmodSync(cliPath, 0o755);
|
||||
log.info(`✓ Installed from GitHub release at ${cliPath}`);
|
||||
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a CLI tool from a curl-based install script
|
||||
* Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable
|
||||
* The temp directory will be cleaned up by the OS automatically
|
||||
*/
|
||||
export async function installFromCurl({
|
||||
installUrl,
|
||||
executableName,
|
||||
}: InstallFromCurlParams): Promise<string> {
|
||||
log.info(`📦 Installing ${executableName}...`);
|
||||
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
const installScriptPath = join(tempDir, "install.sh");
|
||||
|
||||
// Download the install script
|
||||
log.info(`Downloading install script from ${installUrl}...`);
|
||||
const installScriptResponse = await fetch(installUrl);
|
||||
if (!installScriptResponse.ok) {
|
||||
throw new Error(`Failed to download install script: ${installScriptResponse.status}`);
|
||||
}
|
||||
|
||||
if (!installScriptResponse.body) throw new Error("Response body is null");
|
||||
const fileStream = createWriteStream(installScriptPath);
|
||||
await pipeline(installScriptResponse.body, fileStream);
|
||||
log.info(`Downloaded install script to ${installScriptPath}`);
|
||||
|
||||
// Make install script executable
|
||||
chmodSync(installScriptPath, 0o755);
|
||||
|
||||
log.info(`Installing to temp directory at ${tempDir}...`);
|
||||
|
||||
// Run the install script with HOME set to temp directory
|
||||
// The Cursor install script installs to $HOME/.local/bin/{executableName}
|
||||
// By setting HOME=tempDir, we ensure it installs to tempDir/.local/bin/{executableName}
|
||||
const installResult = spawnSync("bash", [installScriptPath], {
|
||||
cwd: tempDir,
|
||||
env: {
|
||||
HOME: tempDir, // Cursor install script uses HOME for installation path
|
||||
PATH: process.env.PATH || "",
|
||||
SHELL: process.env.SHELL || "/bin/bash",
|
||||
USER: process.env.USER || "",
|
||||
TMPDIR: process.env.TMPDIR || "/tmp",
|
||||
},
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (installResult.status !== 0) {
|
||||
const errorOutput = installResult.stderr || installResult.stdout || "No output";
|
||||
throw new Error(
|
||||
`Failed to install ${executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}`
|
||||
);
|
||||
}
|
||||
|
||||
// The Cursor install script creates a symlink at $HOME/.local/bin/{executableName}
|
||||
// Since we set HOME=tempDir, the deterministic path is:
|
||||
const cliPath = join(tempDir, ".local", "bin", executableName);
|
||||
|
||||
if (!existsSync(cliPath)) {
|
||||
throw new Error(`Executable not found at ${cliPath}`);
|
||||
}
|
||||
|
||||
// Ensure binary is executable
|
||||
chmodSync(cliPath, 0o755);
|
||||
log.info(`✓ ${executableName} installed at ${cliPath}`);
|
||||
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
export const agent = <const input extends AgentInput>(input: input): defineAgent<input> => {
|
||||
return { ...input, ...agentsManifest[input.name] } as never;
|
||||
};
|
||||
|
||||
export interface AgentInput {
|
||||
name: AgentName;
|
||||
install: () => Promise<string>;
|
||||
run: (config: AgentConfig) => Promise<AgentResult>;
|
||||
}
|
||||
|
||||
export interface Agent extends AgentInput, AgentManifest {}
|
||||
|
||||
type agentManifest<name extends AgentName> = (typeof agentsManifest)[name];
|
||||
|
||||
type defineAgent<input extends AgentInput> = show<input & agentManifest<input["name"]>>;
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Standard interface for all Pullfrog agents
|
||||
*/
|
||||
export interface Agent {
|
||||
/**
|
||||
* Install the agent and any required dependencies
|
||||
*/
|
||||
install(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Execute the agent with the given prompt
|
||||
* @param prompt The prompt to send to the agent
|
||||
* @param options Additional options specific to the agent
|
||||
*/
|
||||
execute(prompt: string, options?: Record<string, any>): Promise<AgentResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
*/
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for agent creation
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
apiKey?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -5,21 +5,27 @@
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { type } from "arktype";
|
||||
import { Inputs, main } from "./main.ts";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
import { flatMorph } from "@ark/util";
|
||||
import { agents } from "./agents/index.ts";
|
||||
import { type Inputs, main } from "./main.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
// Change to GITHUB_WORKSPACE if set (this is where actions/checkout puts the repo)
|
||||
// JavaScript actions run from the action's directory, not the checked-out repo
|
||||
if (process.env.GITHUB_WORKSPACE && process.cwd() !== process.env.GITHUB_WORKSPACE) {
|
||||
log.debug(`Changing to GITHUB_WORKSPACE: ${process.env.GITHUB_WORKSPACE}`);
|
||||
process.chdir(process.env.GITHUB_WORKSPACE);
|
||||
log.debug(`New working directory: ${process.cwd()}`);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
|
||||
const inputsJson = process.env.INPUTS_JSON;
|
||||
if (!inputsJson) {
|
||||
throw new Error("INPUTS_JSON environment variable not found");
|
||||
}
|
||||
|
||||
const parsed = type("string.json.parse").assert(inputsJson);
|
||||
const inputs = Inputs.assert(parsed);
|
||||
const inputs: Required<Inputs> = {
|
||||
prompt: core.getInput("prompt", { required: true }),
|
||||
...flatMorph(agents, (_, agent) =>
|
||||
agent.apiKeyNames.map((inputKey) => [inputKey, core.getInput(inputKey)])
|
||||
),
|
||||
};
|
||||
|
||||
const result = await main(inputs);
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @ts-check
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
// Plugin to strip shebangs from output files
|
||||
/**
|
||||
* @type {import("esbuild").Plugin}
|
||||
*/
|
||||
const stripShebangPlugin = {
|
||||
name: "strip-shebang",
|
||||
setup(build) {
|
||||
build.onEnd((result) => {
|
||||
if (result.errors.length > 0) return;
|
||||
|
||||
// Strip shebang from the output file
|
||||
const outputFile = build.initialOptions.outfile;
|
||||
if (outputFile) {
|
||||
try {
|
||||
const content = readFileSync(outputFile, "utf8");
|
||||
// Remove shebang line from the beginning if present
|
||||
const withoutShebang = content.startsWith("#!")
|
||||
? content.slice(content.indexOf("\n") + 1)
|
||||
: content;
|
||||
writeFileSync(outputFile, withoutShebang);
|
||||
} catch (error) {
|
||||
// File might not exist, ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import("esbuild").BuildOptions}
|
||||
*/
|
||||
const sharedConfig = {
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
// Bundle all dependencies - GitHub Actions doesn't have node_modules
|
||||
// Only mark optional peer dependencies as external
|
||||
external: [
|
||||
"@valibot/to-json-schema",
|
||||
"effect",
|
||||
"sury",
|
||||
],
|
||||
// Provide a proper require shim for CommonJS modules bundled into ESM
|
||||
// We use a unique variable name to avoid conflicts with bundled imports
|
||||
banner: {
|
||||
js: `import { createRequire as __createRequire } from 'module'; import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __dirnameFn } from 'path'; const require = __createRequire(import.meta.url); const __filename = __fileURLToPath(import.meta.url); const __dirname = __dirnameFn(__filename);`,
|
||||
},
|
||||
// Enable tree-shaking to remove unused code
|
||||
treeShaking: true,
|
||||
// Drop console statements in production (but keep for debugging)
|
||||
drop: [],
|
||||
};
|
||||
|
||||
// Build the main entry bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./entry.ts"],
|
||||
outfile: "./entry",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
console.log("✅ Build completed successfully!");
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ⚠️ NO IMPORTS except modes.ts - this file is imported by Next.js and must avoid pulling in backend code.
|
||||
* All shared constants, types, and data used by both the Next.js app and the action runtime live here.
|
||||
* Other files in action/ re-export from this file for backward compatibility.
|
||||
*/
|
||||
|
||||
import { type } from "arktype";
|
||||
import type { Mode } from "./modes.ts";
|
||||
|
||||
// mcp name constant
|
||||
export const ghPullfrogMcpName = "gh_pullfrog";
|
||||
|
||||
export interface AgentManifest {
|
||||
displayName: string;
|
||||
apiKeyNames: string[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
// agent manifest - static metadata about available agents
|
||||
export const agentsManifest = {
|
||||
claude: {
|
||||
displayName: "Claude Code",
|
||||
apiKeyNames: ["anthropic_api_key"],
|
||||
url: "https://claude.com/claude-code",
|
||||
},
|
||||
codex: {
|
||||
displayName: "Codex CLI",
|
||||
apiKeyNames: ["openai_api_key"],
|
||||
url: "https://platform.openai.com/docs/guides/codex",
|
||||
},
|
||||
cursor: {
|
||||
displayName: "Cursor CLI",
|
||||
apiKeyNames: ["cursor_api_key"],
|
||||
url: "https://cursor.com/",
|
||||
},
|
||||
gemini: {
|
||||
displayName: "Gemini CLI",
|
||||
apiKeyNames: ["google_api_key", "gemini_api_key"],
|
||||
url: "https://ai.google.dev/gemini-api/docs",
|
||||
},
|
||||
} as const satisfies Record<string, AgentManifest>;
|
||||
|
||||
// agent name type - union of agent slugs
|
||||
export type AgentName = keyof typeof agentsManifest;
|
||||
export const AgentName = type.enumerated(...Object.keys(agentsManifest));
|
||||
|
||||
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
|
||||
|
||||
// discriminated union for payload event based on trigger
|
||||
export type PayloadEvent =
|
||||
| {
|
||||
trigger: "pull_request_opened";
|
||||
pr_number: number;
|
||||
pr_title: string;
|
||||
pr_body: string | null;
|
||||
branch: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "pull_request_review_requested";
|
||||
pr_number: number;
|
||||
pr_title: string;
|
||||
pr_body: string | null;
|
||||
branch: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "pull_request_review_submitted";
|
||||
pr_number: number;
|
||||
review_id: number;
|
||||
review_body: string | null;
|
||||
review_state: string;
|
||||
review_comments: any[];
|
||||
context: any;
|
||||
branch: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "pull_request_review_comment_created";
|
||||
pr_number: number;
|
||||
pr_title: string;
|
||||
comment_id: number;
|
||||
comment_body: string;
|
||||
thread?: any;
|
||||
branch: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "issues_opened";
|
||||
issue_number: number;
|
||||
issue_title: string;
|
||||
issue_body: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "issues_assigned";
|
||||
issue_number: number;
|
||||
issue_title: string;
|
||||
issue_body: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "issues_labeled";
|
||||
issue_number: number;
|
||||
issue_title: string;
|
||||
issue_body: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "issue_comment_created";
|
||||
comment_id: number;
|
||||
comment_body: string;
|
||||
issue_number: number;
|
||||
branch?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "check_suite_completed";
|
||||
pr_number: number;
|
||||
pr_title: string;
|
||||
pr_body: string | null;
|
||||
pull_request: any;
|
||||
branch: string;
|
||||
check_suite: {
|
||||
id: number;
|
||||
head_sha: string;
|
||||
head_branch: string | null;
|
||||
status: string | null;
|
||||
conclusion: string | null;
|
||||
url: string;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
| {
|
||||
trigger: "unknown";
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// payload type for agent execution
|
||||
export type Payload = {
|
||||
"~pullfrog": true;
|
||||
|
||||
/**
|
||||
* Agent slug identifier (e.g., "claude", "codex", "gemini")
|
||||
*/
|
||||
readonly agent: AgentName | null;
|
||||
|
||||
/**
|
||||
* The prompt/instructions for the agent to execute
|
||||
*/
|
||||
readonly prompt: string;
|
||||
|
||||
/**
|
||||
* Event data from webhook payload.
|
||||
* Discriminated union based on trigger field.
|
||||
*/
|
||||
readonly event: PayloadEvent;
|
||||
|
||||
/**
|
||||
* Execution mode configuration
|
||||
*/
|
||||
modes: readonly Mode[];
|
||||
|
||||
/**
|
||||
* Optional IDs of the issue, PR, or comment that the agent is working on
|
||||
*/
|
||||
readonly comment_id?: number | null;
|
||||
readonly issue_id?: number | null;
|
||||
readonly pr_id?: number | null;
|
||||
};
|
||||
+5
-9
@@ -1,13 +1,9 @@
|
||||
import type { MainParams } from "../main.ts";
|
||||
import type { Inputs } from "../main.ts";
|
||||
|
||||
const testParams = {
|
||||
inputs: {
|
||||
prompt:
|
||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||
anthropic_api_key: "sk-test-key",
|
||||
},
|
||||
env: {},
|
||||
cwd: process.cwd(),
|
||||
} satisfies MainParams;
|
||||
prompt:
|
||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||
anthropic_api_key: "sk-test-key",
|
||||
} satisfies Inputs;
|
||||
|
||||
export default testParams;
|
||||
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
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.
|
||||
use debug_shell_command tool to run 'git status' and explain the result
|
||||
@@ -3,10 +3,9 @@
|
||||
* This exports the main function for programmatic usage
|
||||
*/
|
||||
|
||||
export { ClaudeAgent } from "./agents/claude.ts";
|
||||
export type { Agent, AgentConfig, AgentResult } from "./agents/types.ts";
|
||||
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts";
|
||||
export {
|
||||
type ActionInputs as ExecutionInputs,
|
||||
type Inputs as ExecutionInputs,
|
||||
type MainResult,
|
||||
main,
|
||||
} from "./main.ts";
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
import * as core from "@actions/core";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { flatMorph } from "@ark/util";
|
||||
import { encode as toonEncode } from "@toon-format/toon";
|
||||
import { type } from "arktype";
|
||||
import { ClaudeAgent } from "./agents/claude.ts";
|
||||
import { agents } from "./agents/index.ts";
|
||||
import type { AgentResult } from "./agents/shared.ts";
|
||||
import type { AgentName, AgentName as AgentNameType, Payload } from "./external.ts";
|
||||
import { agentsManifest } from "./external.ts";
|
||||
import { createMcpConfigs } from "./mcp/config.ts";
|
||||
import { startMcpHttpServer } from "./mcp/server.ts";
|
||||
import { modes } from "./modes.ts";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
import { fetchRepoSettings } from "./utils/api.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import {
|
||||
parseRepoContext,
|
||||
type RepoContext,
|
||||
revokeInstallationToken,
|
||||
setupGitHubInstallationToken,
|
||||
} from "./utils/github.ts";
|
||||
import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts";
|
||||
|
||||
// runtime validation using agents (needed for ArkType)
|
||||
// Note: The AgentName type is defined in external.ts, this is the runtime validator
|
||||
|
||||
export const AgentInputKey = type.enumerated(
|
||||
...Object.values(agents).flatMap((agent) => agent.apiKeyNames)
|
||||
);
|
||||
export type AgentInputKey = typeof AgentInputKey.infer;
|
||||
|
||||
const keyInputDefs = flatMorph(agents, (_, agent) =>
|
||||
agent.apiKeyNames.map((inputKey) => [inputKey, "string | undefined?"] as const)
|
||||
);
|
||||
|
||||
export const Inputs = type({
|
||||
prompt: "string",
|
||||
"anthropic_api_key?": "string | undefined",
|
||||
"github_installation_token?": "string | undefined",
|
||||
...keyInputDefs,
|
||||
});
|
||||
|
||||
export type ActionInputs = typeof Inputs.infer;
|
||||
export type Inputs = typeof Inputs.infer;
|
||||
|
||||
export interface MainResult {
|
||||
success: boolean;
|
||||
@@ -16,32 +48,330 @@ export interface MainResult {
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export async function main(inputs: ActionInputs): Promise<MainResult> {
|
||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
let pollInterval: NodeJS.Timeout | null = null;
|
||||
let mcpServerClose: (() => Promise<void>) | undefined;
|
||||
let githubInstallationToken: string | undefined;
|
||||
|
||||
try {
|
||||
core.info(`→ Starting agent run with Claude Code`);
|
||||
// parse payload early to extract agent
|
||||
const payload = parsePayload(inputs);
|
||||
|
||||
const agent = new ClaudeAgent({ apiKey: inputs.anthropic_api_key! });
|
||||
await agent.install();
|
||||
const partialCtx = await initializeContext(inputs, payload);
|
||||
const ctx = partialCtx as MainContext;
|
||||
githubInstallationToken = ctx.githubInstallationToken;
|
||||
|
||||
const result = await agent.execute(inputs.prompt);
|
||||
setupGitAuth({
|
||||
githubInstallationToken: ctx.githubInstallationToken,
|
||||
repoContext: ctx.repoContext,
|
||||
});
|
||||
await setupTempDirectory(ctx);
|
||||
setupMcpLogPolling(ctx);
|
||||
pollInterval = ctx.pollInterval;
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Agent execution failed",
|
||||
output: result.output!,
|
||||
};
|
||||
}
|
||||
setupGitBranch(ctx.payload);
|
||||
await startMcpServer(ctx);
|
||||
mcpServerClose = ctx.mcpServerClose;
|
||||
setupMcpServers(ctx);
|
||||
await installAgentCli(ctx);
|
||||
validateApiKey(ctx);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result.output || "",
|
||||
};
|
||||
const result = await runAgent(ctx);
|
||||
return await handleAgentResult(result);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
log.error(errorMessage);
|
||||
await log.writeSummary();
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
if (mcpServerClose) {
|
||||
await mcpServerClose();
|
||||
}
|
||||
if (githubInstallationToken) {
|
||||
await revokeInstallationToken(githubInstallationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents that have matching API keys in the inputs
|
||||
*/
|
||||
function getAvailableAgents(inputs: Inputs): (typeof agents)[AgentNameType][] {
|
||||
return Object.values(agents).filter((agent) =>
|
||||
agent.apiKeyNames.some((inputKey) => inputs[inputKey])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all possible API key names from agentsManifest using flatMorph
|
||||
*/
|
||||
function getAllPossibleKeyNames(): string[] {
|
||||
return Object.keys(
|
||||
flatMorph(agentsManifest, (_, manifest) =>
|
||||
manifest.apiKeyNames.map((keyName) => [keyName, true] as const)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error for missing API key with helpful message linking to repo settings
|
||||
*/
|
||||
function throwMissingApiKeyError({
|
||||
agentName,
|
||||
inputKeys,
|
||||
repoContext,
|
||||
}: {
|
||||
agentName: string | null;
|
||||
inputKeys: string[];
|
||||
repoContext: RepoContext;
|
||||
}): never {
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
|
||||
const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`;
|
||||
|
||||
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
|
||||
const secretNameList =
|
||||
inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
||||
|
||||
const githubRepoUrl = `https://github.com/${repoContext.owner}/${repoContext.name}`;
|
||||
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
||||
|
||||
let message = `${
|
||||
agentName === null
|
||||
? "Pullfrog has no agent configured and no API keys are available in the environment."
|
||||
: `Pullfrog is configured to use ${agentName}, but the associated API key was not provided.`
|
||||
}
|
||||
|
||||
To fix this, add the required secret to your GitHub repository:
|
||||
|
||||
1. Go to: ${githubSecretsUrl}
|
||||
2. Click "New repository secret"
|
||||
3. Set the name to ${secretNameList}
|
||||
4. Set the value to your API key
|
||||
5. Click "Add secret"`;
|
||||
|
||||
if (agentName === null) {
|
||||
message += `\n\nAlternatively, configure Pullfrog to use an agent at ${settingsUrl}`;
|
||||
}
|
||||
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
interface MainContext {
|
||||
inputs: Inputs;
|
||||
githubInstallationToken: string;
|
||||
repoContext: RepoContext;
|
||||
agentName: AgentNameType;
|
||||
agent: (typeof agents)[AgentNameType];
|
||||
sharedTempDir: string;
|
||||
mcpLogPath: string;
|
||||
pollInterval: NodeJS.Timeout | null;
|
||||
payload: Payload;
|
||||
mcpServerUrl: string;
|
||||
mcpServerClose: () => Promise<void>;
|
||||
mcpServers: ReturnType<typeof createMcpConfigs>;
|
||||
cliPath: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
async function initializeContext(
|
||||
inputs: Inputs,
|
||||
payload: Payload
|
||||
): Promise<
|
||||
Omit<MainContext, "mcpServerUrl" | "mcpServerClose" | "mcpServers" | "cliPath" | "apiKey">
|
||||
> {
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
Inputs.assert(inputs);
|
||||
setupGitConfig();
|
||||
|
||||
const githubInstallationToken = await setupGitHubInstallationToken();
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
// resolve agent and update payload with resolved agent name
|
||||
const { agentName, agent } = await resolveAgent(
|
||||
inputs,
|
||||
payload,
|
||||
githubInstallationToken,
|
||||
repoContext
|
||||
);
|
||||
const resolvedPayload = { ...payload, agent: agentName };
|
||||
|
||||
return {
|
||||
inputs,
|
||||
githubInstallationToken,
|
||||
repoContext,
|
||||
agentName,
|
||||
agent,
|
||||
payload: resolvedPayload,
|
||||
sharedTempDir: "",
|
||||
mcpLogPath: "",
|
||||
pollInterval: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAgent(
|
||||
inputs: Inputs,
|
||||
payload: Payload,
|
||||
githubInstallationToken: string,
|
||||
repoContext: RepoContext
|
||||
): Promise<{ agentName: AgentNameType; agent: (typeof agents)[AgentNameType] }> {
|
||||
const repoSettings = await fetchRepoSettings({
|
||||
token: githubInstallationToken,
|
||||
repoContext,
|
||||
});
|
||||
|
||||
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
||||
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
||||
|
||||
if (configuredAgentName) {
|
||||
const agentName = configuredAgentName;
|
||||
const agent = agents[agentName];
|
||||
if (!agent) {
|
||||
throw new Error(`invalid agent name: ${agentName}`);
|
||||
}
|
||||
log.info(`Selected configured agent: ${agentName}`);
|
||||
return { agentName, agent };
|
||||
}
|
||||
|
||||
const availableAgents = getAvailableAgents(inputs);
|
||||
const availableAgentNames = availableAgents.map((agent) => agent.name).join(", ");
|
||||
log.debug(`Available agents: ${availableAgentNames || "none"}`);
|
||||
|
||||
if (availableAgents.length === 0) {
|
||||
throwMissingApiKeyError({
|
||||
agentName: configuredAgentName,
|
||||
inputKeys: getAllPossibleKeyNames(),
|
||||
repoContext,
|
||||
});
|
||||
}
|
||||
|
||||
const agentName = availableAgents[0].name;
|
||||
const agent = availableAgents[0];
|
||||
log.info(`No agent configured, defaulting to first available agent: ${agentName}`);
|
||||
return { agentName, agent };
|
||||
}
|
||||
|
||||
async function setupTempDirectory(
|
||||
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
|
||||
): Promise<void> {
|
||||
ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
|
||||
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir;
|
||||
ctx.mcpLogPath = join(ctx.sharedTempDir, "mcpLog.txt");
|
||||
await writeFile(ctx.mcpLogPath, "", "utf-8");
|
||||
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
|
||||
}
|
||||
|
||||
function setupMcpLogPolling(ctx: MainContext): void {
|
||||
let lastSize = 0;
|
||||
ctx.pollInterval = setInterval(() => {
|
||||
if (existsSync(ctx.mcpLogPath)) {
|
||||
const content = readFileSync(ctx.mcpLogPath, "utf-8");
|
||||
if (content.length > lastSize) {
|
||||
const newContent = content.slice(lastSize);
|
||||
process.stdout.write(newContent);
|
||||
lastSize = content.length;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function parsePayload(inputs: Inputs): Payload {
|
||||
try {
|
||||
const parsedPrompt = JSON.parse(inputs.prompt);
|
||||
if (!("~pullfrog" in parsedPrompt)) {
|
||||
throw new Error();
|
||||
}
|
||||
return parsedPrompt as Payload;
|
||||
} catch {
|
||||
return {
|
||||
"~pullfrog": true,
|
||||
agent: null,
|
||||
prompt: inputs.prompt,
|
||||
event: {
|
||||
trigger: "unknown",
|
||||
},
|
||||
modes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function startMcpServer(ctx: MainContext): Promise<void> {
|
||||
// Set environment variables for MCP server tools
|
||||
const repoContext = parseRepoContext();
|
||||
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
|
||||
const allModes = [...modes, ...(ctx.payload.modes || [])];
|
||||
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = ctx.githubInstallationToken;
|
||||
process.env.GITHUB_REPOSITORY = githubRepository;
|
||||
process.env.PULLFROG_MODES = JSON.stringify(allModes);
|
||||
process.env.PULLFROG_PAYLOAD = JSON.stringify(ctx.payload);
|
||||
|
||||
// GITHUB_RUN_ID is already set in GitHub Actions, no need to set it here
|
||||
|
||||
const { url, close } = await startMcpHttpServer();
|
||||
ctx.mcpServerUrl = url;
|
||||
ctx.mcpServerClose = close;
|
||||
log.info(`🚀 MCP server started at ${url}`);
|
||||
}
|
||||
|
||||
function setupMcpServers(ctx: MainContext): void {
|
||||
ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl);
|
||||
log.debug(`📋 MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
|
||||
}
|
||||
|
||||
async function installAgentCli(ctx: MainContext): Promise<void> {
|
||||
ctx.cliPath = await ctx.agent.install();
|
||||
}
|
||||
|
||||
function validateApiKey(ctx: MainContext): void {
|
||||
const matchingInputKey = ctx.agent.apiKeyNames.find((inputKey) => ctx.inputs[inputKey]);
|
||||
if (!matchingInputKey) {
|
||||
throwMissingApiKeyError({
|
||||
agentName: ctx.agentName,
|
||||
inputKeys: ctx.agent.apiKeyNames,
|
||||
repoContext: ctx.repoContext,
|
||||
});
|
||||
}
|
||||
ctx.apiKey = ctx.inputs[matchingInputKey]!;
|
||||
}
|
||||
|
||||
async function runAgent(ctx: MainContext): Promise<AgentResult> {
|
||||
log.info(`Running ${ctx.agentName}...`);
|
||||
// strip context from event
|
||||
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
||||
// format: prompt + two newlines + TOON encoded event
|
||||
const promptContent = `${ctx.payload.prompt}\n\n${toonEncode(eventWithoutContext)}`;
|
||||
log.box(promptContent, { title: "Prompt" });
|
||||
|
||||
return ctx.agent.run({
|
||||
payload: ctx.payload,
|
||||
mcpServers: ctx.mcpServers,
|
||||
githubInstallationToken: ctx.githubInstallationToken,
|
||||
apiKey: ctx.apiKey,
|
||||
cliPath: ctx.cliPath,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAgentResult(result: AgentResult): Promise<MainResult> {
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || "Agent execution failed",
|
||||
output: result.output!,
|
||||
};
|
||||
}
|
||||
|
||||
log.success("Task complete.");
|
||||
await log.writeSummary();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result.output || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# gh_pullfrog MCP Tools
|
||||
|
||||
this directory contains the mcp (model context protocol) server tools for interacting with github.
|
||||
|
||||
## available tools
|
||||
|
||||
### check suite tools
|
||||
|
||||
#### `get_check_suite_logs`
|
||||
get workflow run logs for a failed check suite.
|
||||
|
||||
**parameters:**
|
||||
- `check_suite_id` (number): the id from check_suite.id in the webhook payload
|
||||
|
||||
**replaces:** `gh run list` and `gh run view --log`
|
||||
|
||||
**returns:**
|
||||
all logs from all failed workflow runs in the check suite, including:
|
||||
- workflow run details (id, name, html_url, conclusion)
|
||||
- job details for each workflow run (id, name, status, conclusion, logs)
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
// when handling a check_suite_completed webhook
|
||||
await mcp.call("gh_pullfrog/get_check_suite_logs", {
|
||||
check_suite_id: check_suite.id
|
||||
});
|
||||
```
|
||||
|
||||
### review tools
|
||||
|
||||
#### `get_review_comments`
|
||||
get all line-by-line comments for a specific pull request review.
|
||||
|
||||
**parameters:**
|
||||
- `pull_number` (number): the pull request number
|
||||
- `review_id` (number): the id from review.id in the webhook payload
|
||||
|
||||
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments`
|
||||
|
||||
**returns:**
|
||||
array of review comments including:
|
||||
- file path, line number, comment body
|
||||
- side (LEFT/RIGHT) and position in diff
|
||||
- user, timestamps, html_url
|
||||
- in_reply_to_id for threaded comments
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
// when handling a pull_request_review_submitted webhook
|
||||
await mcp.call("gh_pullfrog/get_review_comments", {
|
||||
pull_number: 47,
|
||||
review_id: review.id
|
||||
});
|
||||
```
|
||||
|
||||
#### `list_pull_request_reviews`
|
||||
list all reviews for a pull request.
|
||||
|
||||
**parameters:**
|
||||
- `pull_number` (number): the pull request number
|
||||
|
||||
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews`
|
||||
|
||||
**returns:**
|
||||
array of reviews with:
|
||||
- review id, body, state (approved/changes_requested/commented)
|
||||
- user, commit_id, submitted_at, html_url
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
await mcp.call("gh_pullfrog/list_pull_request_reviews", {
|
||||
pull_number: 47
|
||||
});
|
||||
```
|
||||
|
||||
### other tools
|
||||
|
||||
see individual files for documentation on other tools:
|
||||
- `comment.ts` - create, edit, and update comments
|
||||
- `issue.ts` - create issues
|
||||
- `pr.ts` - create pull requests
|
||||
- `prInfo.ts` - get pull request information
|
||||
- `review.ts` - create pull request reviews
|
||||
- `selectMode.ts` - select execution mode
|
||||
|
||||
## usage in agents
|
||||
|
||||
agents should never use the `gh` cli. instead, they should use the mcp tools provided by this server.
|
||||
|
||||
the agent instructions automatically include guidance on using these tools.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { configure } from "arktype/config";
|
||||
|
||||
configure({
|
||||
toJsonSchema: {
|
||||
dialect: null,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const GetCheckSuiteLogs = type({
|
||||
check_suite_id: type.number.describe("the id from check_suite.id"),
|
||||
});
|
||||
|
||||
export const GetCheckSuiteLogsTool = tool({
|
||||
name: "get_check_suite_logs",
|
||||
description:
|
||||
"get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
|
||||
parameters: GetCheckSuiteLogs,
|
||||
execute: contextualize(async ({ check_suite_id }, ctx) => {
|
||||
// get workflow runs for this specific check suite
|
||||
const workflowRuns = await ctx.octokit.paginate(
|
||||
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
|
||||
{
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
check_suite_id,
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const failedRuns = workflowRuns.filter((run) => run.conclusion === "failure");
|
||||
|
||||
if (failedRuns.length === 0) {
|
||||
return {
|
||||
check_suite_id,
|
||||
message: "no failed workflow runs found for this check suite",
|
||||
workflow_runs: [],
|
||||
};
|
||||
}
|
||||
|
||||
// get logs for each failed run
|
||||
const logsForRuns = await Promise.all(
|
||||
failedRuns.map(async (run) => {
|
||||
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
run_id: run.id,
|
||||
});
|
||||
|
||||
const jobLogs = await Promise.all(
|
||||
jobs.map(async (job) => {
|
||||
try {
|
||||
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
job_id: job.id,
|
||||
});
|
||||
|
||||
const logsUrl = logsResponse.url;
|
||||
const logsText = await fetch(logsUrl).then((r) => r.text());
|
||||
|
||||
return {
|
||||
job_id: job.id,
|
||||
job_name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
started_at: job.started_at,
|
||||
completed_at: job.completed_at,
|
||||
logs: logsText,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
job_id: job.id,
|
||||
job_name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
started_at: job.started_at,
|
||||
completed_at: job.completed_at,
|
||||
error: `failed to fetch logs: ${error}`,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
workflow_run_id: run.id,
|
||||
workflow_name: run.name,
|
||||
html_url: run.html_url,
|
||||
conclusion: run.conclusion,
|
||||
jobs: jobLogs,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
check_suite_id,
|
||||
workflow_runs: logsForRuns,
|
||||
};
|
||||
}),
|
||||
});
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import { type } from "arktype";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { agentsManifest } from "../external.ts";
|
||||
import { parseRepoContext } from "../utils/github.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||
|
||||
function buildCommentFooter(payload: Payload): string {
|
||||
const repoContext = parseRepoContext();
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
|
||||
const agentName = payload.agent;
|
||||
const agentInfo = agentName ? agentsManifest[agentName] : null;
|
||||
const agentDisplayName = agentInfo?.displayName || "Unknown Agent";
|
||||
const agentUrl = agentInfo?.url || "https://pullfrog.ai";
|
||||
|
||||
// build workflow run URL: https://github.com/{owner}/{repo}/actions/runs/{runId}
|
||||
const workflowRunUrl = runId
|
||||
? `https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId}`
|
||||
: `https://github.com/${repoContext.owner}/${repoContext.name}`;
|
||||
|
||||
return `
|
||||
${PULLFROG_DIVIDER}
|
||||
---
|
||||
|
||||
<sup>🐸 Triggered by [Pullfrog](https://pullfrog.ai) | 🤖 [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [𝕏](https://x.com/pullfrogai)</sup>`;
|
||||
}
|
||||
|
||||
function stripExistingFooter(body: string): string {
|
||||
const dividerIndex = body.indexOf(PULLFROG_DIVIDER);
|
||||
if (dividerIndex === -1) {
|
||||
return body;
|
||||
}
|
||||
return body.substring(0, dividerIndex).trimEnd();
|
||||
}
|
||||
|
||||
function addFooter(body: string, payload: Payload): string {
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = buildCommentFooter(payload);
|
||||
return `${bodyWithoutFooter}${footer}`;
|
||||
}
|
||||
|
||||
export const Comment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
body: type.string.describe("the comment body content"),
|
||||
});
|
||||
|
||||
export const CreateCommentTool = tool({
|
||||
name: "create_issue_comment",
|
||||
description: "Create a comment on a GitHub issue",
|
||||
parameters: Comment,
|
||||
execute: contextualize(async ({ issueNumber, body }, ctx) => {
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
issue_number: issueNumber,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const EditComment = type({
|
||||
commentId: type.number.describe("the ID of the comment to edit"),
|
||||
body: type.string.describe("the new comment body content"),
|
||||
});
|
||||
|
||||
export const EditCommentTool = tool({
|
||||
name: "edit_issue_comment",
|
||||
description: "Edit a GitHub issue comment by its ID",
|
||||
parameters: EditComment,
|
||||
execute: contextualize(async ({ commentId, body }, ctx) => {
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
comment_id: commentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
updatedAt: result.data.updated_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
let workingCommentId: number | null = null;
|
||||
|
||||
export const WorkingComment = type({
|
||||
issueNumber: type.number.describe("the issue number to comment on"),
|
||||
intent: type("/^I'll .+$/").describe(
|
||||
"the body of the initial comment expressing your intent to handle the request. must have the form 'I'll {summary of request}'"
|
||||
),
|
||||
});
|
||||
|
||||
export const CreateWorkingCommentTool = tool({
|
||||
name: "create_working_comment",
|
||||
description:
|
||||
"Create an initial comment on a GitHub issue that will be updated as work progresses",
|
||||
parameters: WorkingComment,
|
||||
execute: contextualize(async ({ issueNumber, intent }, ctx) => {
|
||||
if (workingCommentId) {
|
||||
throw new Error("create_working_comment may not be called multiple times");
|
||||
}
|
||||
|
||||
const body = `${intent} <img src="https://pullfrog.ai/party-parrot.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`;
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
issue_number: issueNumber,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
workingCommentId = result.data.id;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkingCommentUpdate = type({
|
||||
body: type.string.describe("the new comment body content"),
|
||||
});
|
||||
|
||||
export const UpdateWorkingCommentTool = tool({
|
||||
name: "update_working_comment",
|
||||
description: "Update a working comment on a GitHub issue",
|
||||
parameters: WorkingCommentUpdate,
|
||||
execute: contextualize(async ({ body }, ctx) => {
|
||||
if (!workingCommentId) {
|
||||
throw new Error("create_working_comment must be called before update_working_comment");
|
||||
}
|
||||
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
comment_id: workingCommentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body,
|
||||
};
|
||||
}),
|
||||
});
|
||||
+10
-26
@@ -1,35 +1,19 @@
|
||||
/**
|
||||
* Simple MCP configuration helper for adding our minimal GitHub comment server
|
||||
*/
|
||||
// const actionPath = process.env.GITHUB_ACTION_PATH || process.cwd();
|
||||
|
||||
import { fromHere } from "@ark/fs";
|
||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
|
||||
const actionPath = fromHere("..");
|
||||
export type McpName = typeof ghPullfrogMcpName;
|
||||
|
||||
export function createMcpConfig(githubInstallationToken: string) {
|
||||
const githubRepository = process.env.GITHUB_REPOSITORY;
|
||||
if (!githubRepository) {
|
||||
throw new Error(
|
||||
"GITHUB_REPOSITORY environment variable is required for MCP GitHub integration"
|
||||
);
|
||||
}
|
||||
export type McpConfigs = Record<McpName, McpHttpServerConfig>;
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
minimal_github_comment: {
|
||||
command: "node",
|
||||
args: [`${actionPath}/mcp/server.ts`],
|
||||
env: {
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
GITHUB_REPOSITORY: githubRepository,
|
||||
LOG_LEVEL: "debug",
|
||||
},
|
||||
},
|
||||
},
|
||||
export function createMcpConfigs(mcpServerUrl: string): McpConfigs {
|
||||
return {
|
||||
[ghPullfrogMcpName]: {
|
||||
type: "http",
|
||||
url: mcpServerUrl,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type } from "arktype";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolResult } from "./shared.ts";
|
||||
import { tool } from "./shared.ts";
|
||||
|
||||
export const DebugShellCommand = type({});
|
||||
|
||||
export const DebugShellCommandTool = tool({
|
||||
name: "debug_shell_command",
|
||||
description:
|
||||
"debug tool: runs 'git status' and returns the output. use this to test shell command execution in the MCP server.",
|
||||
parameters: DebugShellCommand,
|
||||
execute: async (): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = $("git", ["status"]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
command: "git status",
|
||||
output: result.trim(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type } from "arktype";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const ListFiles = type({
|
||||
path: type.string
|
||||
.describe("The path to list files from (defaults to current directory)")
|
||||
.default("."),
|
||||
});
|
||||
|
||||
export const ListFilesTool = tool({
|
||||
name: "list_files",
|
||||
description:
|
||||
"List files in the repository using git ls-files. Useful for discovering the file structure and locating files.",
|
||||
parameters: ListFiles,
|
||||
execute: contextualize(async ({ path }) => {
|
||||
try {
|
||||
// Use git ls-files to list tracked files
|
||||
// This respects .gitignore and gives a clean list of source files
|
||||
const pathStr = path ?? ".";
|
||||
const output = $("git", pathStr === "." ? ["ls-files"] : ["ls-files", pathStr], {
|
||||
log: false,
|
||||
});
|
||||
const files = output.split("\n").filter((f) => f.trim() !== "");
|
||||
|
||||
if (files.length === 0) {
|
||||
// Fallback for non-git environments or untracked files
|
||||
const findOutput = $(
|
||||
"find",
|
||||
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
|
||||
{ log: false }
|
||||
);
|
||||
return {
|
||||
files: findOutput.split("\n").filter((f) => f.trim() !== ""),
|
||||
method: "find",
|
||||
};
|
||||
}
|
||||
|
||||
return { files, method: "git" };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
error: `Failed to list files: ${errorMessage}`,
|
||||
hint: "Try using a specific path if the repository root is not the current directory.",
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// re-export from external.ts for backward compatibility
|
||||
export { ghPullfrogMcpName } from "../external.ts";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Issue = type({
|
||||
title: type.string.describe("the title of the issue"),
|
||||
body: type.string.describe("the body content of the issue"),
|
||||
labels: type.string
|
||||
.array()
|
||||
.describe("optional array of label names to apply to the issue")
|
||||
.optional(),
|
||||
assignees: type.string
|
||||
.array()
|
||||
.describe("optional array of usernames to assign to the issue")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const IssueTool = tool({
|
||||
name: "create_issue",
|
||||
description: "Create a new GitHub issue",
|
||||
parameters: Issue,
|
||||
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => {
|
||||
const result = await ctx.octokit.rest.issues.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
title: title,
|
||||
body: body,
|
||||
labels: labels ?? [],
|
||||
assignees: assignees ?? [],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issueId: result.data.id,
|
||||
number: result.data.number,
|
||||
url: result.data.html_url,
|
||||
title: result.data.title,
|
||||
state: result.data.state,
|
||||
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
|
||||
assignees: result.data.assignees?.map((assignee) => assignee.login),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequest = type({
|
||||
title: type.string.describe("the title of the pull request"),
|
||||
body: type.string.describe("the body content of the pull request"),
|
||||
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
||||
});
|
||||
|
||||
export const PullRequestTool = tool({
|
||||
name: "create_pull_request",
|
||||
description: "Create a pull request from the current branch",
|
||||
parameters: PullRequest,
|
||||
execute: contextualize(async ({ title, body, base }, ctx) => {
|
||||
// Get the current branch name
|
||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||
|
||||
log.info(`Current branch: ${currentBranch}`);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
title: title,
|
||||
body: body,
|
||||
head: currentBranch,
|
||||
base: base,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pullRequestId: result.data.id,
|
||||
number: result.data.number,
|
||||
url: result.data.html_url,
|
||||
title: result.data.title,
|
||||
head: result.data.head.ref,
|
||||
base: result.data.base.ref,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequestInfo = type({
|
||||
pull_number: type.number.describe("The pull request number to fetch"),
|
||||
});
|
||||
|
||||
export const PullRequestInfoTool = tool({
|
||||
name: "get_pull_request",
|
||||
description:
|
||||
"Retrieve PR information and automatically prepare the repository for review by fetching and checking out the PR branch.",
|
||||
parameters: PullRequestInfo,
|
||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const data = pr.data;
|
||||
|
||||
const baseBranch = data.base.ref;
|
||||
const headBranch = data.head.ref;
|
||||
|
||||
if (!baseBranch) {
|
||||
throw new Error(`Base branch not found for PR #${pull_number}`);
|
||||
}
|
||||
|
||||
// Automatically fetch and checkout branches for review
|
||||
log.info(`Fetching base branch: origin/${baseBranch}`);
|
||||
$("git", ["fetch", "origin", baseBranch, "--depth=20"]);
|
||||
|
||||
log.info(`Fetching PR branch: origin/${headBranch}`);
|
||||
$("git", ["fetch", "origin", headBranch]);
|
||||
|
||||
log.info(`Checking out PR branch: origin/${headBranch}`);
|
||||
// check out a local branch tracking the remote branch so we can push changes
|
||||
$("git", ["checkout", "-B", headBranch, `origin/${headBranch}`]);
|
||||
|
||||
return {
|
||||
number: data.number,
|
||||
url: data.html_url,
|
||||
title: data.title,
|
||||
state: data.state,
|
||||
draft: data.draft,
|
||||
merged: data.merged,
|
||||
base: baseBranch,
|
||||
head: headBranch,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const Review = type({
|
||||
pull_number: type.number.describe("The pull request number to review"),
|
||||
body: type.string
|
||||
.describe(
|
||||
"Brief summary or general feedback that doesn't apply to specific code locations. Keep it concise - most feedback should be in the 'comments' array."
|
||||
)
|
||||
.optional(),
|
||||
commit_id: type.string
|
||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||
.optional(),
|
||||
comments: type({
|
||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
||||
line: type.number.describe(
|
||||
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
|
||||
),
|
||||
side: type
|
||||
.enumerated("LEFT", "RIGHT")
|
||||
.describe(
|
||||
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
|
||||
)
|
||||
.optional(),
|
||||
body: type.string.describe("The comment text for this specific line"),
|
||||
start_line: type.number
|
||||
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
||||
.optional(),
|
||||
})
|
||||
.array()
|
||||
.describe(
|
||||
"REQUIRED: Array of inline comments for specific code issues. Use this for all location-specific feedback. Use 'git diff origin/<base>...origin/<head>' to find the correct line numbers (typically use the line numbers shown on the RIGHT side for new code, LEFT side for old code)."
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ReviewTool = tool({
|
||||
name: "submit_pull_request_review",
|
||||
description:
|
||||
"Submit a review (approve, request changes, or comment) for an existing pull request. " +
|
||||
"IMPORTANT: Use 'comments' array for ALL specific code issues at the line-level. " +
|
||||
"Only use 'body' for a brief summary or feedback that doesn't apply to a specific location.",
|
||||
parameters: Review,
|
||||
execute: contextualize(async ({ pull_number, body, commit_id, comments = [] }, ctx) => {
|
||||
// Get the PR to determine the head commit if commit_id not provided
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
// Compose the request
|
||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
event: "COMMENT",
|
||||
};
|
||||
if (body) params.body = body;
|
||||
if (commit_id) {
|
||||
params.commit_id = commit_id;
|
||||
} else {
|
||||
params.commit_id = pr.data.head.sha;
|
||||
}
|
||||
if (comments.length > 0) {
|
||||
type ReviewComment = (typeof params.comments & {})[number];
|
||||
// Convert comments to the format expected by GitHub API
|
||||
params.comments = comments.map((comment) => {
|
||||
const reviewComment: ReviewComment = {
|
||||
...comment,
|
||||
};
|
||||
reviewComment.side = comment.side || "RIGHT";
|
||||
if (comment.start_line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.start_side = comment.side || "RIGHT";
|
||||
}
|
||||
return reviewComment;
|
||||
});
|
||||
}
|
||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
||||
return {
|
||||
success: true,
|
||||
reviewId: result.data.id,
|
||||
html_url: result.data.html_url,
|
||||
state: result.data.state,
|
||||
user: result.data.user?.login,
|
||||
submitted_at: result.data.submitted_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
export const GetReviewComments = type({
|
||||
pull_number: type.number.describe("The pull request number"),
|
||||
review_id: type.number.describe("The review ID to get comments for"),
|
||||
});
|
||||
|
||||
export const GetReviewCommentsTool = tool({
|
||||
name: "get_review_comments",
|
||||
description:
|
||||
"Get all review comments for a specific pull request review. Returns line-by-line comments that were left on specific code locations.",
|
||||
parameters: GetReviewComments,
|
||||
execute: contextualize(async ({ pull_number, review_id }, ctx) => {
|
||||
const comments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listCommentsForReview, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
review_id,
|
||||
});
|
||||
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
comments: comments.map((comment) => ({
|
||||
id: comment.id,
|
||||
body: comment.body,
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
side: comment.side,
|
||||
start_line: comment.start_line,
|
||||
start_side: comment.start_side,
|
||||
user: typeof comment.user === "string" ? comment.user : comment.user?.login,
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
html_url: comment.html_url,
|
||||
in_reply_to_id: comment.in_reply_to_id,
|
||||
diff_hunk: comment.diff_hunk,
|
||||
reactions: comment.reactions,
|
||||
})),
|
||||
count: comments.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const ListPullRequestReviews = type({
|
||||
pull_number: type.number.describe("The pull request number to list reviews for"),
|
||||
});
|
||||
|
||||
export const ListPullRequestReviewsTool = tool({
|
||||
name: "list_pull_request_reviews",
|
||||
description:
|
||||
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
||||
parameters: ListPullRequestReviews,
|
||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
return {
|
||||
pull_number,
|
||||
reviews: reviews.map((review) => ({
|
||||
id: review.id,
|
||||
body: review.body,
|
||||
state: review.state,
|
||||
user: review.user?.login,
|
||||
commit_id: review.commit_id,
|
||||
submitted_at: review.submitted_at,
|
||||
html_url: review.html_url,
|
||||
})),
|
||||
count: reviews.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type } from "arktype";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
|
||||
// Get modes from environment variable (set by createMcpConfigs)
|
||||
function getModes(): Mode[] {
|
||||
const modesJson = process.env.PULLFROG_MODES;
|
||||
if (modesJson) {
|
||||
try {
|
||||
return JSON.parse(modesJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const SelectMode = type({
|
||||
modeName: type.string.describe(
|
||||
"the name of the mode to select (e.g., 'Plan', 'Build', 'Review', 'Prompt')"
|
||||
),
|
||||
});
|
||||
|
||||
export const SelectModeTool = tool({
|
||||
name: "select_mode",
|
||||
description:
|
||||
"Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.",
|
||||
parameters: SelectMode,
|
||||
execute: contextualize(async ({ modeName }) => {
|
||||
const allModes = getModes();
|
||||
|
||||
if (allModes.length === 0) {
|
||||
return {
|
||||
error:
|
||||
"No modes available. Modes must be provided via PULLFROG_MODES environment variable.",
|
||||
};
|
||||
}
|
||||
|
||||
const selectedMode = allModes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
|
||||
|
||||
if (!selectedMode) {
|
||||
const availableModes = allModes.map((m) => m.name).join(", ");
|
||||
return {
|
||||
error: `Mode "${modeName}" not found. Available modes: ${availableModes}`,
|
||||
availableModes: allModes.map((m) => ({ name: m.name, description: m.description })),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
modeName: selectedMode.name,
|
||||
description: selectedMode.description,
|
||||
prompt: selectedMode.prompt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
+89
-84
@@ -1,92 +1,97 @@
|
||||
#!/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";
|
||||
import "./arkConfig.ts";
|
||||
// this must be imported first
|
||||
import { createServer } from "node:net";
|
||||
import { FastMCP } from "fastmcp";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
import {
|
||||
CreateCommentTool,
|
||||
CreateWorkingCommentTool,
|
||||
EditCommentTool,
|
||||
UpdateWorkingCommentTool,
|
||||
} from "./comment.ts";
|
||||
import { DebugShellCommandTool } from "./debug.ts";
|
||||
import { IssueTool } from "./issue.ts";
|
||||
import { PullRequestTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import { ReviewTool } from "./review.ts";
|
||||
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
||||
import { SelectModeTool } from "./selectMode.ts";
|
||||
import { addTools } from "./shared.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,
|
||||
/**
|
||||
* Find an available port starting from the given port
|
||||
*/
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
const checkPort = (port: number): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => {
|
||||
server.close();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const result = await octokit.rest.issues.createComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
issue_number: issueNumber,
|
||||
body: body,
|
||||
server.listen(port, () => {
|
||||
server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
let port = startPort;
|
||||
while (port < startPort + 100) {
|
||||
if (await checkPort(port)) {
|
||||
return port;
|
||||
}
|
||||
port++;
|
||||
}
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
throw new Error(`Could not find available port starting from ${startPort}`);
|
||||
}
|
||||
|
||||
await runServer();
|
||||
/**
|
||||
* Start the MCP HTTP server and return the URL and close function
|
||||
*/
|
||||
export async function startMcpHttpServer(): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server = new FastMCP({
|
||||
name: ghPullfrogMcpName,
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
addTools(server, [
|
||||
SelectModeTool,
|
||||
CreateCommentTool,
|
||||
EditCommentTool,
|
||||
CreateWorkingCommentTool,
|
||||
UpdateWorkingCommentTool,
|
||||
IssueTool,
|
||||
PullRequestTool,
|
||||
ReviewTool,
|
||||
PullRequestInfoTool,
|
||||
GetReviewCommentsTool,
|
||||
ListPullRequestReviewsTool,
|
||||
GetCheckSuiteLogsTool,
|
||||
DebugShellCommandTool,
|
||||
]);
|
||||
|
||||
const port = await findAvailablePort(3764);
|
||||
const host = "127.0.0.1";
|
||||
const endpoint = "/mcp";
|
||||
|
||||
await server.start({
|
||||
transportType: "httpStream",
|
||||
httpStream: {
|
||||
port,
|
||||
host,
|
||||
endpoint,
|
||||
},
|
||||
});
|
||||
|
||||
const url = `http://${host}:${port}${endpoint}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
close: async () => {
|
||||
await server.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
import type { FastMCP, Tool } from "fastmcp";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
|
||||
|
||||
export interface ToolResult {
|
||||
content: {
|
||||
type: "text";
|
||||
text: string;
|
||||
}[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export function getPayload(): Payload {
|
||||
const payloadEnv = process.env.PULLFROG_PAYLOAD;
|
||||
if (!payloadEnv) {
|
||||
throw new Error("PULLFROG_PAYLOAD environment variable is required");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(payloadEnv) as Payload;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse PULLFROG_PAYLOAD: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpContext(): McpContext {
|
||||
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
|
||||
if (!githubInstallationToken) {
|
||||
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
return {
|
||||
...parseRepoContext(),
|
||||
octokit: new Octokit({
|
||||
auth: githubInstallationToken,
|
||||
}),
|
||||
payload: getPayload(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpContext extends RepoContext {
|
||||
octokit: Octokit;
|
||||
payload: Payload;
|
||||
}
|
||||
|
||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
||||
|
||||
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
for (const tool of tools) {
|
||||
server.addTool(tool);
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
export const contextualize =
|
||||
<T>(executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>) =>
|
||||
async (params: T): Promise<ToolResult> => {
|
||||
try {
|
||||
const ctx = getMcpContext();
|
||||
const result = await executor(params, ctx);
|
||||
return handleToolSuccess(result);
|
||||
} catch (error) {
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const handleToolError = (error: unknown): ToolResult => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ghPullfrogMcpName } from "./external.ts";
|
||||
|
||||
export interface Mode {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const initialCommentInstruction = `When the task is associated with an issue/PR, use ${ghPullfrogMcpName}/create_working_comment to create an initial Working Comment with a conversational description of what work you are about to perform.`;
|
||||
|
||||
export const modes: Mode[] = [
|
||||
{
|
||||
name: "Build",
|
||||
description:
|
||||
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
|
||||
prompt: `Follow these steps:
|
||||
1. ${initialCommentInstruction}
|
||||
|
||||
2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained.
|
||||
|
||||
3. Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production.
|
||||
|
||||
4. Understand the requirements and any existing plan
|
||||
|
||||
5. Make the necessary code changes. Create intermediate commits if called for.
|
||||
|
||||
6. Test your changes to ensure they work correctly
|
||||
|
||||
7. Update your comment using ${ghPullfrogMcpName}/update_working_comment to share progress and results.
|
||||
|
||||
8. Continue updating the same comment as you make progress. Never create additional comments. Always use update_working_comment.
|
||||
|
||||
9. When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). Create a PR with an informative title and body. If relevant, include links to the issue or comment that triggered the PR.
|
||||
|
||||
10. Update the Working Comment one final time with a summary of the results. Include links to any created issues/PRs, e.g. \`[View PR](https://github.com/org/repo/pull/123)\`
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Review",
|
||||
description:
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `Follow these steps:
|
||||
1. ${initialCommentInstruction}
|
||||
|
||||
2. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
|
||||
|
||||
3. View diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments, replace <base> and <head> with 'base' and 'head' from PR info)
|
||||
|
||||
4. Read files from the checked-out PR branch to understand the implementation
|
||||
|
||||
5. Update your comment using ${ghPullfrogMcpName}/update_working_comment with findings as you review
|
||||
|
||||
6. When submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
|
||||
|
||||
7. Only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
|
||||
|
||||
8. Continue updating the same comment as needed (never create additional comments - always use update_working_comment)`,
|
||||
},
|
||||
{
|
||||
name: "Plan",
|
||||
description:
|
||||
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
|
||||
prompt: `Follow these steps:
|
||||
1. ${initialCommentInstruction}
|
||||
|
||||
2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context (read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained.
|
||||
|
||||
3. Analyze the request and break it down into clear, actionable tasks
|
||||
|
||||
4. Consider dependencies, potential challenges, and implementation order
|
||||
|
||||
5. Create a structured plan with clear milestones
|
||||
|
||||
6. Update your comment using ${ghPullfrogMcpName}/update_working_comment to present the plan in a clear, organized format
|
||||
|
||||
7. Continue updating the same comment as needed (never create additional comments - always use update_working_comment)`,
|
||||
},
|
||||
{
|
||||
name: "Prompt",
|
||||
description:
|
||||
"Fallback for tasks that don't fit other workflows, direct prompts via comments, or requests requiring general assistance without a specific workflow pattern",
|
||||
prompt: `Follow these steps:
|
||||
1. ${initialCommentInstruction}
|
||||
|
||||
2. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.
|
||||
|
||||
3. If the task involves making code changes:
|
||||
- Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production.
|
||||
- Make the necessary code changes. Create intermediate commits if called for.
|
||||
- Test your changes to ensure they work correctly.
|
||||
- When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). Create a PR with an informative title and body. If relevant, include links to the issue or comment that triggered the PR.
|
||||
|
||||
4. As your work progresses, update your Working Comment to share progress and results using ${ghPullfrogMcpName}/update_working_comment. Do not create additional comments unless you are explicitly asked to do so.
|
||||
|
||||
5. When you finish the task, update the Working Comment a final time with a summary of the results and links to any created issues, PRs, etc.`,
|
||||
},
|
||||
];
|
||||
Generated
+960
@@ -0,0 +1,960 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.96",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.96",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.26",
|
||||
"@ark/fs": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"arktype": "2.1.25",
|
||||
"convex": "^1.29.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"execa": "^9.6.0",
|
||||
"fastmcp": "^3.20.0",
|
||||
"table": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core": {
|
||||
"version": "1.11.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/http-client": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.18.112"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.1.26_zod@3.25.76/node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.1.26",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "^0.33.5",
|
||||
"@img/sharp-darwin-x64": "^0.33.5",
|
||||
"@img/sharp-linux-arm": "^0.33.5",
|
||||
"@img/sharp-linux-arm64": "^0.33.5",
|
||||
"@img/sharp-linux-x64": "^0.33.5",
|
||||
"@img/sharp-win32-x64": "^0.33.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.1"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@ark+fs@0.53.0/node_modules/@ark/fs": {
|
||||
"version": "0.53.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"../node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util": {
|
||||
"version": "0.53.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"../node_modules/.pnpm/@octokit+rest@22.0.0/node_modules/@octokit/rest": {
|
||||
"version": "22.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/core": "^7.0.2",
|
||||
"@octokit/plugin-paginate-rest": "^13.0.1",
|
||||
"@octokit/plugin-request-log": "^6.0.0",
|
||||
"@octokit/plugin-rest-endpoint-methods": "^16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/auth-action": "^6.0.1",
|
||||
"@octokit/auth-app": "^8.0.1",
|
||||
"@octokit/fixtures-server": "^8.1.0",
|
||||
"@octokit/request": "^10.0.0",
|
||||
"@octokit/tsconfig": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"fetch-mock": "^12.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"nock": "^14.0.0-beta.8",
|
||||
"prettier": "^3.2.4",
|
||||
"semantic-release-plugin-update-version-in-files": "^2.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"undici": "^6.4.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@octokit+webhooks-types@7.6.1/node_modules/@octokit/webhooks-types": {
|
||||
"version": "7.6.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {}
|
||||
},
|
||||
"../node_modules/.pnpm/@standard-schema+spec@1.0.0/node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@types+node@24.7.2/node_modules/@types/node": {
|
||||
"version": "24.7.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/arg@5.0.2/node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"chai": "^4.1.1",
|
||||
"jest": "^27.0.6",
|
||||
"prettier": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/arktype@2.1.25/node_modules/arktype": {
|
||||
"version": "2.1.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ark/schema": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"arkregex": "0.0.2"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/dotenv@17.2.3/node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"license": "BSD-2-Clause",
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.11.3",
|
||||
"decache": "^4.6.2",
|
||||
"sinon": "^14.0.1",
|
||||
"standard": "^17.0.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"tap": "^19.2.0",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/esbuild@0.25.10/node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.10",
|
||||
"@esbuild/android-arm": "0.25.10",
|
||||
"@esbuild/android-arm64": "0.25.10",
|
||||
"@esbuild/android-x64": "0.25.10",
|
||||
"@esbuild/darwin-arm64": "0.25.10",
|
||||
"@esbuild/darwin-x64": "0.25.10",
|
||||
"@esbuild/freebsd-arm64": "0.25.10",
|
||||
"@esbuild/freebsd-x64": "0.25.10",
|
||||
"@esbuild/linux-arm": "0.25.10",
|
||||
"@esbuild/linux-arm64": "0.25.10",
|
||||
"@esbuild/linux-ia32": "0.25.10",
|
||||
"@esbuild/linux-loong64": "0.25.10",
|
||||
"@esbuild/linux-mips64el": "0.25.10",
|
||||
"@esbuild/linux-ppc64": "0.25.10",
|
||||
"@esbuild/linux-riscv64": "0.25.10",
|
||||
"@esbuild/linux-s390x": "0.25.10",
|
||||
"@esbuild/linux-x64": "0.25.10",
|
||||
"@esbuild/netbsd-arm64": "0.25.10",
|
||||
"@esbuild/netbsd-x64": "0.25.10",
|
||||
"@esbuild/openbsd-arm64": "0.25.10",
|
||||
"@esbuild/openbsd-x64": "0.25.10",
|
||||
"@esbuild/openharmony-arm64": "0.25.10",
|
||||
"@esbuild/sunos-x64": "0.25.10",
|
||||
"@esbuild/win32-arm64": "0.25.10",
|
||||
"@esbuild/win32-ia32": "0.25.10",
|
||||
"@esbuild/win32-x64": "0.25.10"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/execa@9.6.0/node_modules/execa": {
|
||||
"version": "9.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"figures": "^6.1.0",
|
||||
"get-stream": "^9.0.0",
|
||||
"human-signals": "^8.0.1",
|
||||
"is-plain-obj": "^4.1.0",
|
||||
"is-stream": "^4.0.1",
|
||||
"npm-run-path": "^6.0.0",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"strip-final-newline": "^4.0.0",
|
||||
"yoctocolors": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.21",
|
||||
"ava": "^6.3.0",
|
||||
"c8": "^10.1.3",
|
||||
"get-node": "^15.0.3",
|
||||
"is-in-ci": "^1.0.0",
|
||||
"is-running": "^2.1.0",
|
||||
"log-process-errors": "^12.0.1",
|
||||
"path-exists": "^5.0.0",
|
||||
"path-key": "^4.0.0",
|
||||
"tempfile": "^5.0.0",
|
||||
"tsd": "^0.32.0",
|
||||
"typescript": "^5.8.3",
|
||||
"which": "^5.0.0",
|
||||
"xo": "^0.60.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/fastmcp@3.20.0_arktype@2.1.25_effect@3.16.12/node_modules/fastmcp": {
|
||||
"version": "3.20.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.2",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"execa": "^9.6.0",
|
||||
"file-type": "^21.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"mcp-proxy": "^5.8.1",
|
||||
"strict-event-emitter-types": "^2.0.0",
|
||||
"undici": "^7.13.0",
|
||||
"uri-templates": "^0.2.0",
|
||||
"xsschema": "0.3.5",
|
||||
"yargs": "^18.0.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
},
|
||||
"bin": {
|
||||
"fastmcp": "dist/bin/fastmcp.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@modelcontextprotocol/inspector": "^0.16.2",
|
||||
"@sebbo2002/semantic-release-jsr": "^3.0.1",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/uri-templates": "^0.1.34",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@valibot/to-json-schema": "^1.3.0",
|
||||
"@wong2/mcp-cli": "^1.13.0",
|
||||
"arktype": "^2.1.20",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-perfectionist": "^4.15.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eventsource-client": "^1.1.4",
|
||||
"get-port-please": "^3.2.0",
|
||||
"jiti": "^2.5.1",
|
||||
"jsr": "^0.13.5",
|
||||
"prettier": "^3.6.2",
|
||||
"semantic-release": "^24.2.7",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.0",
|
||||
"valibot": "^1.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/husky@9.1.7/node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/table@6.9.0/node_modules/table": {
|
||||
"version": "6.9.0",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.1",
|
||||
"lodash.truncate": "^4.4.2",
|
||||
"slice-ansi": "^4.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.16",
|
||||
"@types/lodash.mapvalues": "^4.6.6",
|
||||
"@types/lodash.truncate": "^4.4.6",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/sinon": "^10.0.0",
|
||||
"@types/slice-ansi": "^4.0.0",
|
||||
"ajv-cli": "^5.0.0",
|
||||
"ajv-keywords": "^5.0.0",
|
||||
"chai": "^4.2.0",
|
||||
"chalk": "^4.1.0",
|
||||
"coveralls": "^3.1.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-canonical": "^25.0.0",
|
||||
"gitdown": "^3.1.4",
|
||||
"husky": "^4.3.6",
|
||||
"js-beautify": "^1.14.0",
|
||||
"lodash.mapvalues": "^4.6.0",
|
||||
"mkdirp": "^1.0.4",
|
||||
"mocha": "^8.2.1",
|
||||
"nyc": "^15.1.0",
|
||||
"semantic-release": "^17.3.1",
|
||||
"sinon": "^12.0.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "4.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dprint/formatter": "^0.4.1",
|
||||
"@dprint/typescript": "0.93.4",
|
||||
"@esfx/canceltoken": "^1.0.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@types/chai": "^4.3.20",
|
||||
"@types/diff": "^7.0.1",
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node": "latest",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/which": "^3.0.4",
|
||||
"@typescript-eslint/rule-tester": "^8.24.1",
|
||||
"@typescript-eslint/type-utils": "^8.24.1",
|
||||
"@typescript-eslint/utils": "^8.24.1",
|
||||
"azure-devops-node-api": "^14.1.0",
|
||||
"c8": "^10.1.3",
|
||||
"chai": "^4.5.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dprint": "^0.49.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-formatter-autolinkable-stylish": "^1.4.0",
|
||||
"eslint-plugin-regexp": "^2.7.0",
|
||||
"fast-xml-parser": "^4.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"globals": "^15.15.0",
|
||||
"hereby": "^1.10.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knip": "^5.44.4",
|
||||
"minimist": "^1.2.8",
|
||||
"mocha": "^10.8.2",
|
||||
"mocha-fivemat-progress-reporter": "^0.1.0",
|
||||
"monocart-coverage-reports": "^2.12.1",
|
||||
"ms": "^2.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"playwright": "^1.50.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"which": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"resolved": "../node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"resolved": "../node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.1.26_zod@3.25.76/node_modules/@anthropic-ai/claude-agent-sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ark/fs": {
|
||||
"resolved": "../node_modules/.pnpm/@ark+fs@0.53.0/node_modules/@ark/fs",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ark/util": {
|
||||
"resolved": "../node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
||||
"integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
|
||||
"integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
|
||||
"integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
|
||||
"integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
|
||||
"integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
|
||||
"integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
|
||||
"integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
|
||||
"integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
|
||||
"integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
|
||||
"integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest": {
|
||||
"resolved": "../node_modules/.pnpm/@octokit+rest@22.0.0/node_modules/@octokit/rest",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@octokit/webhooks-types": {
|
||||
"resolved": "../node_modules/.pnpm/@octokit+webhooks-types@7.6.1/node_modules/@octokit/webhooks-types",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"resolved": "../node_modules/.pnpm/@standard-schema+spec@1.0.0/node_modules/@standard-schema/spec",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"resolved": "../node_modules/.pnpm/@types+node@24.7.2/node_modules/@types/node",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"resolved": "../node_modules/.pnpm/arg@5.0.2/node_modules/arg",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/arktype": {
|
||||
"resolved": "../node_modules/.pnpm/arktype@2.1.25/node_modules/arktype",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/convex": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/convex/-/convex-1.29.0.tgz",
|
||||
"integrity": "sha512-uoIPXRKIp2eLCkkR9WJ2vc9NtgQtx8Pml59WPUahwbrd5EuW2WLI/cf2E7XrUzOSifdQC3kJZepisk4wJNTJaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esbuild": "0.25.4",
|
||||
"prettier": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"convex": "bin/main.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@auth0/auth0-react": "^2.0.1",
|
||||
"@clerk/clerk-react": "^4.12.8 || ^5.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@auth0/auth0-react": {
|
||||
"optional": true
|
||||
},
|
||||
"@clerk/clerk-react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/convex/node_modules/esbuild": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
||||
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.4",
|
||||
"@esbuild/android-arm": "0.25.4",
|
||||
"@esbuild/android-arm64": "0.25.4",
|
||||
"@esbuild/android-x64": "0.25.4",
|
||||
"@esbuild/darwin-arm64": "0.25.4",
|
||||
"@esbuild/darwin-x64": "0.25.4",
|
||||
"@esbuild/freebsd-arm64": "0.25.4",
|
||||
"@esbuild/freebsd-x64": "0.25.4",
|
||||
"@esbuild/linux-arm": "0.25.4",
|
||||
"@esbuild/linux-arm64": "0.25.4",
|
||||
"@esbuild/linux-ia32": "0.25.4",
|
||||
"@esbuild/linux-loong64": "0.25.4",
|
||||
"@esbuild/linux-mips64el": "0.25.4",
|
||||
"@esbuild/linux-ppc64": "0.25.4",
|
||||
"@esbuild/linux-riscv64": "0.25.4",
|
||||
"@esbuild/linux-s390x": "0.25.4",
|
||||
"@esbuild/linux-x64": "0.25.4",
|
||||
"@esbuild/netbsd-arm64": "0.25.4",
|
||||
"@esbuild/netbsd-x64": "0.25.4",
|
||||
"@esbuild/openbsd-arm64": "0.25.4",
|
||||
"@esbuild/openbsd-x64": "0.25.4",
|
||||
"@esbuild/sunos-x64": "0.25.4",
|
||||
"@esbuild/win32-arm64": "0.25.4",
|
||||
"@esbuild/win32-ia32": "0.25.4",
|
||||
"@esbuild/win32-x64": "0.25.4"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"resolved": "../node_modules/.pnpm/dotenv@17.2.3/node_modules/dotenv",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"resolved": "../node_modules/.pnpm/esbuild@0.25.10/node_modules/esbuild",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"resolved": "../node_modules/.pnpm/execa@9.6.0/node_modules/execa",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/fastmcp": {
|
||||
"resolved": "../node_modules/.pnpm/fastmcp@3.20.0_arktype@2.1.25_effect@3.16.12/node_modules/fastmcp",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"resolved": "../node_modules/.pnpm/husky@9.1.7/node_modules/husky",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/table": {
|
||||
"resolved": "../node_modules/.pnpm/table@6.9.0/node_modules/table",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"resolved": "../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript",
|
||||
"link": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.113",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
@@ -15,25 +15,35 @@
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node esbuild.config.js",
|
||||
"play": "node play.ts",
|
||||
"scratch": "node scratch.ts",
|
||||
"upDeps": "pnpm up --latest",
|
||||
"lock": "pnpm --ignore-workspace install"
|
||||
"lock": "pnpm --ignore-workspace install",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"@ark/fs": "0.49.0",
|
||||
"@modelcontextprotocol/sdk": "^1.20.0",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.37",
|
||||
"@ark/fs": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"arktype": "^2.1.22",
|
||||
"@openai/codex-sdk": "0.58.0",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"arktype": "2.1.25",
|
||||
"dotenv": "^17.2.3",
|
||||
"execa": "^9.6.0",
|
||||
"fastmcp": "^3.20.0",
|
||||
"table": "^6.9.0",
|
||||
"zod": "^3.24.4"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -2,50 +2,34 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fromHere } from "@ark/fs";
|
||||
import { flatMorph } from "@ark/util";
|
||||
import arg from "arg";
|
||||
import { config } from "dotenv";
|
||||
import { type ActionInputs, main } from "./main.ts";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
import { runAct } from "./utils/act.ts";
|
||||
import { setupGitHubInstallationToken } from "./utils/github.ts";
|
||||
import { agents } from "./agents/index.ts";
|
||||
import type { AgentResult } from "./agents/shared.ts";
|
||||
import { type Inputs, main } from "./main.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { setupTestRepo } from "./utils/setup.ts";
|
||||
|
||||
config();
|
||||
config({ path: join(process.cwd(), "../.env") });
|
||||
|
||||
export async function run(
|
||||
prompt: string,
|
||||
options: { act?: boolean } = {}
|
||||
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
|
||||
export async function run(prompt: string): Promise<AgentResult> {
|
||||
try {
|
||||
console.log(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
if (options.act) {
|
||||
console.log("🐳 Running with Docker/act...");
|
||||
runAct(prompt);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
console.log("🔑 Setting up GitHub installation token...");
|
||||
const installationToken = await setupGitHubInstallationToken();
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = installationToken;
|
||||
|
||||
console.log("✅ GitHub installation token setup successfully");
|
||||
|
||||
const inputs: ActionInputs = {
|
||||
// check if prompt is a pullfrog payload and extract agent
|
||||
// note: agent from payload will be used by determineAgent with highest precedence
|
||||
// we don't need to extract it here since main() will parse the payload
|
||||
const inputs: Required<Inputs> = {
|
||||
prompt,
|
||||
anthropic_api_key: process.env.ANTHROPIC_API_KEY,
|
||||
github_installation_token: installationToken,
|
||||
...flatMorph(agents, (_, agent) =>
|
||||
agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]])
|
||||
),
|
||||
};
|
||||
|
||||
const result = await main(inputs);
|
||||
@@ -53,18 +37,15 @@ export async function run(
|
||||
process.chdir(originalCwd);
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ Action completed successfully");
|
||||
if (result.output) {
|
||||
console.log("Output:", result.output);
|
||||
}
|
||||
log.success("Action completed successfully");
|
||||
return { success: true, output: result.output || undefined, error: undefined };
|
||||
} else {
|
||||
console.error("❌ Action failed:", result.error);
|
||||
log.error(`Action failed: ${result.error || "Unknown error"}`);
|
||||
return { success: false, error: result.error || undefined, output: undefined };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
console.error("❌ Error:", errorMessage);
|
||||
} catch (err) {
|
||||
const errorMessage = (err as Error).message;
|
||||
log.error(`Error: ${errorMessage}`);
|
||||
return { success: false, error: errorMessage, output: undefined };
|
||||
}
|
||||
}
|
||||
@@ -72,13 +53,12 @@ export async function run(
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = arg({
|
||||
"--help": Boolean,
|
||||
"--act": Boolean,
|
||||
"--raw": String,
|
||||
"-h": "--help",
|
||||
});
|
||||
|
||||
if (args["--help"]) {
|
||||
console.log(`
|
||||
log.info(`
|
||||
Usage: tsx play.ts [file] [options]
|
||||
|
||||
Test the Pullfrog action with various prompts.
|
||||
@@ -87,7 +67,6 @@ 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
|
||||
|
||||
@@ -95,7 +74,7 @@ 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 fixtures/test.ts # Use TypeScript file
|
||||
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
||||
`);
|
||||
process.exit(0);
|
||||
@@ -106,7 +85,8 @@ Examples:
|
||||
if (args["--raw"]) {
|
||||
prompt = args["--raw"];
|
||||
} else {
|
||||
const filePath = args._[0] || "fixtures/basic.txt";
|
||||
const filePath = args._[0] || "basic.txt";
|
||||
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
let resolvedPath: string;
|
||||
|
||||
@@ -156,13 +136,15 @@ Examples:
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run(prompt, { act: args["--act"] || false });
|
||||
const result = await run(prompt);
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error:", (error as Error).message);
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
log.error((err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+844
-24
File diff suppressed because it is too large
Load Diff
+15
@@ -0,0 +1,15 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
function findCliPath(name: string): string | null {
|
||||
const result = spawnSync("which", [name], { encoding: "utf-8" });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const cliPath = result.stdout.trim();
|
||||
if (cliPath && existsSync(cliPath)) {
|
||||
return cliPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(findCliPath("codei"));
|
||||
+15
-15
@@ -2,22 +2,22 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ESNext"],
|
||||
"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,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"declaration": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"stripInternal": true,
|
||||
"moduleDetection": "force",
|
||||
"useUnknownInCatchVariables": true
|
||||
"useUnknownInCatchVariables": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { execSync } from "node:child_process";
|
||||
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.ts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
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 {
|
||||
setupTestRepo({ tempDir });
|
||||
|
||||
config({ path: envPath });
|
||||
|
||||
buildAction(actionPath);
|
||||
|
||||
const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
|
||||
|
||||
const distPath = join(actionPath, ".act-dist");
|
||||
console.log("📦 Creating minimal distribution for act...");
|
||||
execSync(`rm -rf "${distPath}" && mkdir -p "${distPath}"`, { shell: "/bin/bash" });
|
||||
|
||||
["action.yml", "entry.cjs", "index.cjs", "package.json"].forEach((file) => {
|
||||
const src = join(actionPath, file);
|
||||
if (existsSync(src)) {
|
||||
execSync(`cp "${src}" "${distPath}"`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
||||
|
||||
const actCommandParts = [
|
||||
"act",
|
||||
"workflow_dispatch",
|
||||
"-W",
|
||||
workflowPath,
|
||||
"--input",
|
||||
`prompt='${escapedPrompt}'`,
|
||||
"--local-repository",
|
||||
`pullfrog/action@v0=${distPath}`, // Use minimal dist without symlinks
|
||||
];
|
||||
|
||||
ENV_VARS.forEach((key) => {
|
||||
if (process.env[key]) {
|
||||
actCommandParts.push("-s", key);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const actCommand = actCommandParts.join(" ");
|
||||
|
||||
console.log("🚀 Running act with prompt:");
|
||||
console.log("─".repeat(50));
|
||||
console.log(prompt);
|
||||
console.log("─".repeat(50));
|
||||
console.log("");
|
||||
|
||||
execSync(actCommand, {
|
||||
stdio: "inherit",
|
||||
cwd: join(__dirname, "..", ".."),
|
||||
});
|
||||
execSync(`rm -rf "${distPath}"`);
|
||||
} catch (error) {
|
||||
execSync(`rm -rf "${distPath}"`);
|
||||
console.error("❌ Act execution failed:", (error as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AgentName } from "../external.ts";
|
||||
import type { RepoContext } from "./github.ts";
|
||||
|
||||
export interface Mode {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface RepoSettings {
|
||||
defaultAgent: AgentName | null;
|
||||
webAccessLevel: "full_access" | "limited";
|
||||
webAccessAllowTrusted: boolean;
|
||||
webAccessDomains: string;
|
||||
modes: Mode[];
|
||||
}
|
||||
|
||||
export const DEFAULT_REPO_SETTINGS: RepoSettings = {
|
||||
defaultAgent: null,
|
||||
webAccessLevel: "full_access",
|
||||
webAccessAllowTrusted: false,
|
||||
webAccessDomains: "",
|
||||
modes: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch repository settings from the Pullfrog API
|
||||
* Returns defaults if repo doesn't exist or fetch fails
|
||||
*/
|
||||
export async function fetchRepoSettings({
|
||||
token,
|
||||
repoContext,
|
||||
}: {
|
||||
token: string;
|
||||
repoContext: RepoContext;
|
||||
}): Promise<RepoSettings> {
|
||||
const settings = await getRepoSettings(token, repoContext);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch repository settings from the Pullfrog API with fallback to defaults
|
||||
* Returns agent, permissions, and workflows (excludes triggers)
|
||||
* Returns defaults if repo doesn't exist or fetch fails
|
||||
*/
|
||||
export async function getRepoSettings(
|
||||
token: string,
|
||||
repoContext: RepoContext
|
||||
): Promise<RepoSettings> {
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
|
||||
|
||||
// Add timeout to prevent hanging (5 seconds)
|
||||
const timeoutMs = 5000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiUrl}/api/repo/${repoContext.owner}/${repoContext.name}/settings`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
// If API returns 404 or other error, fall back to defaults
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
}
|
||||
|
||||
const settings = (await response.json()) as RepoSettings | null;
|
||||
|
||||
// If API returns null (repo doesn't exist), return defaults
|
||||
if (settings === null) {
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
// If fetch fails (network error, timeout, etc.), fall back to defaults
|
||||
return DEFAULT_REPO_SETTINGS;
|
||||
}
|
||||
}
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* CLI output utilities that work well in both local and GitHub Actions environments
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import { table } from "table";
|
||||
|
||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
const isDebugEnabled = () => process.env.LOG_LEVEL === "debug";
|
||||
|
||||
/**
|
||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
||||
*/
|
||||
function startGroup(name: string): void {
|
||||
if (isGitHubActions) {
|
||||
core.startGroup(name);
|
||||
} else {
|
||||
console.group(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End a collapsed group
|
||||
*/
|
||||
function endGroup(): void {
|
||||
if (isGitHubActions) {
|
||||
core.endGroup();
|
||||
} else {
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a formatted box with text (for console output)
|
||||
*/
|
||||
function boxString(
|
||||
text: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
maxWidth?: number;
|
||||
indent?: string;
|
||||
padding?: number;
|
||||
}
|
||||
): string {
|
||||
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
|
||||
|
||||
const lines = text.trim().split("\n");
|
||||
const wrappedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length <= maxWidth - padding * 2) {
|
||||
wrappedLines.push(line);
|
||||
} else {
|
||||
const words = line.split(" ");
|
||||
let currentLine = "";
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (testLine.length <= maxWidth - padding * 2) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
wrappedLines.push(word.substring(0, maxWidth - padding * 2));
|
||||
currentLine = word.substring(maxWidth - padding * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
||||
const contentBoxWidth = maxLineLength + padding * 2;
|
||||
|
||||
// ensure box width is at least as wide as the title line when title exists
|
||||
const titleLineLength = title ? ` ${title} `.length : 0;
|
||||
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
|
||||
|
||||
let result = "";
|
||||
|
||||
if (title) {
|
||||
const titleLine = ` ${title} `;
|
||||
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
||||
result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
result += `${indent}┌${"─".repeat(boxWidth)}┐\n`;
|
||||
}
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
const paddedLine = line.padEnd(maxLineLength);
|
||||
result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`;
|
||||
}
|
||||
|
||||
result += `${indent}└${"─".repeat(boxWidth)}┘`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a formatted box with text
|
||||
* Works well in both local and GitHub Actions environments
|
||||
*/
|
||||
function box(
|
||||
text: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
maxWidth?: number;
|
||||
}
|
||||
): void {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
if (isGitHubActions) {
|
||||
// Add as markdown code block for summary (no headers)
|
||||
core.summary.addRaw(`\`\`\`\n${text}\n\`\`\`\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a table to GitHub Actions job summary (rich formatting)
|
||||
* Also logs to console. Only use this once at the end of execution.
|
||||
*/
|
||||
async function summaryTable(
|
||||
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
||||
options?: {
|
||||
title?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { title } = options || {};
|
||||
|
||||
// Convert rows to format expected by Job Summaries API
|
||||
const formattedRows = rows.map((row) =>
|
||||
row.map((cell) => {
|
||||
if (typeof cell === "string") {
|
||||
return { data: cell };
|
||||
}
|
||||
return cell;
|
||||
})
|
||||
);
|
||||
|
||||
if (isGitHubActions) {
|
||||
const summary = core.summary;
|
||||
if (title) {
|
||||
summary.addRaw(`**${title}**\n\n`);
|
||||
}
|
||||
summary.addTable(formattedRows);
|
||||
// Note: Don't write immediately, let it accumulate with other summary content
|
||||
}
|
||||
|
||||
// Also log to console for visibility
|
||||
if (title) {
|
||||
core.info(`\n${title}`);
|
||||
}
|
||||
const tableText = formattedRows.map((row) => row.map((cell) => cell.data).join(" | ")).join("\n");
|
||||
core.info(`\n${tableText}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a formatted table using the table package
|
||||
* Also logs to console and GitHub Actions summary
|
||||
*/
|
||||
async function printTable(
|
||||
rows: Array<Array<{ data: string; header?: boolean } | string>>,
|
||||
options?: {
|
||||
title?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { title } = options || {};
|
||||
|
||||
// Convert rows to string arrays for the table package
|
||||
const tableData = rows.map((row) =>
|
||||
row.map((cell) => {
|
||||
if (typeof cell === "string") {
|
||||
return cell;
|
||||
}
|
||||
return cell.data;
|
||||
})
|
||||
);
|
||||
|
||||
const formatted = table(tableData);
|
||||
|
||||
if (title) {
|
||||
core.info(`\n${title}`);
|
||||
}
|
||||
core.info(`\n${formatted}\n`);
|
||||
|
||||
if (isGitHubActions) {
|
||||
if (title) {
|
||||
core.summary.addRaw(`**${title}**\n\n`);
|
||||
}
|
||||
core.summary.addRaw(`\`\`\`\n${formatted}\n\`\`\`\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a separator line
|
||||
*/
|
||||
function separator(length: number = 50): void {
|
||||
const separatorText = "─".repeat(length);
|
||||
core.info(separatorText);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`---\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main logging utility object - import this once and access all utilities
|
||||
*/
|
||||
export const log = {
|
||||
/**
|
||||
* Print info message
|
||||
*/
|
||||
info: (message: string): void => {
|
||||
core.info(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print warning message
|
||||
*/
|
||||
warning: (message: string): void => {
|
||||
core.warning(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`⚠️ ${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print error message
|
||||
*/
|
||||
error: (message: string): void => {
|
||||
core.error(message);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`❌ ${message}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print success message
|
||||
*/
|
||||
success: (message: string): void => {
|
||||
const successMessage = `✅ ${message}`;
|
||||
core.info(successMessage);
|
||||
if (isGitHubActions) {
|
||||
core.summary.addRaw(`${successMessage}\n`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print debug message (only if LOG_LEVEL=debug)
|
||||
*/
|
||||
debug: (message: string): void => {
|
||||
if (isDebugEnabled()) {
|
||||
if (isGitHubActions) {
|
||||
core.debug(message);
|
||||
} else {
|
||||
core.info(`[DEBUG] ${message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Print a formatted box with text
|
||||
*/
|
||||
box,
|
||||
|
||||
/**
|
||||
* Add a table to GitHub Actions job summary (rich formatting)
|
||||
* Only use this once at the end of execution
|
||||
*/
|
||||
summaryTable,
|
||||
|
||||
/**
|
||||
* Print a formatted table using the table package
|
||||
*/
|
||||
table: printTable,
|
||||
|
||||
/**
|
||||
* Print a separator line
|
||||
*/
|
||||
separator,
|
||||
|
||||
/**
|
||||
* Write all accumulated summary content to the job summary
|
||||
* Call this at the end of execution to finalize the summary
|
||||
*/
|
||||
writeSummary: async (): Promise<void> => {
|
||||
if (isGitHubActions) {
|
||||
await core.summary.write();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a collapsed group (GitHub Actions) or regular group (local)
|
||||
*/
|
||||
startGroup,
|
||||
|
||||
/**
|
||||
* End a collapsed group
|
||||
*/
|
||||
endGroup,
|
||||
|
||||
/**
|
||||
* Log tool call information to console with formatted output
|
||||
*/
|
||||
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
||||
let output = `→ ${toolName}\n`;
|
||||
|
||||
const inputFormatted = formatJsonValue(input);
|
||||
if (inputFormatted !== "{}") {
|
||||
output += formatIndentedField("input", inputFormatted);
|
||||
}
|
||||
|
||||
log.info(output.trimEnd());
|
||||
},
|
||||
|
||||
/**
|
||||
* Log MCP tool call information to mcpLog.txt in the temp directory
|
||||
*/
|
||||
toolCallToFile: ({
|
||||
toolName,
|
||||
request,
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
toolName: string;
|
||||
request: unknown;
|
||||
result?: string;
|
||||
error?: string;
|
||||
}): void => {
|
||||
const logPath = getMcpLogPath();
|
||||
const params: Parameters<typeof formatToolCall>[0] = { toolName, request };
|
||||
if (error) {
|
||||
params.error = error;
|
||||
} else if (result) {
|
||||
params.result = result;
|
||||
}
|
||||
const logEntry = formatToolCall(params);
|
||||
appendFileSync(logPath, logEntry, "utf-8");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the path to the MCP log file in the temp directory
|
||||
*/
|
||||
function getMcpLogPath(): string {
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
return join(tempDir, "mcpLog.txt");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value as JSON, using compact format for simple values and pretty-printed for complex ones
|
||||
*/
|
||||
export function formatJsonValue(value: unknown): string {
|
||||
const compact = JSON.stringify(value);
|
||||
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a multi-line string with proper indentation for tool call output
|
||||
* First line has the label, subsequent lines are indented 4 spaces
|
||||
*/
|
||||
export function formatIndentedField(label: string, content: string): string {
|
||||
if (!content.includes("\n")) {
|
||||
return ` ${label}: ${content}\n`;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
let formatted = ` ${label}: ${lines[0]}\n`;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
formatted += ` ${lines[i]}\n`;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the input field for a tool call
|
||||
*/
|
||||
function formatToolInput(request: unknown): string {
|
||||
const requestFormatted = formatJsonValue(request);
|
||||
if (requestFormatted === "{}") {
|
||||
return "";
|
||||
}
|
||||
return formatIndentedField("input", requestFormatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the result field for a tool call, parsing JSON if possible
|
||||
*/
|
||||
function formatToolResult(result: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
const formatted = formatJsonValue(parsed);
|
||||
return formatIndentedField("result", formatted);
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
return formatIndentedField("result", result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a complete tool call entry with tool name, input, result, and error
|
||||
*/
|
||||
function formatToolCall({
|
||||
toolName,
|
||||
request,
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
toolName: string;
|
||||
request: unknown;
|
||||
result?: string;
|
||||
error?: string;
|
||||
}): string {
|
||||
let logEntry = `→ ${toolName}\n`;
|
||||
|
||||
logEntry += formatToolInput(request);
|
||||
|
||||
if (error) {
|
||||
logEntry += formatIndentedField("error", error);
|
||||
} else if (result) {
|
||||
logEntry += formatToolResult(result);
|
||||
}
|
||||
|
||||
logEntry += "\n";
|
||||
return logEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a CLI executable path by checking if it's installed globally
|
||||
* @param name The name of the CLI executable to find
|
||||
* @returns The path to the CLI executable, or null if not found
|
||||
*/
|
||||
export function findCliPath(name: string): string | null {
|
||||
const result = spawnSync("which", [name], { encoding: "utf-8" });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const cliPath = result.stdout.trim();
|
||||
if (cliPath && existsSync(cliPath)) {
|
||||
return cliPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Create a temporary file with the given content
|
||||
*/
|
||||
export function createTempFile(content: string, filename = "temp.txt"): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pullfrog-"));
|
||||
const filePath = join(tempDir, filename);
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
+86
-40
@@ -1,6 +1,6 @@
|
||||
import { createSign } from "node:crypto";
|
||||
import * as core from "@actions/core";
|
||||
import { resolveRepoContext } from "./repo-context.ts";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export interface InstallationToken {
|
||||
token: string;
|
||||
@@ -43,44 +43,53 @@ 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...");
|
||||
|
||||
log.info("Generating OIDC token...");
|
||||
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
core.info("OIDC token generated successfully");
|
||||
log.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",
|
||||
},
|
||||
});
|
||||
log.info("Exchanging OIDC token for installation token...");
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
throw new Error(
|
||||
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
|
||||
);
|
||||
// Add timeout to prevent long waits (5 seconds)
|
||||
const timeoutMs = 5000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||
log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||
|
||||
return tokenData.token;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(`Token exchange timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -208,7 +217,7 @@ const findInstallationId = async (
|
||||
};
|
||||
|
||||
async function acquireTokenViaGitHubApp(): Promise<string> {
|
||||
const repoContext = resolveRepoContext();
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
const config: GitHubAppConfig = {
|
||||
appId: process.env.GITHUB_APP_ID!,
|
||||
@@ -236,17 +245,54 @@ async function acquireNewToken(): Promise<string> {
|
||||
* Setup GitHub installation token for the action
|
||||
*/
|
||||
export async function setupGitHubInstallationToken(): Promise<string> {
|
||||
const existingToken = checkExistingToken();
|
||||
if (existingToken) {
|
||||
core.setSecret(existingToken);
|
||||
core.info("Using provided GitHub installation token");
|
||||
return existingToken;
|
||||
const acquiredToken = await acquireNewToken();
|
||||
core.setSecret(acquiredToken);
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken;
|
||||
|
||||
return acquiredToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke GitHub installation token
|
||||
*/
|
||||
export async function revokeInstallationToken(token: string): Promise<void> {
|
||||
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
|
||||
try {
|
||||
await fetch(`${apiUrl}/installation/token`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
log.info("Installation token revoked");
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RepoContext {
|
||||
owner: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse repository context from GITHUB_REPOSITORY environment variable.
|
||||
*/
|
||||
export function parseRepoContext(): RepoContext {
|
||||
const githubRepo = process.env.GITHUB_REPOSITORY;
|
||||
if (!githubRepo) {
|
||||
throw new Error("GITHUB_REPOSITORY environment variable is required");
|
||||
}
|
||||
|
||||
const token = await acquireNewToken();
|
||||
|
||||
core.setSecret(token);
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = token;
|
||||
|
||||
return token;
|
||||
const [owner, name] = githubRepo.split("/");
|
||||
if (!owner || !name) {
|
||||
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
|
||||
}
|
||||
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
+104
-13
@@ -1,5 +1,9 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import type { RepoContext } from "./github.ts";
|
||||
import { $ } from "./shell.ts";
|
||||
|
||||
export interface SetupOptions {
|
||||
tempDir: string;
|
||||
@@ -19,31 +23,118 @@ export function setupTestRepo(options: SetupOptions): void {
|
||||
|
||||
if (existsSync(tempDir)) {
|
||||
if (forceClean) {
|
||||
console.log("🗑️ Removing existing .temp directory...");
|
||||
log.info("🗑️ Removing existing .temp directory...");
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
console.log("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
|
||||
log.info("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
$("git", ["clone", repoUrl, tempDir]);
|
||||
} else {
|
||||
console.log("📦 Resetting existing .temp repository...");
|
||||
log.info("📦 Resetting existing .temp repository...");
|
||||
execSync("git reset --hard HEAD && git clean -fd", {
|
||||
cwd: tempDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
|
||||
log.info("📦 Cloning pullfrogai/scratch into .temp...");
|
||||
$("git", ["clone", repoUrl, tempDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the action bundles
|
||||
* Setup git configuration to avoid identity errors
|
||||
* Uses --local flag to scope config to the current repo only
|
||||
*/
|
||||
export function buildAction(actionPath: string): void {
|
||||
console.log("🔨 Building fresh bundles with esbuild...");
|
||||
execSync("node esbuild.config.js", {
|
||||
cwd: actionPath,
|
||||
stdio: "inherit",
|
||||
});
|
||||
export function setupGitConfig(): void {
|
||||
const repoDir = process.cwd();
|
||||
log.info("🔧 Setting up git configuration...");
|
||||
try {
|
||||
// Use --local to scope config to this repo only, preventing leakage to user's global config
|
||||
execSync('git config --local user.email "action@pullfrog.ai"', {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync('git config --local user.name "Pullfrog Action"', {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
log.debug("setupGitConfig: ✓ Git configuration set successfully (scoped to repo)");
|
||||
} catch (error) {
|
||||
// If git config fails, log warning but don't fail the action
|
||||
// This can happen if we're not in a git repo or git isn't available
|
||||
log.warning(
|
||||
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup git authentication using GitHub installation token
|
||||
* Always uses the installation token, scoped to the current repo only
|
||||
*/
|
||||
export function setupGitAuth(ctx: {
|
||||
githubInstallationToken: string;
|
||||
repoContext: RepoContext;
|
||||
}): void {
|
||||
const repoDir = process.cwd();
|
||||
|
||||
log.info("🔐 Setting up git authentication...");
|
||||
|
||||
// Remove existing git auth headers that actions/checkout might have set
|
||||
// Use --local to scope to this repo only
|
||||
try {
|
||||
execSync("git config --local --unset-all http.https://github.com/.extraheader", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
log.info("✓ Removed existing authentication headers");
|
||||
} catch {
|
||||
log.debug("No existing authentication headers to remove");
|
||||
}
|
||||
|
||||
// Update remote URL to embed the token
|
||||
// This is scoped to the repo's .git/config, not the user's global config
|
||||
const remoteUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.repoContext.owner}/${ctx.repoContext.name}.git`;
|
||||
$("git", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir });
|
||||
log.info("✓ Updated remote URL with authentication token (scoped to repo)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup git branch based on payload event context
|
||||
* Automatically checks out the appropriate branch before agent execution
|
||||
*/
|
||||
export function setupGitBranch(payload: Payload): void {
|
||||
const branch = payload.event.branch;
|
||||
const repoDir = process.cwd();
|
||||
|
||||
if (!branch) {
|
||||
log.debug("No branch specified in payload, using default branch");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`🌿 Setting up git branch: ${branch}`);
|
||||
|
||||
try {
|
||||
// Fetch the branch from origin
|
||||
log.debug(`Fetching branch from origin: ${branch}`);
|
||||
execSync(`git fetch origin ${branch}`, {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Checkout the branch, creating local tracking branch
|
||||
log.debug(`Checking out branch: ${branch}`);
|
||||
execSync(`git checkout -B ${branch} origin/${branch}`, {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
log.info(`✓ Successfully checked out branch: ${branch}`);
|
||||
} catch (error) {
|
||||
// If git operations fail, log warning but don't fail the action
|
||||
// The agent might still be able to work with the default branch
|
||||
log.warning(
|
||||
`Failed to checkout branch ${branch}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
interface ShellOptions {
|
||||
cwd?: string;
|
||||
encoding?:
|
||||
| "utf-8"
|
||||
| "utf8"
|
||||
| "ascii"
|
||||
| "base64"
|
||||
| "base64url"
|
||||
| "hex"
|
||||
| "latin1"
|
||||
| "ucs-2"
|
||||
| "ucs2"
|
||||
| "utf16le";
|
||||
log?: boolean;
|
||||
onError?: (result: { status: number; stdout: string; stderr: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command safely using spawnSync with argument arrays.
|
||||
* Prevents shell injection by avoiding string interpolation in shell commands.
|
||||
*
|
||||
* @param cmd - The command to execute
|
||||
* @param args - Array of arguments to pass to the command
|
||||
* @param options - Optional configuration (cwd, encoding, onError)
|
||||
* @returns The trimmed stdout output
|
||||
* @throws Error if command fails and no onError handler is provided
|
||||
*/
|
||||
export function $(cmd: string, args: string[], options?: ShellOptions): string {
|
||||
const encoding = options?.encoding ?? "utf-8";
|
||||
|
||||
// CRITICAL: use "ignore" for stdin instead of "inherit" to avoid breaking MCP transport
|
||||
// when running inside an MCP server, stdin is used for JSON-RPC protocol
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding,
|
||||
cwd: options?.cwd,
|
||||
});
|
||||
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
|
||||
// Write output to process streams so it behaves like stdio: "inherit"
|
||||
// CRITICAL: when running inside an MCP server, stdout is used for JSON-RPC protocol
|
||||
// so we must write to stderr instead to avoid corrupting the protocol
|
||||
// Only log if log option is not explicitly set to false
|
||||
if (options?.log !== false) {
|
||||
// if stdout is a TTY, it's safe to write to it; otherwise it's likely a pipe used for JSON-RPC
|
||||
const canWriteToStdout = process.stdout.isTTY === true;
|
||||
if (stdout) {
|
||||
if (canWriteToStdout) {
|
||||
process.stdout.write(stdout);
|
||||
} else {
|
||||
// stdout is a pipe (MCP context) - write to stderr instead
|
||||
process.stderr.write(stdout);
|
||||
}
|
||||
}
|
||||
if (stderr) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (result.status !== 0) {
|
||||
const errorResult = {
|
||||
status: result.status ?? -1,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
|
||||
if (options?.onError) {
|
||||
options.onError(errorResult);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Command failed with exit code ${errorResult.status}: ${stderr || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
return stdout.trim();
|
||||
}
|
||||
+8
-2
@@ -6,6 +6,7 @@ export interface SpawnOptions {
|
||||
env?: Record<string, string>;
|
||||
input?: string;
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
}
|
||||
@@ -21,16 +22,21 @@ export interface SpawnResult {
|
||||
* Spawn a subprocess with streaming callbacks and buffered results
|
||||
*/
|
||||
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
const { cmd, args, env, input, timeout, onStdout, onStderr } = options;
|
||||
const { cmd, args, env, input, timeout, cwd, onStdout, onStderr } = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// security: caller must provide complete env object, not merged with process.env
|
||||
const child = nodeSpawn(cmd, args, {
|
||||
env: env ? { ...process.env, ...env } : process.env,
|
||||
env: env || {
|
||||
PATH: process.env.PATH || "",
|
||||
HOME: process.env.HOME || "",
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: cwd || process.cwd(),
|
||||
});
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
import { table } from "table";
|
||||
|
||||
/**
|
||||
* Print a formatted table with consistent styling
|
||||
* @param rows - Array of string arrays representing table rows
|
||||
* @param options - Optional table configuration
|
||||
*/
|
||||
|
||||
export function tableString(
|
||||
rows: string[][],
|
||||
options?: {
|
||||
title?: string;
|
||||
indent?: string;
|
||||
drawHorizontalLine?: (lineIndex: number, rowCount: number) => boolean;
|
||||
}
|
||||
): string {
|
||||
const {
|
||||
title,
|
||||
indent = "",
|
||||
drawHorizontalLine = (lineIndex: number, rowCount: number) => {
|
||||
return lineIndex === 0 || (options?.title && lineIndex === 1) || lineIndex === rowCount;
|
||||
},
|
||||
} = options || {};
|
||||
|
||||
if (title) {
|
||||
rows.unshift([title]);
|
||||
}
|
||||
|
||||
const tableOutput = table(rows, {
|
||||
drawHorizontalLine,
|
||||
border: {
|
||||
topBody: `─`,
|
||||
topJoin: `┬`,
|
||||
topLeft: `┌`,
|
||||
topRight: `┐`,
|
||||
|
||||
bottomBody: `─`,
|
||||
bottomJoin: `┴`,
|
||||
bottomLeft: `└`,
|
||||
bottomRight: `┘`,
|
||||
|
||||
bodyLeft: `│`,
|
||||
bodyRight: `│`,
|
||||
bodyJoin: `│`,
|
||||
|
||||
joinBody: `─`,
|
||||
joinLeft: `├`,
|
||||
joinRight: `┤`,
|
||||
joinJoin: `┼`,
|
||||
},
|
||||
});
|
||||
|
||||
const indentedOutput = tableOutput.split("\n").join(`\n${indent}`).trim();
|
||||
|
||||
return `${indent}${indentedOutput}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a multi-line string in a formatted box with text wrapping
|
||||
* @param text - The text to display
|
||||
* @param options - Optional configuration for the box
|
||||
*/
|
||||
export function boxString(
|
||||
text: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
maxWidth?: number;
|
||||
indent?: string;
|
||||
padding?: number;
|
||||
}
|
||||
): string {
|
||||
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
|
||||
|
||||
// Clean up the text and split into lines
|
||||
const lines = text.trim().split("\n");
|
||||
|
||||
// Word wrap each line to fit within maxWidth
|
||||
const wrappedLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.length <= maxWidth - padding * 2) {
|
||||
wrappedLines.push(line);
|
||||
} else {
|
||||
// Word wrap the line
|
||||
const words = line.split(" ");
|
||||
let currentLine = "";
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (testLine.length <= maxWidth - padding * 2) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
currentLine = word;
|
||||
} else {
|
||||
// Word is too long, break it
|
||||
wrappedLines.push(word.substring(0, maxWidth - padding * 2));
|
||||
currentLine = word.substring(maxWidth - padding * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the maximum line length for box sizing
|
||||
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
||||
const boxWidth = maxLineLength + padding * 2;
|
||||
|
||||
// Create the box
|
||||
const topBorder = "┌" + "─".repeat(boxWidth) + "┐";
|
||||
const bottomBorder = "└" + "─".repeat(boxWidth) + "┘";
|
||||
const sideBorder = "│";
|
||||
|
||||
let result = "";
|
||||
|
||||
// Add title if provided
|
||||
if (title) {
|
||||
const titleLine = ` ${title} `;
|
||||
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
||||
result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`;
|
||||
}
|
||||
|
||||
// Add top border (or title border)
|
||||
if (!title) {
|
||||
result += `${indent}${topBorder}\n`;
|
||||
}
|
||||
|
||||
// Add content lines
|
||||
for (const line of wrappedLines) {
|
||||
const paddedLine = line.padEnd(maxLineLength);
|
||||
result += `${indent}${sideBorder}${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}${sideBorder}\n`;
|
||||
}
|
||||
|
||||
// Add bottom border
|
||||
result += `${indent}${bottomBorder}`;
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user