Compare commits

...

43 Commits

Author SHA1 Message Date
Colin McDonnell 632fffbfa7 0.0.112 2025-11-21 16:54:03 -08:00
Colin McDonnell 339c0ee276 tweak modes 2025-11-21 16:53:40 -08:00
Colin McDonnell 782902d899 Add logging to Gemini 2025-11-21 15:22:25 -08:00
Colin McDonnell 6ba92cb9d8 standardize tool call logging 2025-11-21 15:22:25 -08:00
Colin McDonnell b6bfcb0cca improve cursor tool call logs 2025-11-21 15:22:25 -08:00
Colin McDonnell b0a404c461 Move agent override to env 2025-11-21 15:22:22 -08:00
Colin McDonnell e24db1155f empty 2025-11-21 15:21:13 -08:00
David Blass f6af7b4215 default agent to null 2025-11-21 16:34:15 -05:00
Shawn Morreau 07fb79056f undo setting ctx.agent early 2025-11-21 15:56:04 -05:00
Shawn Morreau a7551316be merge main 2025-11-21 15:47:20 -05:00
David Blass fda0de8dfe drop inputs.defaultAgent 2025-11-21 15:40:47 -05:00
Shawn Morreau 11e7ae6d18 set default agent based on available agents 2025-11-21 15:37:50 -05:00
Colin McDonnell bef3f7794c WIP 2025-11-21 11:18:00 -08:00
Colin McDonnell 124021eaee REmove todo 2025-11-21 11:18:00 -08:00
Colin McDonnell 192f8a19a0 WIP 2025-11-21 11:18:00 -08:00
Shawn Morreau cb1c5d9734 download gemini from Github 2025-11-21 14:11:20 -05:00
Shawn Morreau 264dcc072c remove stdout interception logic 2025-11-21 14:08:36 -05:00
Shawn Morreau 589592372f github token 2025-11-21 14:00:12 -05:00
Shawn Morreau 99e572194d merge main 2025-11-21 11:08:03 -05:00
Shawn Morreau 8944e7fe08 . 2025-11-21 11:06:37 -05:00
Colin McDonnell 595b246235 update instructions fixtures and comment handling 2025-11-20 18:58:20 -08:00
Colin McDonnell 550a162ca6 fix mcp tools by passing pullfrog_temp_dir to server and handling home directory correctly for codex and cursor 2025-11-20 18:56:45 -08:00
Colin McDonnell 8298cdd07c add urls to agent manifest 2025-11-20 17:06:52 -08:00
Colin McDonnell 935fe26013 Tweak footer 2025-11-20 17:05:42 -08:00
Colin McDonnell dd2089d71b have payload.agent take precedence over inputs.defaultAgent 2025-11-20 16:58:21 -08:00
Colin McDonnell b460bd3109 Flesh out modes 2025-11-20 16:46:13 -08:00
Colin McDonnell 0ce1d9fd7b deterministically set up working branch 2025-11-20 16:31:00 -08:00
Colin McDonnell 6c6b7b0b2d Add footer links 2025-11-20 16:05:07 -08:00
Colin McDonnell f8bb2e12f3 Make PayloadEvent typesafe w/ discriminated union 2025-11-20 15:57:20 -08:00
Colin McDonnell e5878de9e4 Drop usage of execSync, switch to $ util 2025-11-20 15:37:34 -08:00
Shawn Morreau c9aab98389 merge 2025-11-20 16:30:03 -05:00
David Blass 43acacd25a improve types 2025-11-20 16:09:55 -05:00
David Blass 975eaa9a64 use temp dir as home in codex 2025-11-20 15:35:11 -05:00
David Blass ba724c8b71 standardize name to gh_pullfrog 2025-11-20 15:09:12 -05:00
Shawn Morreau 6ef5124e32 Merge branch 'main' of https://github.com/pullfrog/action 2025-11-20 14:55:49 -05:00
David Blass cb938a0b7f try configuring dialect 2025-11-20 14:55:44 -05:00
Shawn Morreau eeed6cfbd0 Merge branch 'main' of https://github.com/pullfrog/action 2025-11-20 14:54:34 -05:00
Shawn Morreau ccf9f46346 Merge branch 'main' of https://github.com/pullfrog/action 2025-11-20 14:09:31 -05:00
Shawn Morreau 8f2d98fe4c Merge branch 'main' of https://github.com/pullfrog/action 2025-11-20 14:04:50 -05:00
Shawn Morreau ed39bda62a sketchy remove 2025-11-20 14:04:47 -05:00
Shawn Morreau 96055edda7 Merge branch 'main' of https://github.com/pullfrog/action 2025-11-20 06:54:04 -05:00
Shawn Morreau 295949c173 use github release for gemini 2025-11-20 06:53:57 -05:00
Shawn Morreau 579c79e38c begin gemini depencency download removal 2025-11-19 17:30:28 -05:00
32 changed files with 4523 additions and 2897 deletions
+4 -26
View File
@@ -62,37 +62,15 @@ const messageHandlers: SDKMessageHandlers = {
if (content.type === "text" && content.text?.trim()) {
log.box(content.text.trim(), { title: "Claude" });
} else if (content.type === "tool_use") {
log.info(`${content.name}`);
// Track bash tool IDs
if (content.name === "bash" && content.id) {
bashToolIds.add(content.id);
}
if (content.input) {
const input = content.input as any;
if (input.description) log.info(` └─ ${input.description}`);
if (input.command) log.info(` └─ command: ${input.command}`);
if (input.file_path) log.info(` └─ file: ${input.file_path}`);
if (input.content) {
const preview =
input.content.length > 100
? `${input.content.substring(0, 100)}...`
: input.content;
log.info(` └─ content: ${preview}`);
}
if (input.query) log.info(` └─ query: ${input.query}`);
if (input.pattern) log.info(` └─ pattern: ${input.pattern}`);
if (input.url) log.info(` └─ url: ${input.url}`);
if (input.edits && Array.isArray(input.edits)) {
log.info(` └─ edits: ${input.edits.length} changes`);
input.edits.forEach((edit: any, index: number) => {
if (edit.file_path) log.info(` ${index + 1}. ${edit.file_path}`);
});
}
if (input.task) log.info(` └─ task: ${input.task}`);
if (input.bash_command) log.info(` └─ bash_command: ${input.bash_command}`);
}
log.toolCall({
toolName: content.name,
input: content.input,
});
}
}
}
+21 -2
View File
@@ -1,4 +1,6 @@
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";
@@ -17,6 +19,13 @@ export const codex = agent({
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
@@ -44,6 +53,7 @@ export const codex = agent({
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);
}
@@ -106,12 +116,21 @@ const messageHandlers: {
"item.started": (event) => {
const item = event.item;
if (item.type === "command_execution") {
log.info(`${item.command}`);
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.info(`${item.tool} (${item.server})`);
log.toolCall({
toolName: item.tool,
input: {
server: item.server,
...((item as any).args || {}),
},
});
}
// Reasoning items are handled on completion for better readability
},
+174 -29
View File
@@ -1,10 +1,135 @@
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 () => {
@@ -20,29 +145,35 @@ export const cursor = agent({
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
const startTime = Date.now();
return new Promise((resolve) => {
const child = spawn(
cliPath,
["--print", fullPrompt, "--output-format", "text", "--approve-mcps", "--force"],
[
"--print",
fullPrompt,
"--output-format",
"stream-json",
"--stream-partial-output",
"--approve-mcps",
"--force",
],
{
cwd: process.cwd(), // Run in current working directory
cwd: process.cwd(),
env: {
...process.env,
CURSOR_API_KEY: apiKey,
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
HOME: tempDir, // Set HOME so Cursor CLI can find .cursor/mcp.json
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
}
@@ -51,41 +182,54 @@ export const cursor = agent({
let stdout = "";
let stderr = "";
// Log when process starts
child.on("spawn", () => {
log.debug("Cursor CLI process spawned");
});
child.stdout?.on("data", (data) => {
child.stdout?.on("data", async (data) => {
const text = data.toString();
stdout += text;
// Stream output in real-time
process.stdout.write(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;
// 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) => {
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");
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: ${errorMessage}`);
log.error(`Cursor CLI failed after ${duration}s: ${errorMessage}`);
resolve({
success: false,
error: errorMessage,
@@ -95,8 +239,9 @@ export const cursor = agent({
});
child.on("error", (error) => {
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
const errorMessage = error.message || String(error);
log.error(`Cursor CLI execution failed: ${errorMessage}`);
log.error(`Cursor CLI execution failed after ${duration}s: ${errorMessage}`);
resolve({
success: false,
error: errorMessage,
@@ -116,14 +261,14 @@ export const cursor = agent({
},
});
/**
* 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");
// 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 });
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
log.info(`MCP config written to ${mcpConfigPath}`);
}
+179 -14
View File
@@ -2,16 +2,154 @@ 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";
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 installFromNpmTarball({
packageName: "@google/gemini-cli",
version: "latest",
executablePath: "dist/index.js",
installDependencies: true,
return await installFromGithub({
owner: "google-gemini",
repo: "gemini-cli",
tag: "v0.16.0",
assetName: "gemini.js",
});
},
run: async ({ payload, apiKey, mcpServers, githubInstallationToken, cliPath }) => {
@@ -31,21 +169,45 @@ export const gemini = agent({
try {
const result = await spawn({
cmd: "node",
args: [cliPath, "--yolo", "--output-format", "text", sessionPrompt],
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!,
},
onStdout: (chunk) => {
const trimmed = chunk.trim();
if (trimmed) {
log.info(trimmed);
finalOutput += trimmed + "\n";
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";
}
@@ -53,7 +215,11 @@ export const gemini = agent({
});
if (result.exitCode !== 0) {
const errorMessage = result.stderr || result.stdout || "Unknown error";
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,
@@ -93,7 +259,6 @@ function configureGeminiMcpServers({ mcpServers, cliPath }: ConfigureMcpServersP
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}`);
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { encode as toonEncode } from "@toon-format/toon";
import type { Payload } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { modes } from "../modes.ts";
@@ -15,6 +16,8 @@ You do not add unecessary comments, tests, or documentation unless explicitly pr
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
@@ -62,4 +65,4 @@ ${[...modes, ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`)
${payload.prompt}
${typeof payload.event === "string" ? payload.event : JSON.stringify(payload.event, null, 2)}`;
${toonEncode(payload.event)}`;
+104 -3
View File
@@ -1,5 +1,7 @@
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 { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk";
@@ -12,8 +14,8 @@ import { log } from "../utils/cli.ts";
*/
export interface AgentResult {
success: boolean;
output?: string;
error?: string;
output?: string | undefined;
error?: string | undefined;
metadata?: Record<string, unknown>;
}
@@ -54,6 +56,17 @@ export interface InstallFromCurlParams {
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
*/
@@ -167,6 +180,91 @@ export async function installFromNpmTarball({
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
@@ -204,8 +302,11 @@ export async function installFromCurl({
const installResult = spawnSync("bash", [installScriptPath], {
cwd: tempDir,
env: {
...process.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",
+2039 -1298
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -7,7 +7,7 @@
import * as core from "@actions/core";
import { flatMorph } from "@ark/util";
import { agents } from "./agents/index.ts";
import { AgentName, type Inputs, main } from "./main.ts";
import { type Inputs, main } from "./main.ts";
import { log } from "./utils/cli.ts";
async function run(): Promise<void> {
@@ -22,7 +22,6 @@ async function run(): Promise<void> {
try {
const inputs: Required<Inputs> = {
prompt: core.getInput("prompt", { required: true }),
agent: core.getInput("agent") ? AgentName.assert(core.getInput("agent")) : undefined,
...flatMorph(agents, (_, agent) =>
agent.apiKeyNames.map((inputKey) => [inputKey, core.getInput(inputKey)])
),
+99 -2
View File
@@ -4,6 +4,7 @@
* 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
@@ -12,6 +13,7 @@ export const ghPullfrogMcpName = "gh_pullfrog";
export interface AgentManifest {
displayName: string;
apiKeyNames: string[];
url: string;
}
// agent manifest - static metadata about available agents
@@ -19,26 +21,121 @@ 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;
@@ -55,9 +152,9 @@ export type Payload = {
/**
* Event data from webhook payload.
* Can be an object (will be JSON.stringify'd) or a string (used as-is).
* Discriminated union based on trigger field.
*/
readonly event: object | string;
readonly event: PayloadEvent;
/**
* Execution mode configuration
+1 -1
View File
@@ -1 +1 @@
create a comment on https://github.com/pullfrogai/scratch/pull/29 that says ribbit
write a comment to https://github.com/pullfrogai/scratch/pull/29 that tells a joke
+123 -61
View File
@@ -3,9 +3,12 @@ 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 { agents } from "./agents/index.ts";
import type { AgentName as AgentNameType, Payload } from "./external.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 { modes } from "./modes.ts";
import packageJson from "./package.json" with { type: "json" };
@@ -17,11 +20,10 @@ import {
revokeInstallationToken,
setupGitHubInstallationToken,
} from "./utils/github.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.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 AgentName = type.enumerated(...Object.values(agents).map((agent) => agent.name));
export const AgentInputKey = type.enumerated(
...Object.values(agents).flatMap((agent) => agent.apiKeyNames)
@@ -35,7 +37,6 @@ const keyInputDefs = flatMorph(agents, (_, agent) =>
export const Inputs = type({
prompt: "string",
...keyInputDefs,
"agent?": type.enumerated(...Object.values(agents).map((agent) => agent.name)).or("undefined"),
});
export type Inputs = typeof Inputs.infer;
@@ -47,16 +48,39 @@ export interface MainResult {
}
export async function main(inputs: Inputs): Promise<MainResult> {
const partialCtx = await initializeContext(inputs);
const ctx = partialCtx as MainContext;
let githubInstallationToken: string | undefined;
let pollInterval: NodeJS.Timeout | null = null;
try {
await determineAgent(ctx);
// parse payload early to extract agent
const payload = parsePayload(inputs);
// resolve agent before initializing context
githubInstallationToken = await setupGitHubInstallationToken();
const repoContext = parseRepoContext();
const { agentName, agent } = await resolveAgent(
inputs,
payload,
githubInstallationToken,
repoContext
);
const partialCtx = await initializeContext(
inputs,
agentName,
agent,
githubInstallationToken,
repoContext
);
const ctx = partialCtx as MainContext;
ctx.payload = payload;
setupGitAuth(ctx.githubInstallationToken, ctx.repoContext);
await setupTempDirectory(ctx);
setupMcpLogPolling(ctx);
pollInterval = ctx.pollInterval;
ctx.payload = parsePayload(inputs);
setupGitBranch(ctx.payload);
setupMcpServers(ctx);
await installAgentCli(ctx);
validateApiKey(ctx);
@@ -72,10 +96,35 @@ export async function main(inputs: Inputs): Promise<MainResult> {
error: errorMessage,
};
} finally {
await cleanup(partialCtx);
if (pollInterval) {
clearInterval(pollInterval);
}
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
*/
@@ -83,12 +132,10 @@ function throwMissingApiKeyError({
agentName,
inputKeys,
repoContext,
inputs,
}: {
agentName: string;
agentName: string | null;
inputKeys: string[];
repoContext: RepoContext;
inputs: Inputs;
}): never {
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`;
@@ -100,12 +147,11 @@ function throwMissingApiKeyError({
const githubRepoUrl = `https://github.com/${repoContext.owner}/${repoContext.name}`;
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
// Find which agents have inputKeys that match the provided inputs
const availableAgents = Object.values(agents).filter((agent) =>
agent.apiKeyNames.some((inputKey) => inputs[inputKey])
);
let message = `Pullfrog is configured to use ${agentName}, but the associated API key was not provided.
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:
@@ -115,10 +161,8 @@ To fix this, add the required secret to your GitHub repository:
4. Set the value to your API key
5. Click "Add secret"`;
// If other credentials are present, suggest alternative agents
if (availableAgents.length > 0) {
const agentNames = availableAgents.map((agent) => agent.name).join(", ");
message += `\n\nAlternatively, configure Pullfrog to use an agent with existing credentials in your environment (${agentNames}) at ${settingsUrl}`;
if (agentName === null) {
message += `\n\nAlternatively, configure Pullfrog to use an agent at ${settingsUrl}`;
}
log.error(message);
@@ -128,7 +172,6 @@ To fix this, add the required secret to your GitHub repository:
interface MainContext {
inputs: Inputs;
githubInstallationToken: string;
tokenToRevoke: string | null;
repoContext: RepoContext;
agentName: AgentNameType;
agent: (typeof agents)[AgentNameType];
@@ -142,39 +185,68 @@ interface MainContext {
}
async function initializeContext(
inputs: Inputs
inputs: Inputs,
agentName: AgentNameType,
agent: (typeof agents)[AgentNameType],
githubInstallationToken: string,
repoContext: RepoContext
): 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,
agentName,
agent,
sharedTempDir: "",
mcpLogPath: "",
pollInterval: null,
};
}
async function determineAgent(
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
): Promise<void> {
async function resolveAgent(
inputs: Inputs,
payload: Payload,
githubInstallationToken: string,
repoContext: RepoContext
): Promise<{ agentName: AgentNameType; agent: (typeof agents)[AgentNameType] }> {
const repoSettings = await fetchRepoSettings({
token: ctx.githubInstallationToken,
repoContext: ctx.repoContext,
token: githubInstallationToken,
repoContext,
});
ctx.agentName = ctx.inputs.agent || repoSettings.defaultAgent || "claude";
ctx.agent = agents[ctx.agentName];
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(
@@ -213,7 +285,9 @@ function parsePayload(inputs: Inputs): Payload {
"~pullfrog": true,
agent: null,
prompt: inputs.prompt,
event: {},
event: {
trigger: "unknown",
},
modes,
};
}
@@ -221,7 +295,7 @@ function parsePayload(inputs: Inputs): Payload {
function setupMcpServers(ctx: MainContext): void {
const allModes = [...modes, ...(ctx.payload.modes || [])];
ctx.mcpServers = createMcpConfigs(ctx.githubInstallationToken, allModes);
ctx.mcpServers = createMcpConfigs(ctx.githubInstallationToken, allModes, ctx.payload);
log.debug(`📋 MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
}
@@ -230,23 +304,24 @@ async function installAgentCli(ctx: MainContext): Promise<void> {
}
function validateApiKey(ctx: MainContext): void {
const matchingInputKey = ctx.agent.apiKeyNames.find(
(inputKey: string) => ctx.inputs[inputKey as AgentInputKey]
);
const matchingInputKey = ctx.agent.apiKeyNames.find((inputKey) => ctx.inputs[inputKey]);
if (!matchingInputKey) {
throwMissingApiKeyError({
agentName: ctx.agentName,
inputKeys: ctx.agent.apiKeyNames,
repoContext: ctx.repoContext,
inputs: ctx.inputs,
});
}
ctx.apiKey = ctx.inputs[matchingInputKey as AgentInputKey]!;
ctx.apiKey = ctx.inputs[matchingInputKey]!;
}
async function runAgent(ctx: MainContext): Promise<import("./agents/shared.ts").AgentResult> {
async function runAgent(ctx: MainContext): Promise<AgentResult> {
log.info(`Running ${ctx.agentName}...`);
log.box(ctx.payload.prompt, { title: "Prompt" });
// 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,
@@ -257,9 +332,7 @@ async function runAgent(ctx: MainContext): Promise<import("./agents/shared.ts").
});
}
async function handleAgentResult(
result: import("./agents/shared.ts").AgentResult
): Promise<MainResult> {
async function handleAgentResult(result: AgentResult): Promise<MainResult> {
if (!result.success) {
return {
success: false,
@@ -276,14 +349,3 @@ async function handleAgentResult(
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);
}
}
+1420 -1301
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,4 +1,4 @@
# gh-pullfrog MCP Tools
# gh_pullfrog MCP Tools
this directory contains the mcp (model context protocol) server tools for interacting with github.
@@ -22,7 +22,7 @@ all logs from all failed workflow runs in the check suite, including:
**example:**
```typescript
// when handling a check_suite_completed webhook
await mcp.call("gh-pullfrog/get_check_suite_logs", {
await mcp.call("gh_pullfrog/get_check_suite_logs", {
check_suite_id: check_suite.id
});
```
@@ -48,7 +48,7 @@ array of review comments including:
**example:**
```typescript
// when handling a pull_request_review_submitted webhook
await mcp.call("gh-pullfrog/get_review_comments", {
await mcp.call("gh_pullfrog/get_review_comments", {
pull_number: 47,
review_id: review.id
});
@@ -69,7 +69,7 @@ array of reviews with:
**example:**
```typescript
await mcp.call("gh-pullfrog/list_pull_request_reviews", {
await mcp.call("gh_pullfrog/list_pull_request_reviews", {
pull_number: 47
});
```
+7
View File
@@ -0,0 +1,7 @@
import { configure } from "arktype/config";
configure({
toJsonSchema: {
dialect: null,
},
});
+53 -4
View File
@@ -1,6 +1,46 @@
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
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"),
@@ -11,11 +51,13 @@ export const CreateCommentTool = tool({
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: body,
body: bodyWithFooter,
});
return {
@@ -37,11 +79,13 @@ export const EditCommentTool = tool({
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: body,
body: bodyWithFooter,
});
return {
@@ -73,11 +117,14 @@ export const CreateWorkingCommentTool = tool({
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: `${intent} <img src="https://pullfrog.ai/party-parrot.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`,
body: bodyWithFooter,
});
workingCommentId = result.data.id;
@@ -104,11 +151,13 @@ export const UpdateWorkingCommentTool = tool({
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,
body: bodyWithFooter,
});
return {
+20 -6
View File
@@ -4,6 +4,7 @@
import type { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { fromHere } from "@ark/fs";
import type { Payload } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import { parseRepoContext } from "../utils/github.ts";
@@ -12,7 +13,11 @@ export type McpName = typeof ghPullfrogMcpName;
export type McpConfigs = Record<McpName, McpStdioServerConfig>;
export function createMcpConfigs(githubInstallationToken: string, modes: Mode[]): McpConfigs {
export function createMcpConfigs(
githubInstallationToken: string,
modes: Mode[],
payload: Payload
): McpConfigs {
const repoContext = parseRepoContext();
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
@@ -20,15 +25,24 @@ export function createMcpConfigs(githubInstallationToken: string, modes: Mode[])
// In development, server.ts is in the same directory as this file (config.ts)
const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server") : fromHere("server.ts");
const env: Record<string, string> = {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
PULLFROG_MODES: JSON.stringify(modes),
PULLFROG_PAYLOAD: JSON.stringify(payload),
PULLFROG_TEMP_DIR: process.env.PULLFROG_TEMP_DIR!,
};
// pass through GITHUB_RUN_ID if available (automatically set in GitHub Actions)
if (process.env.GITHUB_RUN_ID) {
env.GITHUB_RUN_ID = process.env.GITHUB_RUN_ID;
}
return {
[ghPullfrogMcpName]: {
command: "node",
args: [serverPath],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
PULLFROG_MODES: JSON.stringify(modes),
},
env,
},
};
}
+10 -5
View File
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts";
export const ListFiles = type({
@@ -17,14 +17,19 @@ export const ListFilesTool = tool({
try {
// Use git ls-files to list tracked files
// This respects .gitignore and gives a clean list of source files
const command = path === "." ? "git ls-files" : `git ls-files ${path}`;
const output = execSync(command, { encoding: "utf-8" });
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 findCmd = `find ${path} -maxdepth 3 -not -path '*/.*' -type f`;
const findOutput = execSync(findCmd, { encoding: "utf-8" });
const findOutput = $(
"find",
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
{ log: false }
);
return {
files: findOutput.split("\n").filter((f) => f.trim() !== ""),
method: "find",
+2 -4
View File
@@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
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({
@@ -15,9 +15,7 @@ export const PullRequestTool = tool({
parameters: PullRequest,
execute: contextualize(async ({ title, body, base }, ctx) => {
// Get the current branch name
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
encoding: "utf8",
}).trim();
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
log.info(`Current branch: ${currentBranch}`);
+4 -4
View File
@@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
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({
@@ -30,14 +30,14 @@ export const PullRequestInfoTool = tool({
// Automatically fetch and checkout branches for review
log.info(`Fetching base branch: origin/${baseBranch}`);
execSync(`git fetch origin ${baseBranch} --depth=20`, { stdio: "inherit" });
$("git", ["fetch", "origin", baseBranch, "--depth=20"]);
log.info(`Fetching PR branch: origin/${headBranch}`);
execSync(`git fetch origin ${headBranch}`, { stdio: "inherit" });
$("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
execSync(`git checkout -B ${headBranch} origin/${headBranch}`, { stdio: "inherit" });
$("git", ["checkout", "-B", headBranch, `origin/${headBranch}`]);
return {
number: data.number,
+2 -1
View File
@@ -1,3 +1,5 @@
import "./arkConfig.ts";
// this must be imported first
import { FastMCP } from "fastmcp";
import { ghPullfrogMcpName } from "../external.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
@@ -27,7 +29,6 @@ addTools(server, [
CreateWorkingCommentTool,
UpdateWorkingCommentTool,
IssueTool,
// ListFilesTool,
PullRequestTool,
ReviewTool,
PullRequestInfoTool,
+39 -19
View File
@@ -1,7 +1,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 type { Payload } from "../external.ts";
import { log } from "../utils/cli.ts";
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
@@ -13,7 +13,22 @@ export interface ToolResult {
isError?: boolean;
}
export const getMcpContext = cached((): McpContext => {
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");
@@ -24,11 +39,13 @@ export const getMcpContext = cached((): McpContext => {
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>>) => {
@@ -39,25 +56,28 @@ export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>)
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 });
}
// TOOL CALL LOGGING DISABLED — now handled by each agent
// 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 });
// const errorMessage = error instanceof Error ? error.message : String(error);
// log.toolCall({ toolName, request: args, error: errorMessage });
throw error;
}
};
+56 -24
View File
@@ -9,32 +9,31 @@ export interface Mode {
const initialCommentInstruction = `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: "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: "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. Understand the requirements and any existing plan
4. Make the necessary code changes
5. Test your changes to ensure they work correctly
6. Update your comment using ${ghPullfrogMcpName}/update_working_comment to share progress and results
7. Continue updating the same comment as you make progress (never create additional comments - always use update_working_comment)`,
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",
@@ -42,14 +41,39 @@ export const modes: Mode[] = [
"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",
@@ -57,9 +81,17 @@ export const modes: Mode[] = [
"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. 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.`,
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.`,
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.111",
"version": "0.0.112",
"type": "module",
"files": [
"index.js",
+6 -4
View File
@@ -6,15 +6,15 @@ import { flatMorph } from "@ark/util";
import arg from "arg";
import { config } from "dotenv";
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
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
export async function run(prompt: string): Promise<AgentResult> {
try {
const tempDir = join(process.cwd(), ".temp");
setupTestRepo({ tempDir, forceClean: true });
@@ -22,9 +22,11 @@ export async function run(
const originalCwd = process.cwd();
process.chdir(tempDir);
// 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,
agent: "claude",
...flatMorph(agents, (_, agent) =>
agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]])
),
+10 -11
View File
@@ -2,15 +2,14 @@ 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;
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;
}
}
console.log(findCliPath("codei"));
return null;
}
console.log(findCliPath("codei"));
-38
View File
@@ -1,38 +0,0 @@
## CURRENT
[] 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
[] avoid passing all of process.env into agents: minimum # of vars
[] toon encode in prompt
## MAYBE
[] investigate repo config file
[] try to find heavy claude code user
[] investigate including terminal output from bash commands as collapsed groups from claude
[] test initialization trade offs for pullfrog.yml
## DONE
[x] investigate mcp naming convention
[x] input just accepts one key for API key
[x] look into trigger.yml without installation
[x] cancel installation token at the end of github action
[x] avoid exposing env adding ## SECURITY prompt
[x] add modes to prompt
[x] progressively update comment
[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
[x] rename mcp to use underscore
[x] external.ts align to agents
-3
View File
@@ -1,5 +1,4 @@
import type { AgentName } from "../external.ts";
import { log } from "./cli.ts";
import type { RepoContext } from "./github.ts";
export interface Mode {
@@ -36,9 +35,7 @@ export async function fetchRepoSettings({
token: string;
repoContext: RepoContext;
}): Promise<RepoSettings> {
log.info("Fetching repository settings...");
const settings = await getRepoSettings(token, repoContext);
log.info("Repository settings fetched");
return settings;
}
+24 -6
View File
@@ -9,7 +9,7 @@ import * as core from "@actions/core";
import { table } from "table";
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
const isDebugEnabled = process.env.LOG_LEVEL === "debug";
const isDebugEnabled = () => process.env.LOG_LEVEL === "debug";
/**
* Start a collapsed group (GitHub Actions) or regular group (local)
@@ -79,7 +79,11 @@ function boxString(
}
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
const boxWidth = maxLineLength + padding * 2;
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 = "";
@@ -258,7 +262,7 @@ export const log = {
* Print debug message (only if LOG_LEVEL=debug)
*/
debug: (message: string): void => {
if (isDebugEnabled) {
if (isDebugEnabled()) {
if (isGitHubActions) {
core.debug(message);
} else {
@@ -308,10 +312,24 @@ export const log = {
*/
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
*/
toolCall: ({
toolCallToFile: ({
toolName,
request,
result,
@@ -345,7 +363,7 @@ function getMcpLogPath(): string {
/**
* Format a value as JSON, using compact format for simple values and pretty-printed for complex ones
*/
function formatJsonValue(value: unknown): string {
export function formatJsonValue(value: unknown): string {
const compact = JSON.stringify(value);
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
}
@@ -354,7 +372,7 @@ function formatJsonValue(value: unknown): string {
* 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 {
export function formatIndentedField(label: string, content: string): string {
if (!content.includes("\n")) {
return ` ${label}: ${content}\n`;
}
+2 -19
View File
@@ -43,12 +43,6 @@ 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);
}
@@ -249,24 +243,13 @@ async function acquireNewToken(): Promise<string> {
/**
* Setup GitHub installation token for the action
* Returns the token and whether it was acquired (needs revocation)
*/
export async function setupGitHubInstallationToken(): Promise<{
githubInstallationToken: string;
wasAcquired: boolean;
}> {
const existingToken = checkExistingToken();
if (existingToken) {
core.setSecret(existingToken);
log.info("Using provided GitHub installation token");
return { githubInstallationToken: existingToken, wasAcquired: false };
}
export async function setupGitHubInstallationToken(): Promise<string> {
const acquiredToken = await acquireNewToken();
core.setSecret(acquiredToken);
process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken;
return { githubInstallationToken: acquiredToken, wasAcquired: true };
return acquiredToken;
}
/**
+38 -3
View File
@@ -1,7 +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;
@@ -25,7 +27,7 @@ export function setupTestRepo(options: SetupOptions): void {
rmSync(tempDir, { recursive: true, force: true });
log.info("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
$("git", ["clone", repoUrl, tempDir]);
} else {
log.info("📦 Resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
@@ -35,7 +37,7 @@ export function setupTestRepo(options: SetupOptions): void {
}
} else {
log.info("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
$("git", ["clone", repoUrl, tempDir]);
}
}
@@ -87,6 +89,39 @@ export function setupGitAuth(githubToken: string, repoContext: RepoContext): voi
// Update remote URL to embed the token
const remoteUrl = `https://x-access-token:${githubToken}@github.com/${repoContext.owner}/${repoContext.name}.git`;
execSync(`git remote set-url origin "${remoteUrl}"`, { stdio: "inherit" });
$("git", ["remote", "set-url", "origin", remoteUrl]);
log.info("✓ Updated remote URL with authentication token");
}
/**
* 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;
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}`, { stdio: "pipe" });
// Checkout the branch, creating local tracking branch
log.debug(`Checking out branch: ${branch}`);
execSync(`git checkout -B ${branch} origin/${branch}`, { 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)}`
);
}
}
+71
View File
@@ -0,0 +1,71 @@
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";
const result = spawnSync(cmd, args, {
stdio: ["inherit", "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"
// Only log if log option is not explicitly set to false
if (options?.log !== false) {
if (stdout) {
process.stdout.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();
}
+5 -1
View File
@@ -29,8 +29,12 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
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(),
});