tool call logging, centralized temp dir

This commit is contained in:
David Blass
2025-11-19 18:26:15 -05:00
parent 4b43b617f0
commit 7e0dcd5374
13 changed files with 781 additions and 354 deletions
+8 -4
View File
@@ -46,13 +46,17 @@ 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.
Available modes:
${[...modes, ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
## Modes
${[...modes, ...payload.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
************* USER PROMPT *************
+3 -14
View File
@@ -1,7 +1,5 @@
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";
@@ -97,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
@@ -183,12 +177,7 @@ export async function installFromCurl({
}: InstallFromCurlParams): Promise<string> {
log.info(`📦 Installing ${executableName}...`);
// Derive temp directory prefix from executable name (sanitize similar to package name)
// Replace any special characters with - and ensure trailing -
const tempDirPrefix = executableName.replace(/[^a-zA-Z0-9]/g, "-") + "-";
// Create temp directory
const tempDir = await mkdtemp(join(tmpdir(), tempDirPrefix));
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const installScriptPath = join(tempDir, "install.sh");
// Download the install script
@@ -206,7 +195,7 @@ export async function installFromCurl({
// Make install script executable
chmodSync(installScriptPath, 0o755);
log.info("Installing to temp directory...");
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}
+262 -118
View File
@@ -18146,7 +18146,7 @@ var require_summary = __commonJS({
exports.summary = exports.markdownSummary = exports.SUMMARY_DOCS_URL = exports.SUMMARY_ENV_VAR = void 0;
var os_1 = __require("os");
var fs_1 = __require("fs");
var { access, appendFile, writeFile } = fs_1.promises;
var { access, appendFile, writeFile: writeFile2 } = fs_1.promises;
exports.SUMMARY_ENV_VAR = "GITHUB_STEP_SUMMARY";
exports.SUMMARY_DOCS_URL = "https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary";
var Summary = class {
@@ -18204,7 +18204,7 @@ var require_summary = __commonJS({
return __awaiter(this, void 0, void 0, function* () {
const overwrite = !!(options === null || options === void 0 ? void 0 : options.overwrite);
const filePath2 = yield this.filePath();
const writeFunc = overwrite ? writeFile : appendFile;
const writeFunc = overwrite ? writeFile2 : appendFile;
yield writeFunc(filePath2, this._buffer, { encoding: "utf8" });
return this.emptyBuffer();
});
@@ -26991,13 +26991,13 @@ var ProcessTransport = class {
this.logForDebugging(data.toString());
});
}
const cleanup = () => {
const cleanup2 = () => {
if (this.child && !this.child.killed) {
this.child.kill("SIGTERM");
}
};
this.processExitHandler = cleanup;
this.abortHandler = cleanup;
this.processExitHandler = cleanup2;
this.abortHandler = cleanup2;
process.on("exit", this.processExitHandler);
this.abortController.signal.addEventListener("abort", this.abortHandler);
this.child.on("error", (error2) => {
@@ -28223,15 +28223,15 @@ var Query = class {
const messageId = "id" in mcpRequest.message ? mcpRequest.message.id : null;
const key = `${serverName}:${messageId}`;
return new Promise((resolve, reject) => {
const cleanup = () => {
const cleanup2 = () => {
this.pendingMcpResponses.delete(key);
};
const resolveAndCleanup = (response) => {
cleanup();
cleanup2();
resolve(response);
};
const rejectAndCleanup = (error2) => {
cleanup();
cleanup2();
reject(error2);
};
this.pendingMcpResponses.set(key, {
@@ -28241,7 +28241,7 @@ var Query = class {
if (transport.onmessage) {
transport.onmessage(mcpRequest.message);
} else {
cleanup();
cleanup2();
reject(new Error("No message handler registered"));
return;
}
@@ -32888,7 +32888,7 @@ function query({
// package.json
var package_default = {
name: "@pullfrog/action",
version: "0.0.107",
version: "0.0.108",
type: "module",
files: [
"index.js",
@@ -32962,6 +32962,8 @@ var package_default = {
// utils/cli.ts
var core = __toESM(require_core(), 1);
import { appendFileSync as appendFileSync2, existsSync as existsSync2 } from "node:fs";
import { join as join4 } from "node:path";
var isGitHubActions = !!process.env.GITHUB_ACTIONS;
var isDebugEnabled = process.env.LOG_LEVEL === "debug";
function startGroup2(name) {
@@ -33157,8 +33159,82 @@ var log = {
/**
* End a collapsed group
*/
endGroup: endGroup2
endGroup: endGroup2,
/**
* Log MCP tool call information to mcpLog.txt in the temp directory
*/
toolCall: ({
toolName,
request,
result,
error: error2
}) => {
const logPath = getMcpLogPath();
const params = { toolName, request };
if (error2) {
params.error = error2;
} else if (result) {
params.result = result;
}
const logEntry = formatToolCall(params);
appendFileSync2(logPath, logEntry, "utf-8");
}
};
function getMcpLogPath() {
const tempDir = process.env.PULLFROG_TEMP_DIR;
return join4(tempDir, "mcpLog.txt");
}
function formatJsonValue(value2) {
const compact = JSON.stringify(value2);
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
}
function formatIndentedField(label, content) {
if (!content.includes("\n")) {
return ` ${label}: ${content}
`;
}
const lines = content.split("\n");
let formatted = ` ${label}: ${lines[0]}
`;
for (let i = 1; i < lines.length; i++) {
formatted += ` ${lines[i]}
`;
}
return formatted;
}
function formatToolInput(request) {
const requestFormatted = formatJsonValue(request);
if (requestFormatted === "{}") {
return "";
}
return formatIndentedField("input", requestFormatted);
}
function formatToolResult(result) {
try {
const parsed2 = JSON.parse(result);
const formatted = formatJsonValue(parsed2);
return formatIndentedField("result", formatted);
} catch {
return formatIndentedField("result", result);
}
}
function formatToolCall({
toolName,
request,
result,
error: error2
}) {
let logEntry = `\u2192 ${toolName}
`;
logEntry += formatToolInput(request);
if (error2) {
logEntry += formatIndentedField("error", error2);
} else if (result) {
logEntry += formatToolResult(result);
}
logEntry += "\n";
return logEntry;
}
// mcp/index.ts
var ghPullfrogMcpName = "gh-pullfrog";
@@ -33257,15 +33333,17 @@ 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.
Available modes:
${[...modes, ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
## Modes
${[...modes, ...payload.modes].map((w) => `### ${w.name}
${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
************* USER PROMPT *************
@@ -33275,10 +33353,8 @@ ${JSON.stringify(payload.event, null, 2)}`;
// agents/shared.ts
import { spawnSync } from "node:child_process";
import { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync2 } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join as join4 } from "node:path";
import { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync3 } from "node:fs";
import { join as join5 } from "node:path";
import { pipeline } from "node:stream/promises";
async function installFromNpmTarball({
packageName,
@@ -33306,9 +33382,8 @@ async function installFromNpmTarball({
}
}
log.info(`\u{1F4E6} Installing ${packageName}@${resolvedVersion}...`);
const tempDirPrefix = packageName.replace("@", "").replace(/\//g, "-") + "-";
const tempDir = await mkdtemp(join4(tmpdir(), tempDirPrefix));
const tarballPath = join4(tempDir, "package.tgz");
const tempDir = process.env.PULLFROG_TEMP_DIR;
const tarballPath = join5(tempDir, "package.tgz");
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
let tarballUrl;
if (packageName.startsWith("@")) {
@@ -33337,9 +33412,9 @@ async function installFromNpmTarball({
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
);
}
const extractedDir = join4(tempDir, "package");
const cliPath = join4(extractedDir, executablePath);
if (!existsSync2(cliPath)) {
const extractedDir = join5(tempDir, "package");
const cliPath = join5(extractedDir, executablePath);
if (!existsSync3(cliPath)) {
throw new Error(`Executable not found in extracted package at ${cliPath}`);
}
if (installDependencies) {
@@ -33365,9 +33440,8 @@ async function installFromCurl({
executableName
}) {
log.info(`\u{1F4E6} Installing ${executableName}...`);
const tempDirPrefix = executableName.replace(/[^a-zA-Z0-9]/g, "-") + "-";
const tempDir = await mkdtemp(join4(tmpdir(), tempDirPrefix));
const installScriptPath = join4(tempDir, "install.sh");
const tempDir = process.env.PULLFROG_TEMP_DIR;
const installScriptPath = join5(tempDir, "install.sh");
log.info(`Downloading install script from ${installUrl}...`);
const installScriptResponse = await fetch(installUrl);
if (!installScriptResponse.ok) {
@@ -33378,7 +33452,7 @@ async function installFromCurl({
await pipeline(installScriptResponse.body, fileStream);
log.info(`Downloaded install script to ${installScriptPath}`);
chmodSync(installScriptPath, 493);
log.info("Installing to temp directory...");
log.info(`Installing to temp directory at ${tempDir}...`);
const installResult = spawnSync("bash", [installScriptPath], {
cwd: tempDir,
env: {
@@ -33395,8 +33469,8 @@ async function installFromCurl({
`Failed to install ${executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}`
);
}
const cliPath = join4(tempDir, ".local", "bin", executableName);
if (!existsSync2(cliPath)) {
const cliPath = join5(tempDir, ".local", "bin", executableName);
if (!existsSync3(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
}
chmodSync(cliPath, 493);
@@ -33554,7 +33628,7 @@ async function createOutputSchemaFile(schema2) {
}
const schemaDir = await fs2.mkdtemp(path.join(os.tmpdir(), "codex-output-schema-"));
const schemaPath = path.join(schemaDir, "schema.json");
const cleanup = async () => {
const cleanup2 = async () => {
try {
await fs2.rm(schemaDir, { recursive: true, force: true });
} catch {
@@ -33562,9 +33636,9 @@ async function createOutputSchemaFile(schema2) {
};
try {
await fs2.writeFile(schemaPath, JSON.stringify(schema2), "utf8");
return { schemaPath, cleanup };
return { schemaPath, cleanup: cleanup2 };
} catch (error2) {
await cleanup();
await cleanup2();
throw error2;
}
}
@@ -33592,7 +33666,7 @@ var Thread = class {
return { events: this.runStreamedInternal(input, turnOptions) };
}
async *runStreamedInternal(input, turnOptions = {}) {
const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema);
const { schemaPath, cleanup: cleanup2 } = await createOutputSchemaFile(turnOptions.outputSchema);
const options = this._threadOptions;
const { prompt, images } = normalizeInput(input);
const generator = this._exec.run({
@@ -33625,7 +33699,7 @@ var Thread = class {
yield parsed2;
}
} finally {
await cleanup();
await cleanup2();
}
}
/** Provides the input to the agent and returns the completed turn. */
@@ -34008,7 +34082,7 @@ function configureCodexMcpServers({ mcpServers, cliPath }) {
// agents/cursor.ts
import { spawn as spawn3 } from "node:child_process";
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
import { join as join5 } from "node:path";
import { join as join6 } from "node:path";
var cursor = agent({
name: "cursor",
inputKeys: ["cursor_api_key"],
@@ -34103,8 +34177,8 @@ var cursor = agent({
});
function configureCursorMcpServers({ mcpServers, cliPath }) {
const tempDir = cliPath.split("/.local/bin/")[0];
const cursorConfigDir = join5(tempDir, ".cursor");
const mcpConfigPath = join5(cursorConfigDir, "mcp.json");
const cursorConfigDir = join6(tempDir, ".cursor");
const mcpConfigPath = join6(cursorConfigDir, "mcp.json");
mkdirSync2(cursorConfigDir, { recursive: true });
writeFileSync2(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
}
@@ -34289,6 +34363,12 @@ var agents = {
gemini
};
// main.ts
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join as join8 } from "node:path";
// node_modules/.pnpm/@ark+schema@0.53.0/node_modules/@ark/schema/out/shared/registry.js
var _registryName = "$ark";
var suffix = 2;
@@ -42016,7 +42096,7 @@ var caller = (options = {}) => {
};
// node_modules/.pnpm/@ark+fs@0.53.0/node_modules/@ark/fs/out/fs.js
import { dirname as dirname2, join as join6, parse } from "node:path";
import { dirname as dirname2, join as join7, parse } from "node:path";
import * as process3 from "node:process";
import { URL as URL2, fileURLToPath as fileURLToPath4 } from "node:url";
var filePath = (path4) => {
@@ -42030,7 +42110,7 @@ var filePath = (path4) => {
return file;
};
var dirOfCaller = () => dirname2(filePath(caller({ methodName: "dirOfCaller", upStackBy: 1 }).file));
var fromHere = (...joinWith) => join6(dirOfCaller(), ...joinWith);
var fromHere = (...joinWith) => join7(dirOfCaller(), ...joinWith);
var fsRoot = parse(process3.cwd()).root;
// utils/github.ts
@@ -42222,7 +42302,7 @@ function parseRepoContext() {
}
// mcp/config.ts
function createMcpConfigs(githubInstallationToken) {
function createMcpConfigs(githubInstallationToken, modes2) {
const repoContext = parseRepoContext();
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server.js") : fromHere("server.ts");
@@ -42232,7 +42312,8 @@ function createMcpConfigs(githubInstallationToken) {
args: [serverPath],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository
GITHUB_REPOSITORY: githubRepository,
PULLFROG_MODES: JSON.stringify(modes2)
}
}
};
@@ -42334,6 +42415,32 @@ var Inputs = type({
...keyInputDefs,
"agent?": AgentName.or("undefined")
});
async function main(inputs) {
const partialCtx = await initializeContext(inputs);
const ctx = partialCtx;
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 (error2) {
const errorMessage = error2 instanceof Error ? error2.message : "Unknown error occurred";
log.error(errorMessage);
await log.writeSummary();
return {
success: false,
error: errorMessage
};
} finally {
await cleanup(partialCtx);
}
}
function throwMissingApiKeyError({
agentName,
inputKeys,
@@ -42367,87 +42474,124 @@ Alternatively, configure Pullfrog to use an agent with existing credentials in y
log.error(message);
throw new Error(message);
}
async function main(inputs) {
let tokenToRevoke = null;
try {
log.info(`\u{1F438} Running pullfrog/action@${package_default.version}...`);
Inputs.assert(inputs);
setupGitConfig();
const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken();
if (wasAcquired) {
tokenToRevoke = githubInstallationToken;
}
const repoContext = parseRepoContext();
const repoSettings = await fetchRepoSettings({
token: githubInstallationToken,
repoContext
});
const agentName = inputs.agent || repoSettings.defaultAgent || "claude";
const agent2 = agents[agentName];
setupGitAuth(githubInstallationToken, repoContext);
const mcpServers = createMcpConfigs(githubInstallationToken);
log.debug(`\u{1F4CB} MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
const cliPath = await agent2.install();
log.info(`Running ${agentName}...`);
let payload;
try {
const parsedPrompt = JSON.parse(inputs.prompt);
if (!("~pullfrog" in parsedPrompt)) {
throw new Error();
async function initializeContext(inputs) {
log.info(`\u{1F438} Running pullfrog/action@${package_default.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",
agent: agents.claude,
sharedTempDir: "",
mcpLogPath: "",
pollInterval: null
};
}
async function determineAgent(ctx) {
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) {
ctx.sharedTempDir = await mkdtemp(join8(tmpdir(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir;
ctx.mcpLogPath = join8(ctx.sharedTempDir, "mcpLog.txt");
await writeFile(ctx.mcpLogPath, "", "utf-8");
log.info(`\u{1F4C2} PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
}
function setupMcpLogPolling(ctx) {
let lastSize = 0;
ctx.pollInterval = setInterval(() => {
if (existsSync4(ctx.mcpLogPath)) {
const content = readFileSync2(ctx.mcpLogPath, "utf-8");
if (content.length > lastSize) {
const newContent = content.slice(lastSize);
process.stdout.write(newContent);
lastSize = content.length;
}
payload = parsedPrompt;
} catch {
payload = {
"~pullfrog": true,
agent: null,
prompt: inputs.prompt,
event: {},
modes
};
}
log.box(payload.prompt, { title: "Prompt" });
const matchingInputKey = agent2.inputKeys.find((inputKey) => inputs[inputKey]);
if (!matchingInputKey) {
throwMissingApiKeyError({
agentName,
inputKeys: agent2.inputKeys,
repoContext,
inputs
});
}, 100);
}
function parsePayload(inputs) {
try {
const parsedPrompt = JSON.parse(inputs.prompt);
if (!("~pullfrog" in parsedPrompt)) {
throw new Error();
}
const apiKey = inputs[matchingInputKey];
const result = await agent2.run({
payload,
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 parsedPrompt;
} catch {
return {
success: true,
output: result.output || ""
"~pullfrog": true,
agent: null,
prompt: inputs.prompt,
event: {},
modes
};
} catch (error2) {
const errorMessage = error2 instanceof Error ? error2.message : "Unknown error occurred";
log.error(errorMessage);
await log.writeSummary();
}
}
function setupMcpServers(ctx) {
const allModes = [...modes, ...ctx.payload.modes || []];
ctx.mcpServers = createMcpConfigs(ctx.githubInstallationToken, allModes);
log.debug(`\u{1F4CB} MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
}
async function installAgentCli(ctx) {
ctx.cliPath = await ctx.agent.install();
}
function validateApiKey(ctx) {
const matchingInputKey = ctx.agent.inputKeys.find(
(inputKey) => ctx.inputs[inputKey]
);
if (!matchingInputKey) {
throwMissingApiKeyError({
agentName: ctx.agentName,
inputKeys: ctx.agent.inputKeys,
repoContext: ctx.repoContext,
inputs: ctx.inputs
});
}
ctx.apiKey = ctx.inputs[matchingInputKey];
}
async function runAgent(ctx) {
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) {
if (!result.success) {
return {
success: false,
error: errorMessage
error: result.error || "Agent execution failed",
output: result.output
};
} finally {
if (tokenToRevoke) {
await revokeInstallationToken(tokenToRevoke);
}
}
log.success("Task complete.");
await log.writeSummary();
return {
success: true,
output: result.output || ""
};
}
async function cleanup(ctx) {
if (ctx.pollInterval) {
clearInterval(ctx.pollInterval);
}
if (ctx.tokenToRevoke) {
await revokeInstallationToken(ctx.tokenToRevoke);
}
}
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env node
/**
* Entry point for GitHub Action
*/
+181 -92
View File
@@ -1,3 +1,7 @@
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";
@@ -41,6 +45,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
*/
@@ -90,110 +124,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: AgentName;
agent: (typeof agents)[AgentName];
sharedTempDir: string;
mcpLogPath: string;
pollInterval: NodeJS.Timeout | null;
payload: Payload;
mcpServers: ReturnType<typeof createMcpConfigs>;
cliPath: string;
apiKey: string;
}
try {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
async function initializeContext(
inputs: Inputs
): Promise<Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">> {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
Inputs.assert(inputs);
setupGitConfig();
Inputs.assert(inputs);
setupGitConfig();
const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken();
const tokenToRevoke = wasAcquired ? githubInstallationToken : null;
const repoContext = parseRepoContext();
const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken();
if (wasAcquired) {
tokenToRevoke = githubInstallationToken;
}
const repoContext = parseRepoContext();
return {
inputs,
githubInstallationToken,
tokenToRevoke,
repoContext,
agentName: "claude" as AgentName,
agent: agents.claude,
sharedTempDir: "",
mcpLogPath: "",
pollInterval: null,
};
}
const repoSettings = await fetchRepoSettings({
token: githubInstallationToken,
repoContext,
});
async function determineAgent(
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
): Promise<void> {
const repoSettings = await fetchRepoSettings({
token: ctx.githubInstallationToken,
repoContext: ctx.repoContext,
});
const agentName: AgentName = inputs.agent || repoSettings.defaultAgent || "claude";
ctx.agentName = ctx.inputs.agent || repoSettings.defaultAgent || "claude";
ctx.agent = agents[ctx.agentName];
}
const agent = agents[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}`);
}
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}...`);
let payload: Payload;
try {
// attempt JSON parsing
const parsedPrompt = JSON.parse(inputs.prompt);
if (!("~pullfrog" in parsedPrompt)) {
// is non-pullfrog JSON (probably from a GitHub event), treat it as a plain text prompt
throw new Error();
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;
}
payload = parsedPrompt as Payload;
} catch {
payload = {
"~pullfrog": true,
agent: null,
prompt: inputs.prompt,
event: {},
modes,
};
}
}, 100);
}
log.box(payload.prompt, { title: "Prompt" });
const matchingInputKey = agent.inputKeys.find((inputKey) => inputs[inputKey]);
if (!matchingInputKey) {
throwMissingApiKeyError({
agentName,
inputKeys: agent.inputKeys,
repoContext,
inputs,
});
function parsePayload(inputs: Inputs): Payload {
try {
const parsedPrompt = JSON.parse(inputs.prompt);
if (!("~pullfrog" in parsedPrompt)) {
throw new Error();
}
const apiKey = inputs[matchingInputKey]!;
const result = await agent.run({
payload,
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 parsedPrompt as Payload;
} catch {
return {
success: true,
output: result.output || "",
"~pullfrog": true,
agent: null,
prompt: inputs.prompt,
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) => 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);
}
}
+133 -46
View File
@@ -97759,10 +97759,6 @@ var schema = ark.schema;
var define2 = ark.define;
var declare = ark.declare;
// mcp/shared.ts
import { appendFileSync } from "node:fs";
import { join } from "node:path";
// node_modules/.pnpm/universal-user-agent@7.0.3/node_modules/universal-user-agent/index.js
function getUserAgent() {
if (typeof navigator === "object" && "userAgent" in navigator) {
@@ -101186,11 +101182,10 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes
}
);
// utils/github.ts
var core2 = __toESM(require_core(), 1);
// utils/cli.ts
var core = __toESM(require_core(), 1);
import { appendFileSync, existsSync } from "node:fs";
import { join } from "node:path";
var isGitHubActions = !!process.env.GITHUB_ACTIONS;
var isDebugEnabled = process.env.LOG_LEVEL === "debug";
function startGroup2(name) {
@@ -101386,10 +101381,85 @@ var log = {
/**
* End a collapsed group
*/
endGroup: endGroup2
endGroup: endGroup2,
/**
* Log MCP tool call information to mcpLog.txt in the temp directory
*/
toolCall: ({
toolName,
request: request2,
result,
error: error41
}) => {
const logPath = getMcpLogPath();
const params = { toolName, request: request2 };
if (error41) {
params.error = error41;
} else if (result) {
params.result = result;
}
const logEntry = formatToolCall(params);
appendFileSync(logPath, logEntry, "utf-8");
}
};
function getMcpLogPath() {
const tempDir = process.env.PULLFROG_TEMP_DIR;
return join(tempDir, "mcpLog.txt");
}
function formatJsonValue(value2) {
const compact = JSON.stringify(value2);
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
}
function formatIndentedField(label, content) {
if (!content.includes("\n")) {
return ` ${label}: ${content}
`;
}
const lines = content.split("\n");
let formatted = ` ${label}: ${lines[0]}
`;
for (let i = 1; i < lines.length; i++) {
formatted += ` ${lines[i]}
`;
}
return formatted;
}
function formatToolInput(request2) {
const requestFormatted = formatJsonValue(request2);
if (requestFormatted === "{}") {
return "";
}
return formatIndentedField("input", requestFormatted);
}
function formatToolResult(result) {
try {
const parsed2 = JSON.parse(result);
const formatted = formatJsonValue(parsed2);
return formatIndentedField("result", formatted);
} catch {
return formatIndentedField("result", result);
}
}
function formatToolCall({
toolName,
request: request2,
result,
error: error41
}) {
let logEntry = `\u2192 ${toolName}
`;
logEntry += formatToolInput(request2);
if (error41) {
logEntry += formatIndentedField("error", error41);
} else if (result) {
logEntry += formatToolResult(result);
}
logEntry += "\n";
return logEntry;
}
// utils/github.ts
var core2 = __toESM(require_core(), 1);
function parseRepoContext() {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
@@ -101415,53 +101485,25 @@ var getMcpContext = cached2(() => {
})
};
});
function getLogPath() {
return join(process.cwd(), "log.txt");
}
function logToolCall({
toolName,
request: request2,
error: error41,
success
}) {
const logPath = getLogPath();
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
const requestStr = JSON.stringify(request2, null, 2);
let logEntry = `[${timestamp}] Tool: ${toolName}
Request: ${requestStr}
`;
if (error41) {
const errorMessage = error41 instanceof Error ? error41.message : String(error41);
const errorStack = error41 instanceof Error ? error41.stack : void 0;
logEntry += `Error: ${errorMessage}
`;
if (errorStack) {
logEntry += `Stack: ${errorStack}
`;
}
logEntry += `Status: FAILED
`;
} else if (success !== void 0) {
logEntry += `Status: ${success ? "SUCCESS" : "FAILED"}
`;
}
logEntry += `${"=".repeat(80)}
`;
appendFileSync(logPath, logEntry, "utf-8");
}
var tool = (toolDef) => {
const toolName = toolDef.name;
const originalExecute = toolDef.execute;
toolDef.execute = async (args2, context) => {
try {
logToolCall({ toolName, request: args2 });
const result = await originalExecute(args2, context);
const isError = result && typeof result === "object" && "isError" in result && result.isError === true;
logToolCall({ toolName, request: args2, success: !isError });
const resultData = result && typeof result === "object" && "content" in result ? result.content[0]?.text : void 0;
if (isError && resultData) {
log.toolCall({ toolName, request: args2, error: resultData });
} else if (resultData) {
log.toolCall({ toolName, request: args2, result: resultData });
} else {
log.toolCall({ toolName, request: args2 });
}
return result;
} catch (error41) {
logToolCall({ toolName, request: args2, error: error41 });
const errorMessage = error41 instanceof Error ? error41.message : String(error41);
log.toolCall({ toolName, request: args2, error: errorMessage });
throw error41;
}
};
@@ -101786,12 +101828,57 @@ var ReviewTool = tool({
})
});
// mcp/selectMode.ts
function getModes() {
const modesJson = process.env.PULLFROG_MODES;
if (modesJson) {
try {
return JSON.parse(modesJson);
} catch {
return [];
}
}
return [];
}
var SelectMode = type({
modeName: type.string.describe(
"the name of the mode to select (e.g., 'Plan', 'Build', 'Review', 'Prompt')"
)
});
var 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
};
})
});
// mcp/server.ts
var server = new FastMCP({
name: "gh-pullfrog",
version: "0.0.1"
});
addTools(server, [
SelectModeTool,
CreateCommentTool,
EditCommentTool,
CreateWorkingCommentTool,
+4 -21
View File
@@ -2,9 +2,9 @@
* Simple MCP configuration helper for adding our minimal GitHub comment server
*/
import type { McpServerConfig, McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { fromHere } from "@ark/fs";
import { log } from "../utils/cli.ts";
import type { Mode } from "../modes.ts";
import { parseRepoContext } from "../utils/github.ts";
import { ghPullfrogMcpName } from "./index.ts";
@@ -12,7 +12,7 @@ export type McpName = typeof ghPullfrogMcpName;
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}`;
@@ -27,25 +27,8 @@ export function createMcpConfigs(githubInstallationToken: string): McpConfigs {
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
PULLFROG_MODES: JSON.stringify(modes),
},
},
};
}
/**
* Iterate through MCP servers and call the provided handler for each stdio server
* Shared logic to avoid duplication across agents
*/
export function forEachStdioMcpServer(
mcpServers: Record<string, McpServerConfig>,
handler: (serverName: string, serverConfig: McpStdioServerConfig) => void
): void {
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
// Only configure stdio servers (CLIs support stdio MCP servers)
if (!("command" in serverConfig)) {
log.warning(`MCP server '${serverName}' is not a stdio server, skipping...`);
continue;
}
handler(serverName, serverConfig);
}
}
+55
View File
@@ -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
View File
@@ -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,
+15 -48
View File
@@ -1,9 +1,8 @@
import { appendFileSync } from "node:fs";
import { join } from "node:path";
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 {
@@ -32,49 +31,6 @@ export interface McpContext extends RepoContext {
octokit: Octokit;
}
/**
* Get the log file path
*/
function getLogPath(): string {
return join(process.cwd(), "log.txt");
}
/**
* Log MCP tool call information to log.txt
*/
function logToolCall({
toolName,
request,
error,
success,
}: {
toolName: string;
request: unknown;
error?: unknown;
success?: boolean;
}) {
const logPath = getLogPath();
const timestamp = new Date().toISOString();
const requestStr = JSON.stringify(request, null, 2);
let logEntry = `[${timestamp}] Tool: ${toolName}\nRequest: ${requestStr}\n`;
if (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
logEntry += `Error: ${errorMessage}\n`;
if (errorStack) {
logEntry += `Stack: ${errorStack}\n`;
}
logEntry += `Status: FAILED\n`;
} else if (success !== undefined) {
logEntry += `Status: ${success ? "SUCCESS" : "FAILED"}\n`;
}
logEntry += `${"=".repeat(80)}\n\n`;
appendFileSync(logPath, logEntry, "utf-8");
}
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;
@@ -82,15 +38,26 @@ export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>)
toolDef.execute = async (args: params, context: any) => {
try {
logToolCall({ toolName, request: args });
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;
logToolCall({ toolName, request: args, success: !isError });
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) {
logToolCall({ toolName, request: args, error });
const errorMessage = error instanceof Error ? error.message : String(error);
log.toolCall({ toolName, request: args, error: errorMessage });
throw error;
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.107",
"version": "0.0.108",
"type": "module",
"files": [
"index.js",
+3 -4
View File
@@ -3,10 +3,6 @@
[] gemini installation speed (bundle/esm.sh?) (TODO: SHAWN)
[] handle defaulting agent name value (TODO: SHAWN)
[] split up prompts, load dynamically based on mode
[] log.txt to stdout
[] entry.js
[] 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
@@ -33,3 +29,6 @@
[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
+114 -2
View File
@@ -2,9 +2,10 @@
* 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";
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
const isDebugEnabled = process.env.LOG_LEVEL === "debug";
@@ -263,8 +264,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