Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7d2035315 | |||
| c703ecc4f4 | |||
| b05d1bfc53 | |||
| f765a0878d | |||
| e3a7b09df4 | |||
| 7e0dcd5374 | |||
| 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 | |||
| b2a9b60271 | |||
| 41a4f44e2d | |||
| d1f16e9dd2 |
+1
-1
@@ -8,5 +8,5 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
pnpm build
|
||||
|
||||
# Add the built files and lockfile to the commit
|
||||
git add entry.js mcp-server.js pnpm-lock.yaml
|
||||
git add entry mcp-server pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
@@ -11,7 +11,7 @@ GitHub Action for running Claude Code and other agents via Pullfrog.
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Testing with play.ts
|
||||
## Testing with `play.ts`
|
||||
|
||||
```bash
|
||||
pnpm play # Uses fixtures/play.txt
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ inputs:
|
||||
|
||||
runs:
|
||||
using: "node20"
|
||||
main: "entry.js"
|
||||
main: "entry"
|
||||
|
||||
branding:
|
||||
icon: "code"
|
||||
|
||||
+4
-2
@@ -15,11 +15,13 @@ export const claude = agent({
|
||||
executablePath: "cli.js",
|
||||
});
|
||||
},
|
||||
run: async ({ prompt, mcpServers, apiKey, cliPath }) => {
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath }) => {
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
|
||||
const prompt = addInstructions(payload);
|
||||
|
||||
const queryInstance = query({
|
||||
prompt: addInstructions(prompt),
|
||||
prompt,
|
||||
options: {
|
||||
permissionMode: "bypassPermissions",
|
||||
mcpServers,
|
||||
|
||||
+11
-32
@@ -1,9 +1,8 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, installFromNpmTarball } from "./shared.ts";
|
||||
import { agent, type ConfigureMcpServersParams, installFromNpmTarball } from "./shared.ts";
|
||||
|
||||
export const codex = agent({
|
||||
name: "codex",
|
||||
@@ -15,13 +14,11 @@ export const codex = agent({
|
||||
executablePath: "bin/codex.js",
|
||||
});
|
||||
},
|
||||
run: async ({ prompt, mcpServers, apiKey, cliPath, githubInstallationToken }) => {
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => {
|
||||
process.env.OPENAI_API_KEY = apiKey;
|
||||
process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken;
|
||||
// Configure MCP servers for Codex (global config is fine - not part of repo)
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
configureMcpServers({ mcpServers, apiKey, cliPath });
|
||||
}
|
||||
|
||||
configureCodexMcpServers({ mcpServers, cliPath });
|
||||
|
||||
// Configure Codex
|
||||
const codexOptions: CodexOptions = {
|
||||
@@ -42,7 +39,7 @@ export const codex = agent({
|
||||
|
||||
try {
|
||||
// Use runStreamed to get streaming events similar to claude.ts
|
||||
const streamedTurn = await thread.runStreamed(addInstructions(prompt));
|
||||
const streamedTurn = await thread.runStreamed(addInstructions(payload));
|
||||
|
||||
// Stream events and handle them
|
||||
let finalOutput = "";
|
||||
@@ -160,33 +157,19 @@ const messageHandlers: {
|
||||
},
|
||||
};
|
||||
|
||||
function configureMcpServers({
|
||||
mcpServers,
|
||||
apiKey,
|
||||
cliPath,
|
||||
}: {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
apiKey: string;
|
||||
cliPath: string;
|
||||
}): void {
|
||||
log.info("Configuring MCP servers for Codex...");
|
||||
/**
|
||||
* Configure MCP servers for Codex using the CLI.
|
||||
* Codex CLI syntax: codex mcp add <name> --env KEY=value -- <command> [args...]
|
||||
*/
|
||||
function configureCodexMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
// Only configure stdio servers (Codex CLI supports stdio MCP servers)
|
||||
// Check if it's a stdio server config (has 'command' property)
|
||||
if (!("command" in serverConfig)) {
|
||||
log.warning(`MCP server '${serverName}' is not a stdio server, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build command and args
|
||||
const command = serverConfig.command;
|
||||
const args = serverConfig.args || [];
|
||||
const envVars = serverConfig.env || {};
|
||||
|
||||
// Build the codex mcp add command with proper argument handling
|
||||
const addArgs = ["mcp", "add", serverName];
|
||||
|
||||
// Add environment variables as --env flags
|
||||
// Add environment variables as --env flags first
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
addArgs.push("--env", `${key}=${value}`);
|
||||
}
|
||||
@@ -197,10 +180,6 @@ function configureMcpServers({
|
||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENAI_API_KEY: apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (addResult.status !== 0) {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, type ConfigureMcpServersParams, installFromCurl } from "./shared.ts";
|
||||
|
||||
export const cursor = agent({
|
||||
name: "cursor",
|
||||
inputKeys: ["cursor_api_key"],
|
||||
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 {
|
||||
// Run cursor-agent in non-interactive mode with the prompt
|
||||
// Using -p flag for prompt, --output-format text for plain text output
|
||||
// and --approve-mcps to automatically approve all MCP servers
|
||||
const fullPrompt = addInstructions(payload);
|
||||
|
||||
// Find temp directory from cliPath to set HOME for MCP config lookup
|
||||
const tempDir = cliPath.split("/.local/bin/")[0];
|
||||
|
||||
log.info("Running Cursor CLI...");
|
||||
|
||||
// Use spawn to handle streaming output
|
||||
// Use --print flag explicitly for non-interactive mode
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
cliPath,
|
||||
["--print", fullPrompt, "--output-format", "text", "--approve-mcps", "--force"],
|
||||
{
|
||||
cwd: process.cwd(), // Run in current working directory
|
||||
env: {
|
||||
...process.env,
|
||||
CURSOR_API_KEY: apiKey,
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
HOME: tempDir, // Set HOME so Cursor CLI can find .cursor/mcp.json
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
|
||||
}
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
// Log when process starts
|
||||
child.on("spawn", () => {
|
||||
log.debug("Cursor CLI process spawned");
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
// Stream output in real-time
|
||||
process.stdout.write(text);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
// Log errors as they come - but also write to stdout so we can see it
|
||||
process.stderr.write(text);
|
||||
log.warning(text);
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code, signal) => {
|
||||
if (signal) {
|
||||
log.warning(`Cursor CLI terminated by signal: ${signal}`);
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
log.success("Cursor CLI completed successfully");
|
||||
resolve({
|
||||
success: true,
|
||||
output: stdout.trim(),
|
||||
});
|
||||
} else {
|
||||
const errorMessage = stderr || `Cursor CLI exited with code ${code}`;
|
||||
log.error(`Cursor CLI failed: ${errorMessage}`);
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: stdout.trim(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
const errorMessage = error.message || String(error);
|
||||
log.error(`Cursor CLI execution failed: ${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: "",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Configure MCP servers for Cursor by writing to the Cursor configuration file.
|
||||
* For cursor, we need to add the MCP servers to the Cursor configuration file manually as there is no CLI command to do this.
|
||||
*/
|
||||
function configureCursorMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams) {
|
||||
const tempDir = cliPath.split("/.local/bin/")[0];
|
||||
const cursorConfigDir = join(tempDir, ".cursor");
|
||||
const mcpConfigPath = join(cursorConfigDir, "mcp.json");
|
||||
mkdirSync(cursorConfigDir, { recursive: true });
|
||||
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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, installFromNpmTarball } from "./shared.ts";
|
||||
|
||||
export const gemini = agent({
|
||||
name: "gemini",
|
||||
inputKeys: ["google_api_key", "gemini_api_key"],
|
||||
install: async () => {
|
||||
return await installFromNpmTarball({
|
||||
packageName: "@google/gemini-cli",
|
||||
version: "latest",
|
||||
executablePath: "dist/index.js",
|
||||
installDependencies: true,
|
||||
});
|
||||
},
|
||||
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", "text", sessionPrompt],
|
||||
env: {
|
||||
GEMINI_API_KEY: apiKey,
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
},
|
||||
onStdout: (chunk) => {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed) {
|
||||
log.info(trimmed);
|
||||
finalOutput += trimmed + "\n";
|
||||
}
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed) {
|
||||
log.warning(trimmed);
|
||||
finalOutput += trimmed + "\n";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorMessage = result.stderr || result.stdout || "Unknown error";
|
||||
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...] --env KEY=value
|
||||
*/
|
||||
function configureGeminiMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
const command = serverConfig.command;
|
||||
const args = serverConfig.args || [];
|
||||
const envVars = serverConfig.env || {};
|
||||
|
||||
const addArgs = ["mcp", "add", serverName, command, ...args];
|
||||
|
||||
// Add environment variables as --env flags
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
addArgs.push("--env", `${key}=${value}`);
|
||||
}
|
||||
|
||||
log.info(`Adding MCP server '${serverName}'...`);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -1,11 +1,15 @@
|
||||
import type { AgentName } from "../external.ts";
|
||||
import { claude } from "./claude.ts";
|
||||
import { codex } from "./codex.ts";
|
||||
import { jules } from "./jules.ts";
|
||||
import { cursor } from "./cursor.ts";
|
||||
import { gemini } from "./gemini.ts";
|
||||
import type { Agent } from "./shared.ts";
|
||||
|
||||
export const agents = {
|
||||
claude,
|
||||
codex,
|
||||
jules,
|
||||
} as const;
|
||||
cursor,
|
||||
gemini,
|
||||
} satisfies Record<AgentName, Agent>;
|
||||
|
||||
export type AgentInputKey = (typeof agents)[keyof typeof agents]["inputKeys"][number];
|
||||
|
||||
+19
-13
@@ -1,20 +1,20 @@
|
||||
import { ghPullfrogMcpName } from "../mcp/index.ts";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import { modes } from "../modes.ts";
|
||||
|
||||
const userPromptHeader = `****** USER PROMPT ******\n`;
|
||||
|
||||
export const instructions = `
|
||||
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 that is asked of you below ${userPromptHeader}.
|
||||
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.
|
||||
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).
|
||||
|
||||
## SECURITY
|
||||
|
||||
@@ -46,14 +46,20 @@ Ensure after your edits are done, your final comments do not contain intermediat
|
||||
|
||||
## Mode Selection
|
||||
|
||||
choose the appropriate mode based on the prompt payload:
|
||||
Before starting any work, you must first determine which mode to use by examining the request and calling ${ghPullfrogMcpName}/select_mode.
|
||||
|
||||
${modes.map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
|
||||
Available modes:
|
||||
|
||||
## Modes
|
||||
${[...modes, ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
|
||||
|
||||
${modes.map((w) => `### ${w.name}\n\n${w.prompt}`).join("\n\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
|
||||
|
||||
export const addInstructions = (prompt: string) =>
|
||||
`****** GENERAL INSTRUCTIONS ******\n${instructions}\n\n${userPromptHeader}${prompt}`;
|
||||
************* USER PROMPT *************
|
||||
|
||||
${payload.prompt}
|
||||
|
||||
${JSON.stringify(payload.event, null, 2)}`;
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { parseRepoContext } from "../utils/github.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, installFromNpmTarball } from "./shared.ts";
|
||||
|
||||
export const jules = agent({
|
||||
name: "jules",
|
||||
inputKeys: ["google_api_key", "gemini_api_key"],
|
||||
install: async () => {
|
||||
return await installFromNpmTarball({
|
||||
packageName: "@google/jules",
|
||||
version: "latest",
|
||||
executablePath: "run.cjs",
|
||||
});
|
||||
},
|
||||
run: async ({
|
||||
prompt,
|
||||
apiKey,
|
||||
mcpServers: _mcpServers,
|
||||
githubInstallationToken: _githubInstallationToken,
|
||||
cliPath,
|
||||
}) => {
|
||||
if (!apiKey) {
|
||||
throw new Error("google_api_key is required for jules agent");
|
||||
}
|
||||
process.env.GOOGLE_API_KEY = apiKey;
|
||||
|
||||
const repoContext = parseRepoContext();
|
||||
const repoName = `${repoContext.owner}/${repoContext.name}`;
|
||||
|
||||
log.info(`Creating Jules session for ${repoName}...`);
|
||||
|
||||
// Create a new remote session
|
||||
const sessionPrompt = addInstructions(prompt);
|
||||
log.info(`Starting session with prompt: ${prompt.substring(0, 100)}...`);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const createResult = await spawn({
|
||||
cmd: "node",
|
||||
args: [cliPath, "remote", "new", "--repo", repoName, "--session", sessionPrompt],
|
||||
onStdout: (chunk) => {
|
||||
log.info(chunk.trim());
|
||||
// Try to extract session ID from output
|
||||
const match = chunk.match(/session[:\s]+(\d+)/i) || chunk.match(/id[:\s]+(\d+)/i);
|
||||
if (match && !sessionId) {
|
||||
sessionId = match[1];
|
||||
log.info(`✓ Session ID: ${sessionId}`);
|
||||
}
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
log.warning(chunk.trim());
|
||||
},
|
||||
});
|
||||
|
||||
if (createResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Failed to create Jules session: ${createResult.stderr || createResult.stdout || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
// If session ID wasn't extracted from stdout, try to parse it
|
||||
if (!sessionId) {
|
||||
const output = createResult.stdout + createResult.stderr;
|
||||
const match = output.match(/session[:\s]+(\d+)/i) || output.match(/id[:\s]+(\d+)/i);
|
||||
if (match) {
|
||||
sessionId = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
log.warning("Could not extract session ID from output. Session may have been created.");
|
||||
log.info(`Output: ${createResult.stdout}`);
|
||||
} else {
|
||||
log.info(`✓ Session created: ${sessionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to create Jules session: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: "",
|
||||
};
|
||||
}
|
||||
|
||||
// Monitor session progress by polling session list
|
||||
log.info("Monitoring session progress...");
|
||||
let finalOutput = "";
|
||||
const maxPollAttempts = 300; // ~50 minutes max (10 second intervals)
|
||||
let pollAttempts = 0;
|
||||
|
||||
while (pollAttempts < maxPollAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds between polls
|
||||
pollAttempts++;
|
||||
|
||||
try {
|
||||
// List sessions to check status
|
||||
const listResult = await spawn({
|
||||
cmd: "node",
|
||||
args: [cliPath, "remote", "list", "--session"],
|
||||
onStdout: (chunk) => {
|
||||
// Log session updates
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed) {
|
||||
log.info(trimmed);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (listResult.exitCode === 0) {
|
||||
const output = listResult.stdout;
|
||||
// Check if our session is complete
|
||||
// The CLI output format may vary, so we look for completion indicators
|
||||
if (sessionId && output.includes(sessionId)) {
|
||||
// Try to determine if session is complete
|
||||
// This is a heuristic - the actual output format may differ
|
||||
if (
|
||||
output.includes("completed") ||
|
||||
output.includes("done") ||
|
||||
output.includes("finished")
|
||||
) {
|
||||
log.info("Session appears to be completed");
|
||||
finalOutput = "Session completed. Pulling results...";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.warning(`Error checking session status: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull results if session ID is available
|
||||
if (sessionId) {
|
||||
try {
|
||||
log.info(`Pulling results for session ${sessionId}...`);
|
||||
const pullResult = await spawn({
|
||||
cmd: "node",
|
||||
args: [cliPath, "remote", "pull", "--session", sessionId],
|
||||
onStdout: (chunk) => {
|
||||
log.info(chunk.trim());
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
log.warning(chunk.trim());
|
||||
},
|
||||
});
|
||||
|
||||
if (pullResult.exitCode === 0) {
|
||||
finalOutput = pullResult.stdout || "Results pulled successfully.";
|
||||
} else {
|
||||
log.warning(`Failed to pull results: ${pullResult.stderr || pullResult.stdout}`);
|
||||
finalOutput = finalOutput || "Session completed. Check Jules dashboard for results.";
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.warning(`Error pulling results: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (pollAttempts >= maxPollAttempts) {
|
||||
log.warning("Session monitoring timeout reached. Session may still be in progress.");
|
||||
finalOutput =
|
||||
finalOutput || "Session monitoring timeout. Check Jules dashboard for session status.";
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput || "Jules session completed. Check the Jules dashboard for results.",
|
||||
};
|
||||
},
|
||||
});
|
||||
+124
-19
@@ -1,10 +1,9 @@
|
||||
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 { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
|
||||
/**
|
||||
@@ -23,11 +22,45 @@ export interface AgentResult {
|
||||
export interface AgentConfig {
|
||||
apiKey: string;
|
||||
githubInstallationToken: string;
|
||||
prompt: string;
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
payload: Payload;
|
||||
mcpServers: Record<string, McpStdioServerConfig>;
|
||||
cliPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for configuring MCP servers
|
||||
*/
|
||||
export interface ConfigureMcpServersParams {
|
||||
mcpServers: Record<string, McpStdioServerConfig>;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -37,11 +70,8 @@ export async function installFromNpmTarball({
|
||||
packageName,
|
||||
version,
|
||||
executablePath,
|
||||
}: {
|
||||
packageName: string;
|
||||
version: string;
|
||||
executablePath: string;
|
||||
}): Promise<string> {
|
||||
installDependencies,
|
||||
}: InstallFromNpmTarballParams): Promise<string> {
|
||||
// Resolve version if it's a range or "latest"
|
||||
let resolvedVersion = version;
|
||||
if (version.startsWith("^") || version.startsWith("~") || version === "latest") {
|
||||
@@ -52,10 +82,7 @@ export async function installFromNpmTarball({
|
||||
if (!registryResponse.ok) {
|
||||
throw new Error(`Failed to query registry: ${registryResponse.status}`);
|
||||
}
|
||||
const registryData = (await registryResponse.json()) as {
|
||||
"dist-tags": { latest: string };
|
||||
versions: Record<string, unknown>;
|
||||
};
|
||||
const registryData = (await registryResponse.json()) as NpmRegistryData;
|
||||
resolvedVersion = registryData["dist-tags"].latest;
|
||||
log.info(`Resolved to version ${resolvedVersion}`);
|
||||
} catch (error) {
|
||||
@@ -68,11 +95,7 @@ export async function installFromNpmTarball({
|
||||
|
||||
log.info(`📦 Installing ${packageName}@${resolvedVersion}...`);
|
||||
|
||||
// Derive temp directory prefix from package name (remove @, replace / with -, add trailing -)
|
||||
const tempDirPrefix = packageName.replace("@", "").replace(/\//g, "-") + "-";
|
||||
|
||||
// Create temp directory
|
||||
const tempDir = await mkdtemp(join(tmpdir(), tempDirPrefix));
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
const tarballPath = join(tempDir, "package.tgz");
|
||||
|
||||
// Download tarball from npm
|
||||
@@ -119,6 +142,22 @@ export async function installFromNpmTarball({
|
||||
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);
|
||||
|
||||
@@ -127,6 +166,72 @@ export async function installFromNpmTarball({
|
||||
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: {
|
||||
...process.env,
|
||||
HOME: tempDir, // Cursor install script uses HOME for installation path
|
||||
},
|
||||
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 agent extends Agent>(agent: agent): agent => {
|
||||
return agent;
|
||||
};
|
||||
|
||||
+6420
-430
File diff suppressed because one or more lines are too long
+39
-4
@@ -1,5 +1,39 @@
|
||||
import { build } from "esbuild";
|
||||
// @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",
|
||||
@@ -29,15 +63,16 @@ const sharedConfig = {
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./entry.ts"],
|
||||
outfile: "./entry.js",
|
||||
outfile: "./entry",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
// Build the MCP server bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./mcp/server.ts"],
|
||||
outfile: "./mcp-server.js",
|
||||
outfile: "./mcp-server",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
console.log("✅ Build completed successfully!");
|
||||
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* ⚠️ 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 { Mode } from "./modes.ts";
|
||||
|
||||
// mcp name constant
|
||||
export const ghPullfrogMcpName = "gh-pullfrog";
|
||||
|
||||
// agent manifest - static metadata about available agents
|
||||
export const agentsManifest = {
|
||||
claude: {
|
||||
name: "claude",
|
||||
apiKeys: ["anthropic_api_key"],
|
||||
},
|
||||
codex: {
|
||||
name: "codex",
|
||||
apiKeys: ["openai_api_key"],
|
||||
},
|
||||
cursor: {
|
||||
name: "cursor",
|
||||
apiKeys: ["cursor_api_key"],
|
||||
},
|
||||
gemini: {
|
||||
name: "gemini",
|
||||
apiKeys: ["google_api_key", "gemini_api_key"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// agent name type - union of agent slugs
|
||||
export type AgentName = keyof typeof agentsManifest;
|
||||
|
||||
// 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.
|
||||
*/
|
||||
readonly event: object;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
create a comment on https://github.com/pullfrogai/scratch/issues/21 with an implementation of an mcp tool for fetching issue comments from github
|
||||
create a comment on https://github.com/pullfrogai/scratch/pull/29 that says ribbit
|
||||
@@ -1,7 +1,13 @@
|
||||
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 { type } from "arktype";
|
||||
import { agents } from "./agents/index.ts";
|
||||
import type { AgentName as AgentNameType, Payload } from "./external.ts";
|
||||
import { createMcpConfigs } from "./mcp/config.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";
|
||||
@@ -13,8 +19,9 @@ import {
|
||||
} from "./utils/github.ts";
|
||||
import { setupGitAuth, 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 AgentName = type.enumerated(...Object.values(agents).map((agent) => agent.name));
|
||||
export type AgentName = typeof AgentName.infer;
|
||||
|
||||
export const AgentInputKey = type.enumerated(
|
||||
...Object.values(agents).flatMap((agent) => agent.inputKeys)
|
||||
@@ -28,7 +35,7 @@ const keyInputDefs = flatMorph(agents, (_, agent) =>
|
||||
export const Inputs = type({
|
||||
prompt: "string",
|
||||
...keyInputDefs,
|
||||
"agent?": AgentName.or("undefined"),
|
||||
"agent?": type.enumerated(...Object.values(agents).map((agent) => agent.name)).or("undefined"),
|
||||
});
|
||||
|
||||
export type Inputs = typeof Inputs.infer;
|
||||
@@ -39,6 +46,36 @@ export interface MainResult {
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
const partialCtx = await initializeContext(inputs);
|
||||
const ctx = partialCtx as MainContext;
|
||||
|
||||
try {
|
||||
await determineAgent(ctx);
|
||||
setupGitAuth(ctx.githubInstallationToken, ctx.repoContext);
|
||||
await setupTempDirectory(ctx);
|
||||
setupMcpLogPolling(ctx);
|
||||
|
||||
ctx.payload = parsePayload(inputs);
|
||||
setupMcpServers(ctx);
|
||||
await installAgentCli(ctx);
|
||||
validateApiKey(ctx);
|
||||
|
||||
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 {
|
||||
await cleanup(partialCtx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error for missing API key with helpful message linking to repo settings
|
||||
*/
|
||||
@@ -88,93 +125,165 @@ To fix this, add the required secret to your GitHub repository:
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
let tokenToRevoke: string | null = null;
|
||||
interface MainContext {
|
||||
inputs: Inputs;
|
||||
githubInstallationToken: string;
|
||||
tokenToRevoke: string | null;
|
||||
repoContext: RepoContext;
|
||||
agentName: AgentNameType;
|
||||
agent: (typeof agents)[AgentNameType];
|
||||
sharedTempDir: string;
|
||||
mcpLogPath: string;
|
||||
pollInterval: NodeJS.Timeout | null;
|
||||
payload: Payload;
|
||||
mcpServers: ReturnType<typeof createMcpConfigs>;
|
||||
cliPath: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
async function initializeContext(
|
||||
inputs: Inputs
|
||||
): Promise<Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">> {
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
Inputs.assert(inputs);
|
||||
setupGitConfig();
|
||||
|
||||
const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken();
|
||||
const tokenToRevoke = wasAcquired ? githubInstallationToken : null;
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
return {
|
||||
inputs,
|
||||
githubInstallationToken,
|
||||
tokenToRevoke,
|
||||
repoContext,
|
||||
agentName: "claude" as AgentNameType,
|
||||
agent: agents.claude,
|
||||
sharedTempDir: "",
|
||||
mcpLogPath: "",
|
||||
pollInterval: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function determineAgent(
|
||||
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
|
||||
): Promise<void> {
|
||||
const repoSettings = await fetchRepoSettings({
|
||||
token: ctx.githubInstallationToken,
|
||||
repoContext: ctx.repoContext,
|
||||
});
|
||||
|
||||
ctx.agentName = ctx.inputs.agent || repoSettings.defaultAgent || "claude";
|
||||
ctx.agent = agents[ctx.agentName];
|
||||
}
|
||||
|
||||
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 {
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
|
||||
Inputs.assert(inputs);
|
||||
setupGitConfig();
|
||||
|
||||
const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken();
|
||||
if (wasAcquired) {
|
||||
tokenToRevoke = githubInstallationToken;
|
||||
const parsedPrompt = JSON.parse(inputs.prompt);
|
||||
if (!("~pullfrog" in parsedPrompt)) {
|
||||
throw new Error();
|
||||
}
|
||||
const repoContext = parseRepoContext();
|
||||
|
||||
const repoSettings = await fetchRepoSettings({
|
||||
token: githubInstallationToken,
|
||||
repoContext,
|
||||
});
|
||||
|
||||
const agentName: AgentName = inputs.agent || repoSettings.defaultAgent || "claude";
|
||||
|
||||
const agent = agents[agentName];
|
||||
|
||||
setupGitAuth(githubInstallationToken, repoContext);
|
||||
|
||||
const mcpServers = createMcpConfigs(githubInstallationToken);
|
||||
|
||||
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
||||
|
||||
// Install agent CLI before running
|
||||
const cliPath = await agent.install();
|
||||
|
||||
log.info(`Running ${agentName}...`);
|
||||
log.box(inputs.prompt, { title: "Prompt" });
|
||||
|
||||
// TODO: check if `inputs.prompts` is JSON
|
||||
// if yes, check if it's a webhook payload or toJSON(github.event)
|
||||
// for webhook payloads, check the specified `agent` field
|
||||
|
||||
const matchingInputKey = agent.inputKeys.find((inputKey) => inputs[inputKey]);
|
||||
|
||||
if (!matchingInputKey) {
|
||||
throwMissingApiKeyError({
|
||||
agentName,
|
||||
inputKeys: agent.inputKeys,
|
||||
repoContext,
|
||||
inputs,
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = inputs[matchingInputKey]!;
|
||||
|
||||
const result = await agent.run({
|
||||
return parsedPrompt as Payload;
|
||||
} catch {
|
||||
return {
|
||||
"~pullfrog": true,
|
||||
agent: null,
|
||||
prompt: inputs.prompt,
|
||||
mcpServers,
|
||||
githubInstallationToken,
|
||||
apiKey,
|
||||
cliPath,
|
||||
});
|
||||
|
||||
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 || "",
|
||||
event: {},
|
||||
modes,
|
||||
};
|
||||
} 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 (tokenToRevoke) {
|
||||
await revokeInstallationToken(tokenToRevoke);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupMcpServers(ctx: MainContext): void {
|
||||
const allModes = [...modes, ...(ctx.payload.modes || [])];
|
||||
ctx.mcpServers = createMcpConfigs(ctx.githubInstallationToken, allModes);
|
||||
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.inputKeys.find(
|
||||
(inputKey: string) => ctx.inputs[inputKey as AgentInputKey]
|
||||
);
|
||||
if (!matchingInputKey) {
|
||||
throwMissingApiKeyError({
|
||||
agentName: ctx.agentName,
|
||||
inputKeys: ctx.agent.inputKeys,
|
||||
repoContext: ctx.repoContext,
|
||||
inputs: ctx.inputs,
|
||||
});
|
||||
}
|
||||
ctx.apiKey = ctx.inputs[matchingInputKey as AgentInputKey]!;
|
||||
}
|
||||
|
||||
async function runAgent(ctx: MainContext): Promise<import("./agents/shared.ts").AgentResult> {
|
||||
log.info(`Running ${ctx.agentName}...`);
|
||||
log.box(ctx.payload.prompt, { 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: import("./agents/shared.ts").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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanup(
|
||||
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
|
||||
): Promise<void> {
|
||||
if (ctx.pollInterval) {
|
||||
clearInterval(ctx.pollInterval);
|
||||
}
|
||||
if (ctx.tokenToRevoke) {
|
||||
await revokeInstallationToken(ctx.tokenToRevoke);
|
||||
}
|
||||
}
|
||||
|
||||
+6112
-287
File diff suppressed because one or more lines are too long
+1
-1
@@ -77,7 +77,7 @@ export const CreateWorkingCommentTool = tool({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
issue_number: issueNumber,
|
||||
body: intent,
|
||||
body: `${intent} <img src="https://pullfrog.ai/party-parrot.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`,
|
||||
});
|
||||
|
||||
workingCommentId = result.data.id;
|
||||
|
||||
+8
-6
@@ -2,22 +2,23 @@
|
||||
* Simple MCP configuration helper for adding our minimal GitHub comment server
|
||||
*/
|
||||
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { fromHere } from "@ark/fs";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import { parseRepoContext } from "../utils/github.ts";
|
||||
import { ghPullfrogMcpName } from "./index.ts";
|
||||
|
||||
export type McpName = typeof ghPullfrogMcpName;
|
||||
|
||||
export type McpConfigs = Record<McpName, McpServerConfig>;
|
||||
export type McpConfigs = Record<McpName, McpStdioServerConfig>;
|
||||
|
||||
export function createMcpConfigs(githubInstallationToken: string): McpConfigs {
|
||||
export function createMcpConfigs(githubInstallationToken: string, modes: Mode[]): McpConfigs {
|
||||
const repoContext = parseRepoContext();
|
||||
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
|
||||
|
||||
// In production (GitHub Actions), mcp-server.js is in same directory as entry.js (where this is bundled)
|
||||
// In production (GitHub Actions), mcp-server is in same directory as entry.js (where this is bundled)
|
||||
// In development, server.ts is in the same directory as this file (config.ts)
|
||||
const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server.js") : fromHere("server.ts");
|
||||
const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server") : fromHere("server.ts");
|
||||
|
||||
return {
|
||||
[ghPullfrogMcpName]: {
|
||||
@@ -26,6 +27,7 @@ export function createMcpConfigs(githubInstallationToken: string): McpConfigs {
|
||||
env: {
|
||||
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
|
||||
GITHUB_REPOSITORY: githubRepository,
|
||||
PULLFROG_MODES: JSON.stringify(modes),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+2
-4
@@ -1,4 +1,2 @@
|
||||
// for reasos this can't live alongide config.ts
|
||||
// because its imported into the frontend UI
|
||||
// and we don't want to pull in FastMCP, etc
|
||||
export const ghPullfrogMcpName = "gh-pullfrog";
|
||||
// re-export from external.ts for backward compatibility
|
||||
export { ghPullfrogMcpName } from "../external.ts";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
+2
-2
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimal GitHub Issue Comment MCP Server
|
||||
import { FastMCP } from "fastmcp";
|
||||
import {
|
||||
CreateCommentTool,
|
||||
@@ -11,6 +9,7 @@ import { IssueTool } from "./issue.ts";
|
||||
import { PullRequestTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import { ReviewTool } from "./review.ts";
|
||||
import { SelectModeTool } from "./selectMode.ts";
|
||||
import { addTools } from "./shared.ts";
|
||||
|
||||
const server = new FastMCP({
|
||||
@@ -19,6 +18,7 @@ const server = new FastMCP({
|
||||
});
|
||||
|
||||
addTools(server, [
|
||||
SelectModeTool,
|
||||
CreateCommentTool,
|
||||
EditCommentTool,
|
||||
CreateWorkingCommentTool,
|
||||
|
||||
+34
-1
@@ -2,6 +2,7 @@ import { cached } from "@ark/util";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
import type { FastMCP, Tool } from "fastmcp";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
|
||||
|
||||
export interface ToolResult {
|
||||
@@ -30,7 +31,39 @@ export interface McpContext extends RepoContext {
|
||||
octokit: Octokit;
|
||||
}
|
||||
|
||||
export const tool = <const params>(tool: Tool<any, StandardSchemaV1<params>>) => tool;
|
||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => {
|
||||
// Wrap the execute function to add logging with the tool name
|
||||
const toolName = toolDef.name;
|
||||
const originalExecute = toolDef.execute;
|
||||
|
||||
toolDef.execute = async (args: params, context: any) => {
|
||||
try {
|
||||
const result = await originalExecute(args, context);
|
||||
// Check if result is a ToolResult with isError property
|
||||
const isError =
|
||||
result && typeof result === "object" && "isError" in result && result.isError === true;
|
||||
const resultData =
|
||||
result && typeof result === "object" && "content" in result
|
||||
? (result as ToolResult).content[0]?.text
|
||||
: undefined;
|
||||
|
||||
if (isError && resultData) {
|
||||
log.toolCall({ toolName, request: args, error: resultData });
|
||||
} else if (resultData) {
|
||||
log.toolCall({ toolName, request: args, result: resultData });
|
||||
} else {
|
||||
log.toolCall({ toolName, request: args });
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.toolCall({ toolName, request: args, error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return toolDef;
|
||||
};
|
||||
|
||||
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
for (const tool of tools) {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { ghPullfrogMcpName } from "./mcp/index.ts";
|
||||
import { ghPullfrogMcpName } from "./external.ts";
|
||||
|
||||
export const modes = [
|
||||
export interface Mode {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const initialCommentInstruction = `Use ${ghPullfrogMcpName}/create_working_comment to create an initial Working Comment that contains a SENTENCE FRAGMENT that casually describes the actions you're about to perform. It must be of the form "Starting work on this issue...". You MUST use an -ing verb!`;
|
||||
|
||||
export const modes: Mode[] = [
|
||||
{
|
||||
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. Create initial response comment using ${ghPullfrogMcpName}/create_working_comment saying "I'll {summary of request}"
|
||||
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
|
||||
@@ -19,7 +28,8 @@ export const modes = [
|
||||
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. Create initial response comment using ${ghPullfrogMcpName}/create_working_comment saying "I'll {summary of request}"
|
||||
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. Understand the requirements and any existing plan
|
||||
4. Make the necessary code changes
|
||||
@@ -32,7 +42,8 @@ export const modes = [
|
||||
description:
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `Follow these steps:
|
||||
1. Create initial response comment using ${ghPullfrogMcpName}/create_working_comment saying "I'll review this"
|
||||
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
|
||||
@@ -46,9 +57,10 @@ export const modes = [
|
||||
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. Create an initial "Working Comment" using ${ghPullfrogMcpName}/create_working_comment saying "I'll {summary of request}"
|
||||
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. 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.
|
||||
4. 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.`,
|
||||
},
|
||||
] as const;
|
||||
];
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.105",
|
||||
"version": "0.0.110",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
@@ -24,12 +24,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.37",
|
||||
"@openai/codex-sdk": "0.58.0",
|
||||
"@ark/fs": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"@openai/codex-sdk": "0.58.0",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"arktype": "2.1.25",
|
||||
"dotenv": "^17.2.3",
|
||||
|
||||
Generated
+137
@@ -11,6 +11,9 @@ importers:
|
||||
'@actions/core':
|
||||
specifier: ^1.11.1
|
||||
version: 1.11.1
|
||||
'@actions/github':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
'@anthropic-ai/claude-agent-sdk':
|
||||
specifier: 0.1.37
|
||||
version: 0.1.37(zod@3.25.76)
|
||||
@@ -75,6 +78,9 @@ packages:
|
||||
'@actions/exec@1.1.1':
|
||||
resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==}
|
||||
|
||||
'@actions/github@6.0.1':
|
||||
resolution: {integrity: sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==}
|
||||
|
||||
'@actions/http-client@2.2.3':
|
||||
resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==}
|
||||
|
||||
@@ -324,10 +330,18 @@ packages:
|
||||
resolution: {integrity: sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@octokit/auth-token@4.0.0':
|
||||
resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/auth-token@6.0.0':
|
||||
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/core@5.2.2':
|
||||
resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/core@7.0.5':
|
||||
resolution: {integrity: sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -336,10 +350,24 @@ packages:
|
||||
resolution: {integrity: sha512-7P1dRAZxuWAOPI7kXfio88trNi/MegQ0IJD3vfgC3b+LZo1Qe6gRJc2v0mz2USWWJOKrB2h5spXCzGbw+fAdqA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/endpoint@9.0.6':
|
||||
resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/graphql@7.1.1':
|
||||
resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/graphql@9.0.2':
|
||||
resolution: {integrity: sha512-iz6KzZ7u95Fzy9Nt2L8cG88lGRMr/qy1Q36ih/XVzMIlPDMYwaNLE/ENhqmIzgPrlNWiYJkwmveEetvxAgFBJw==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/openapi-types@20.0.0':
|
||||
resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==}
|
||||
|
||||
'@octokit/openapi-types@24.2.0':
|
||||
resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==}
|
||||
|
||||
'@octokit/openapi-types@26.0.0':
|
||||
resolution: {integrity: sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==}
|
||||
|
||||
@@ -349,18 +377,34 @@ packages:
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/plugin-paginate-rest@9.2.2':
|
||||
resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '5'
|
||||
|
||||
'@octokit/plugin-request-log@6.0.0':
|
||||
resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@10.4.1':
|
||||
resolution: {integrity: sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '5'
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@16.1.0':
|
||||
resolution: {integrity: sha512-nCsyiKoGRnhH5LkH8hJEZb9swpqOcsW+VXv1QoyUNQXJeVODG4+xM6UICEqyqe9XFr6LkL8BIiFCPev8zMDXPw==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/request-error@5.1.1':
|
||||
resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/request-error@7.0.1':
|
||||
resolution: {integrity: sha512-CZpFwV4+1uBrxu7Cw8E5NCXDWFNf18MSY23TdxCBgjw1tXXHvTrZVsXlW8hgFTOLw8RQR1BBrMvYRtuyaijHMA==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -369,10 +413,20 @@ packages:
|
||||
resolution: {integrity: sha512-TXnouHIYLtgDhKo+N6mXATnDBkV05VwbR0TtMWpgTHIoQdRQfCSzmy/LGqR1AbRMbijq/EckC/E3/ZNcU92NaQ==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/request@8.4.1':
|
||||
resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/rest@22.0.0':
|
||||
resolution: {integrity: sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/types@12.6.0':
|
||||
resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==}
|
||||
|
||||
'@octokit/types@13.10.0':
|
||||
resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==}
|
||||
|
||||
'@octokit/types@15.0.0':
|
||||
resolution: {integrity: sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ==}
|
||||
|
||||
@@ -442,6 +496,9 @@ packages:
|
||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
before-after-hook@2.2.3:
|
||||
resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==}
|
||||
|
||||
before-after-hook@4.0.0:
|
||||
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
|
||||
|
||||
@@ -509,6 +566,9 @@ packages:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
deprecation@2.3.1:
|
||||
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -953,6 +1013,9 @@ packages:
|
||||
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
universal-user-agent@6.0.1:
|
||||
resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==}
|
||||
|
||||
universal-user-agent@7.0.3:
|
||||
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
|
||||
|
||||
@@ -1040,6 +1103,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@actions/io': 1.1.3
|
||||
|
||||
'@actions/github@6.0.1':
|
||||
dependencies:
|
||||
'@actions/http-client': 2.2.3
|
||||
'@octokit/core': 5.2.2
|
||||
'@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.2)
|
||||
'@octokit/plugin-rest-endpoint-methods': 10.4.1(@octokit/core@5.2.2)
|
||||
'@octokit/request': 8.4.1
|
||||
'@octokit/request-error': 5.1.1
|
||||
undici: 5.29.0
|
||||
|
||||
'@actions/http-client@2.2.3':
|
||||
dependencies:
|
||||
tunnel: 0.0.6
|
||||
@@ -1208,8 +1281,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@octokit/auth-token@4.0.0': {}
|
||||
|
||||
'@octokit/auth-token@6.0.0': {}
|
||||
|
||||
'@octokit/core@5.2.2':
|
||||
dependencies:
|
||||
'@octokit/auth-token': 4.0.0
|
||||
'@octokit/graphql': 7.1.1
|
||||
'@octokit/request': 8.4.1
|
||||
'@octokit/request-error': 5.1.1
|
||||
'@octokit/types': 13.10.0
|
||||
before-after-hook: 2.2.3
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/core@7.0.5':
|
||||
dependencies:
|
||||
'@octokit/auth-token': 6.0.0
|
||||
@@ -1225,12 +1310,27 @@ snapshots:
|
||||
'@octokit/types': 15.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/endpoint@9.0.6':
|
||||
dependencies:
|
||||
'@octokit/types': 13.10.0
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/graphql@7.1.1':
|
||||
dependencies:
|
||||
'@octokit/request': 8.4.1
|
||||
'@octokit/types': 13.10.0
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/graphql@9.0.2':
|
||||
dependencies:
|
||||
'@octokit/request': 10.0.5
|
||||
'@octokit/types': 15.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/openapi-types@20.0.0': {}
|
||||
|
||||
'@octokit/openapi-types@24.2.0': {}
|
||||
|
||||
'@octokit/openapi-types@26.0.0': {}
|
||||
|
||||
'@octokit/plugin-paginate-rest@13.2.0(@octokit/core@7.0.5)':
|
||||
@@ -1238,15 +1338,31 @@ snapshots:
|
||||
'@octokit/core': 7.0.5
|
||||
'@octokit/types': 15.0.0
|
||||
|
||||
'@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.2)':
|
||||
dependencies:
|
||||
'@octokit/core': 5.2.2
|
||||
'@octokit/types': 12.6.0
|
||||
|
||||
'@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.5)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.5
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@10.4.1(@octokit/core@5.2.2)':
|
||||
dependencies:
|
||||
'@octokit/core': 5.2.2
|
||||
'@octokit/types': 12.6.0
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@16.1.0(@octokit/core@7.0.5)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.5
|
||||
'@octokit/types': 15.0.0
|
||||
|
||||
'@octokit/request-error@5.1.1':
|
||||
dependencies:
|
||||
'@octokit/types': 13.10.0
|
||||
deprecation: 2.3.1
|
||||
once: 1.4.0
|
||||
|
||||
'@octokit/request-error@7.0.1':
|
||||
dependencies:
|
||||
'@octokit/types': 15.0.0
|
||||
@@ -1259,6 +1375,13 @@ snapshots:
|
||||
fast-content-type-parse: 3.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/request@8.4.1':
|
||||
dependencies:
|
||||
'@octokit/endpoint': 9.0.6
|
||||
'@octokit/request-error': 5.1.1
|
||||
'@octokit/types': 13.10.0
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/rest@22.0.0':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.5
|
||||
@@ -1266,6 +1389,14 @@ snapshots:
|
||||
'@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.5)
|
||||
'@octokit/plugin-rest-endpoint-methods': 16.1.0(@octokit/core@7.0.5)
|
||||
|
||||
'@octokit/types@12.6.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 20.0.0
|
||||
|
||||
'@octokit/types@13.10.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 24.2.0
|
||||
|
||||
'@octokit/types@15.0.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 26.0.0
|
||||
@@ -1337,6 +1468,8 @@ snapshots:
|
||||
|
||||
astral-regex@2.0.0: {}
|
||||
|
||||
before-after-hook@2.2.3: {}
|
||||
|
||||
before-after-hook@4.0.0: {}
|
||||
|
||||
body-parser@2.2.0:
|
||||
@@ -1404,6 +1537,8 @@ snapshots:
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
deprecation@2.3.1: {}
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -1889,6 +2024,8 @@ snapshots:
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
|
||||
universal-user-agent@6.0.1: {}
|
||||
|
||||
universal-user-agent@7.0.3: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
## CURRENT
|
||||
|
||||
[] handle progressive comment updating from pullfrog mcp
|
||||
[] gemini installation speed (bundle/esm.sh?) (TODO: SHAWN)
|
||||
[] handle defaulting agent name value (TODO: SHAWN)
|
||||
|
||||
[] test agent/mode combinations
|
||||
[] test if home directory mcp.json works if mcp.json is specified in repo
|
||||
[] add footer to the working comment ("executed by {agent}", link to pullfrog (homepage) w/ small logo?, feedback (create github issue), link to workflow run)- see https://github.com/colinhacks/zod/issues/5459#issuecomment-3548382991
|
||||
|
||||
## MAYBE
|
||||
|
||||
@@ -21,3 +26,9 @@
|
||||
[x] don't allow rejecting prs
|
||||
[x] fix pnpm caching
|
||||
[x] fix prompt to avoid narration like "I just read all tools from MCP server"
|
||||
[x] handle progressive comment updating from pullfrog mcp
|
||||
[x] jules/gemini support
|
||||
[x] standardize mcp server
|
||||
[x] entry.js
|
||||
[x] split up prompts, load dynamically based on mode
|
||||
[x] log.txt to stdout
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AgentName } from "../main.ts";
|
||||
import type { AgentName } from "../external.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import type { RepoContext } from "./github.ts";
|
||||
|
||||
|
||||
+157
-2
@@ -2,9 +2,11 @@
|
||||
* 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 { spawnSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { table } from "table";
|
||||
|
||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
const isDebugEnabled = process.env.LOG_LEVEL === "debug";
|
||||
@@ -159,6 +161,43 @@ async function summaryTable(
|
||||
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
|
||||
*/
|
||||
@@ -239,6 +278,11 @@ export const log = {
|
||||
*/
|
||||
summaryTable,
|
||||
|
||||
/**
|
||||
* Print a formatted table using the table package
|
||||
*/
|
||||
table: printTable,
|
||||
|
||||
/**
|
||||
* Print a separator line
|
||||
*/
|
||||
@@ -263,8 +307,119 @@ export const log = {
|
||||
* End a collapsed group
|
||||
*/
|
||||
endGroup,
|
||||
|
||||
/**
|
||||
* Log MCP tool call information to mcpLog.txt in the temp directory
|
||||
*/
|
||||
toolCall: ({
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user