diff --git a/agents/instructions.ts b/agents/instructions.ts index 9f165a5..bc93731 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -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 ************* diff --git a/agents/shared.ts b/agents/shared.ts index 5a36399..8332b46 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -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 { 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} diff --git a/entry b/entry index 2602878..69fff29 100755 --- a/entry +++ b/entry @@ -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); } } diff --git a/entry.ts b/entry.ts index 01e6595..06eeda8 100644 --- a/entry.ts +++ b/entry.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - /** * Entry point for GitHub Action */ diff --git a/main.ts b/main.ts index 2218d7e..d0a6db4 100644 --- a/main.ts +++ b/main.ts @@ -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 { + 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 { - 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; + cliPath: string; + apiKey: string; +} - try { - log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); +async function initializeContext( + inputs: Inputs +): Promise> { + 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 +): Promise { + 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 +): Promise { + 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 { + 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 { + 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 { + 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 +): Promise { + if (ctx.pollInterval) { + clearInterval(ctx.pollInterval); + } + if (ctx.tokenToRevoke) { + await revokeInstallationToken(ctx.tokenToRevoke); } } diff --git a/mcp-server b/mcp-server index e7124d4..02373d1 100755 --- a/mcp-server +++ b/mcp-server @@ -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, diff --git a/mcp/config.ts b/mcp/config.ts index d1abed0..b18fc9d 100644 --- a/mcp/config.ts +++ b/mcp/config.ts @@ -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; -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, - 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); - } -} diff --git a/mcp/selectMode.ts b/mcp/selectMode.ts new file mode 100644 index 0000000..4f193a5 --- /dev/null +++ b/mcp/selectMode.ts @@ -0,0 +1,55 @@ +import { type } from "arktype"; +import type { Mode } from "../modes.ts"; +import { contextualize, tool } from "./shared.ts"; + +// Get modes from environment variable (set by createMcpConfigs) +function getModes(): Mode[] { + const modesJson = process.env.PULLFROG_MODES; + if (modesJson) { + try { + return JSON.parse(modesJson); + } catch { + return []; + } + } + return []; +} + +export const SelectMode = type({ + modeName: type.string.describe( + "the name of the mode to select (e.g., 'Plan', 'Build', 'Review', 'Prompt')" + ), +}); + +export const SelectModeTool = tool({ + name: "select_mode", + description: + "Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.", + parameters: SelectMode, + execute: contextualize(async ({ modeName }) => { + const allModes = getModes(); + + if (allModes.length === 0) { + return { + error: + "No modes available. Modes must be provided via PULLFROG_MODES environment variable.", + }; + } + + const selectedMode = allModes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()); + + if (!selectedMode) { + const availableModes = allModes.map((m) => m.name).join(", "); + return { + error: `Mode "${modeName}" not found. Available modes: ${availableModes}`, + availableModes: allModes.map((m) => ({ name: m.name, description: m.description })), + }; + } + + return { + modeName: selectedMode.name, + description: selectedMode.description, + prompt: selectedMode.prompt, + }; + }), +}); diff --git a/mcp/server.ts b/mcp/server.ts index 847ae8b..d7f95d9 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -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, diff --git a/mcp/shared.ts b/mcp/shared.ts index 3a46f64..d86490d 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -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 = (toolDef: Tool>) => { // Wrap the execute function to add logging with the tool name const toolName = toolDef.name; @@ -82,15 +38,26 @@ export const tool = (toolDef: Tool>) 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; } }; diff --git a/package.json b/package.json index 109c8e4..8977171 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/action", - "version": "0.0.107", + "version": "0.0.108", "type": "module", "files": [ "index.js", diff --git a/todo.md b/todo.md index 00d0adb..98e09f3 100644 --- a/todo.md +++ b/todo.md @@ -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 \ No newline at end of file diff --git a/utils/cli.ts b/utils/cli.ts index d9f7bd7..5aa98a3 100644 --- a/utils/cli.ts +++ b/utils/cli.ts @@ -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[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