From bf7bfb26551f3b1dded2635d604ffe08efc91c57 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 10 Dec 2025 12:56:06 -0800 Subject: [PATCH] The one with opencode support --- entry | 799 +++++++++++++++++++++++++++++++++++++++++++++------ package.json | 2 +- 2 files changed, 708 insertions(+), 93 deletions(-) diff --git a/entry b/entry index 548c985..8ae77df 100755 --- a/entry +++ b/entry @@ -83327,7 +83327,7 @@ function query({ // package.json var package_default = { name: "@pullfrog/action", - version: "0.0.129", + version: "0.0.130", type: "module", files: [ "index.js", @@ -92539,6 +92539,12 @@ var agentsManifest = { displayName: "Gemini CLI", apiKeyNames: ["google_api_key", "gemini_api_key"], url: "https://ai.google.dev/gemini-api/docs" + }, + opencode: { + displayName: "OpenCode", + apiKeyNames: [], + // empty array means OpenCode accepts any API_KEY from environment + url: "https://opencode.ai" } }; var AgentName = type.enumerated(...Object.keys(agentsManifest)); @@ -92546,14 +92552,31 @@ var AgentName = type.enumerated(...Object.keys(agentsManifest)); // modes.ts var reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`; function getModes({ disableProgressComment }) { - const progressStep = disableProgressComment ? "" : ` + return [ + { + name: "Build", + description: "Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details", + prompt: `Follow these steps: +1. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained. -6. ${reportProgressInstruction}`; - const finalProgressStep = disableProgressComment ? "" : ` +2. Create a branch using ${ghPullfrogMcpName}/create_branch. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit directly to main, master, or production. Do NOT use git commands directly - always use ${ghPullfrogMcpName} MCP tools for git operations. -9. Call report_progress one final time with a summary of the results and a link to any artifacts created, like PRs or branches. - - If relevant, include links to the issue or comment that triggered the PR. - - If you created a PR, ALWAYS include a "View PR" link. e.g.: +3. Understand the requirements and any existing plan + +4. Make the necessary code changes using file operations. Then use ${ghPullfrogMcpName}/commit_files to commit your changes, and ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands like \`git commit\` or \`git push\` directly. + +5. Test your changes to ensure they work correctly + +6. ${reportProgressInstruction} + +7. When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123"). + +8. By default, create a PR with an informative title and body. However, if the user explicitly requests a branch without a PR (e.g. "implement X in a new branch", "don't create a PR", "branch only"), you still need to use ${ghPullfrogMcpName}/create_pull_request to ensure commits are properly attributed - you can note in the PR description that it's branch-only if needed. + +9. Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes: + - A summary of what was accomplished + - Links to any artifacts created (PRs, branches, issues) + - If you created a PR, ALWAYS include the PR link. e.g.: \`\`\`md [View PR \u2794](https://github.com/org/repo/pull/123) \`\`\` @@ -92562,25 +92585,9 @@ function getModes({ disableProgressComment }) { \`\`\`md [\`pullfrog/branch-name\`](https://github.com/pullfrog/scratch/tree/pullfrog/branch-name) \u2022 [Create PR \u2794](https://github.com/pullfrog/scratch/compare/main...pullfrog/branch-name?quick_pull=1&title=&body=) \`\`\` -`; - return [ - { - name: "Build", - description: "Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details", - prompt: `Follow these steps: -1. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained. - -2. Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production. - -3. Understand the requirements and any existing plan - -4. Make the necessary code changes. Create intermediate commits if called for. - -5. Test your changes to ensure they work correctly${progressStep} - -7. When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). - -8. By default, create a PR with an informative title and body. However, if the user explicitly requests a branch without a PR (e.g. "implement X in a new branch", "don't create a PR", "branch only"), just make changes the changes in a branch and push them.${finalProgressStep}` + + **IMPORTANT**: Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task. Please review the PR." If your previous report_progress call already contains all the necessary information and links, skip the final call entirely. +` }, { name: "Address Reviews", @@ -92600,7 +92607,10 @@ function getModes({ disableProgressComment }) { 7. ${reportProgressInstruction}`} -8. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one.${disableProgressComment ? "" : "\n\n9. Call report_progress one final time with a summary of all changes made."}` +8. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one. + +9. Call report_progress one final time ONLY if you haven't already included a complete summary in a previous report_progress call. If you already called report_progress with complete information, you do NOT need to call it again. Only make a final call if you need to add missing information. **IMPORTANT**: Do NOT overwrite a good comment with details with a generic message. +` }, { name: "Review", @@ -92639,26 +92649,94 @@ function getModes({ disableProgressComment }) { 1. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.${disableProgressComment ? "" : "\n\n2. When creating comments, always use report_progress. Do not use create_issue_comment."} 2. If the task involves making code changes: - - Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production. - - Make the necessary code changes. Create intermediate commits if called for. + - Create a branch using ${ghPullfrogMcpName}/create_branch. Branch names should be prefixed with "pullfrog/" and reflect the exact changes you are making. Never commit directly to main, master, or production. + - Use file operations to create/modify files with your changes. + - Use ${ghPullfrogMcpName}/commit_files to commit your changes, then ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands directly (\`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) as these will use incorrect credentials. - Test your changes to ensure they work correctly. - - When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). Create a PR with an informative title and body. If relevant, include links to the issue or comment that triggered the PR.${disableProgressComment ? "" : ` + - When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123"). Include links to the issue or comment that triggered the PR in the PR body. 3. ${reportProgressInstruction} -4. When finished with the task, use report_progress one final time to update the comment with a summary of the results and links to any created issues, PRs, etc.`}` +4. When finished with the task, use report_progress one final time ONLY if you haven't already included all the important information (summary, links to PRs/issues) in a previous report_progress call. If you already called report_progress with complete information including links after creating artifacts, you do NOT need to call it again. **IMPORTANT**: Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task."` } ]; } var modes = getModes({ disableProgressComment: void 0 }); // agents/instructions.ts +function extractEssentialEventData(event) { + const trigger = event.trigger; + const essential = { trigger }; + if ("issue_number" in event) { + essential.issue_number = event.issue_number; + } + if ("branch" in event && event.branch) { + essential.branch = event.branch; + } + switch (trigger) { + case "issue_comment_created": + if ("comment_id" in event) essential.comment_id = event.comment_id; + if ("comment_body" in event) essential.comment_body = event.comment_body; + if ("context" in event && event.context && typeof event.context === "object") { + const ctx = event.context; + if (ctx.issue && typeof ctx.issue === "object") { + const issue2 = ctx.issue; + if (issue2.title) essential.issue_title = issue2.title; + if (issue2.body) essential.issue_body = issue2.body; + } + } + break; + case "issues_opened": + case "issues_assigned": + case "issues_labeled": + if ("issue_title" in event) essential.issue_title = event.issue_title; + if ("issue_body" in event) essential.issue_body = event.issue_body; + break; + case "pull_request_opened": + case "pull_request_review_requested": + if ("pr_title" in event) essential.pr_title = event.pr_title; + if ("pr_body" in event) essential.pr_body = event.pr_body; + if ("branch" in event) essential.branch = event.branch; + break; + case "pull_request_review_submitted": + if ("review_id" in event) essential.review_id = event.review_id; + if ("review_body" in event) essential.review_body = event.review_body; + if ("review_state" in event) essential.review_state = event.review_state; + if ("branch" in event) essential.branch = event.branch; + break; + case "pull_request_review_comment_created": + if ("comment_id" in event) essential.comment_id = event.comment_id; + if ("comment_body" in event) essential.comment_body = event.comment_body; + if ("pr_title" in event) essential.pr_title = event.pr_title; + if ("branch" in event) essential.branch = event.branch; + break; + case "check_suite_completed": + if ("pr_title" in event) essential.pr_title = event.pr_title; + if ("pr_body" in event) essential.pr_body = event.pr_body; + if ("branch" in event) essential.branch = event.branch; + if ("check_suite" in event) { + essential.check_suite = { + id: event.check_suite.id, + head_sha: event.check_suite.head_sha, + head_branch: event.check_suite.head_branch, + status: event.check_suite.status, + conclusion: event.check_suite.conclusion + }; + } + break; + case "workflow_dispatch": + if ("inputs" in event) essential.inputs = event.inputs; + break; + } + return essential; +} var addInstructions = (payload) => { let encodedEvent = ""; const eventKeys = Object.keys(payload.event); if (eventKeys.length === 1 && eventKeys[0] === "trigger") { } else { - encodedEvent = encode(payload.event); + const essentialEvent = extractEssentialEventData(payload.event); + encodedEvent = encode(essentialEvent); } return ` *********************************************** @@ -92729,7 +92807,22 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **GitHub CLI prohibition**: Do not use the \`gh\` CLI under any circumstances. Use the corresponding tool from ${ghPullfrogMcpName} instead. -**Authentication**: Do not attempt to configure git credentials, generate tokens, or handle GitHub authentication manually. The ${ghPullfrogMcpName} server handles all authentication internally. +**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author. + +**Available git MCP tools**: +- \`${ghPullfrogMcpName}/create_branch\` - Create a new branch from a base branch +- \`${ghPullfrogMcpName}/commit_files\` - Stage and commit files with proper authentication +- \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote repository +- \`${ghPullfrogMcpName}/create_pull_request\` - Create a PR from the current branch + +**Workflow for making code changes**: +1. Use file operations to create/modify files +2. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch +3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes +4. Use \`${ghPullfrogMcpName}/push_branch\` to push the branch +5. Use \`${ghPullfrogMcpName}/create_pull_request\` to create a PR + +**Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally. **Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable\u2014do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question." @@ -93675,7 +93768,7 @@ var codex = agent({ ); try { const streamedTurn = await thread.runStreamed(addInstructions(payload)); - let finalOutput = ""; + let finalOutput2 = ""; for await (const event of streamedTurn.events) { const handler2 = messageHandlers2[event.type]; log.debug(JSON.stringify(event, null, 2)); @@ -93683,12 +93776,12 @@ var codex = agent({ handler2(event); } if (event.type === "item.completed" && event.item.type === "agent_message") { - finalOutput = event.item.text; + finalOutput2 = event.item.text; } } return { success: true, - output: finalOutput + output: finalOutput2 }; } catch (error41) { const errorMessage = error41 instanceof Error ? error41.message : String(error41); @@ -93820,7 +93913,7 @@ var cursor = agent({ configureCursorMcpServers({ mcpServers, cliPath }); configureCursorSandbox({ sandbox: payload.sandbox ?? false }); const loggedModelCallIds = /* @__PURE__ */ new Set(); - const messageHandlers4 = { + const messageHandlers5 = { system: (_event) => { }, user: (_event) => { @@ -93905,7 +93998,7 @@ var cursor = agent({ if (event.type === "thinking" && event.subtype === "delta" && !event.text) { return; } - const handler2 = messageHandlers4[event.type]; + const handler2 = messageHandlers5[event.type]; if (handler2) { await handler2(event); } @@ -94017,7 +94110,7 @@ import { spawnSync as spawnSync3 } from "node:child_process"; // utils/subprocess.ts import { spawn as nodeSpawn } from "node:child_process"; async function spawn4(options) { - const { cmd, args: args3, env: env3, input, timeout, cwd: cwd2, onStdout, onStderr } = options; + const { cmd, args: args3, env: env3, input, timeout, cwd: cwd2, stdio, onStdout, onStderr } = options; const startTime = Date.now(); let stdoutBuffer = ""; let stderrBuffer = ""; @@ -94027,7 +94120,7 @@ async function spawn4(options) { PATH: process.env.PATH || "", HOME: process.env.HOME || "" }, - stdio: ["pipe", "pipe", "pipe"], + stdio: stdio || ["pipe", "pipe", "pipe"], cwd: cwd2 || process.cwd() }); let timeoutId; @@ -94073,11 +94166,12 @@ async function spawn4(options) { durationMs }); }); - child.on("error", (_error) => { + child.on("error", (error41) => { const durationMs = Date.now() - startTime; if (timeoutId) { clearTimeout(timeoutId); } + console.error(`[spawn] Process spawn error: ${error41.message}`); resolve({ stdout: stdoutBuffer, stderr: stderrBuffer, @@ -94085,7 +94179,7 @@ async function spawn4(options) { durationMs }); }); - if (input && child.stdin) { + if (input && child.stdin && stdio?.[0] !== "ignore") { child.stdin.write(input); child.stdin.end(); } @@ -94191,7 +94285,7 @@ var gemini = agent({ if (payload.sandbox) { log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); } - let finalOutput = ""; + let finalOutput2 = ""; try { const result = await spawn4({ cmd: "node", @@ -94201,7 +94295,7 @@ var gemini = agent({ }), onStdout: async (chunk) => { const text = chunk.toString(); - finalOutput += text; + finalOutput2 += text; const lines = text.split("\n"); for (const line of lines) { const trimmed = line.trim(); @@ -94223,24 +94317,24 @@ var gemini = agent({ if (trimmed) { log.debug(`[gemini stderr] ${trimmed}`); log.warning(trimmed); - finalOutput += trimmed + "\n"; + finalOutput2 += trimmed + "\n"; } } }); if (result.exitCode !== 0) { - const errorMessage = result.stderr || finalOutput || result.stdout || "Unknown error - no output from Gemini CLI"; + const errorMessage = result.stderr || finalOutput2 || result.stdout || "Unknown error - no output from Gemini CLI"; log.error(`Gemini CLI exited with code ${result.exitCode}: ${errorMessage}`); return { success: false, error: errorMessage, - output: finalOutput || result.stdout || "" + output: finalOutput2 || result.stdout || "" }; } - finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully."; + finalOutput2 = finalOutput2 || result.stdout || "Gemini CLI completed successfully."; log.info("\u2713 Gemini CLI completed successfully"); return { success: true, - output: finalOutput + output: finalOutput2 }; } catch (error41) { const errorMessage = error41 instanceof Error ? error41.message : String(error41); @@ -94248,7 +94342,7 @@ var gemini = agent({ return { success: false, error: errorMessage, - output: finalOutput || "" + output: finalOutput2 || "" }; } } @@ -94279,18 +94373,352 @@ function configureGeminiMcpServers({ mcpServers, cliPath }) { } } +// agents/opencode.ts +import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs"; +import { join as join7 } from "node:path"; +var finalOutput = ""; +var accumulatedTokens = { input: 0, output: 0 }; +var tokensLogged = false; +var toolCallTimings = /* @__PURE__ */ new Map(); +var currentStepId = null; +var currentStepType = null; +var stepHistory = []; +var messageHandlers4 = { + init: (event) => { + log.info( + `\u{1F535} OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}` + ); + finalOutput = ""; + accumulatedTokens = { input: 0, output: 0 }; + tokensLogged = false; + }, + message: (event) => { + if (event.role === "assistant" && event.content?.trim()) { + const message = event.content.trim(); + if (message) { + if (event.delta) { + log.info( + `\u{1F4AD} OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}` + ); + } else { + log.info( + `\u{1F4AC} OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}` + ); + finalOutput = message; + } + } + } else if (event.role === "user") { + log.info( + `\u{1F4AC} OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}` + ); + } + }, + text: (event) => { + if (event.part?.text?.trim()) { + const message = event.part.text.trim(); + log.info( + `\u{1F4DD} OpenCode text output: ${message.substring(0, 200)}${message.length > 200 ? "..." : ""}` + ); + log.box(message, { title: "OpenCode" }); + finalOutput = message; + } + }, + step_start: (event) => { + const stepType = event.part?.type || "unknown"; + const stepId = event.part?.id || "unknown"; + currentStepId = stepId; + currentStepType = stepType; + stepHistory.push({ stepId, stepType, toolCalls: [] }); + }, + step_finish: async (event) => { + const stepId = event.part?.id || "unknown"; + const eventTokens = event.part?.tokens; + if (eventTokens) { + const inputTokens = eventTokens.input || 0; + const outputTokens = eventTokens.output || 0; + accumulatedTokens.input += inputTokens; + accumulatedTokens.output += outputTokens; + } + if (currentStepId === stepId) { + currentStepId = null; + currentStepType = null; + } + }, + tool_use: (event) => { + if (event.tool_name && event.tool_id) { + toolCallTimings.set(event.tool_id, Date.now()); + const paramsStr = event.parameters ? JSON.stringify(event.parameters).substring(0, 500) : "{}"; + const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"}, stepId=${currentStepId.substring(0, 20)}...)` : ""; + log.info(`\u{1F527} OpenCode tool_use: ${event.tool_name}${stepContext}, id=${event.tool_id}`); + log.info(` Parameters: ${paramsStr}${paramsStr.length >= 500 ? "..." : ""}`); + if (stepHistory.length > 0) { + stepHistory[stepHistory.length - 1].toolCalls.push(event.tool_name); + } + log.toolCall({ + toolName: event.tool_name, + input: event.parameters || {} + }); + } + }, + tool_result: (event) => { + if (event.tool_id) { + const toolStartTime = toolCallTimings.get(event.tool_id); + if (toolStartTime) { + const toolDuration = Date.now() - toolStartTime; + toolCallTimings.delete(event.tool_id); + const status = event.status || "unknown"; + const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : ""; + const outputPreview = typeof event.output === "string" ? event.output.substring(0, 500) : JSON.stringify(event.output).substring(0, 500); + log.info( + `\u{1F527} OpenCode tool_result${stepContext}: id=${event.tool_id}, status=${status}, duration=${toolDuration}ms` + ); + if (outputPreview && outputPreview !== "{}" && outputPreview !== "null") { + log.info(` Output: ${outputPreview}${outputPreview.length >= 500 ? "..." : ""}`); + } + if (toolDuration > 5e3) { + log.warning( + `\u26A0\uFE0F Tool call took ${(toolDuration / 1e3).toFixed(1)}s - this may indicate network latency or slow processing` + ); + } + } + } + if (event.status === "error") { + const errorMsg = typeof event.output === "string" ? event.output : JSON.stringify(event.output); + log.warning(`\u274C Tool call failed: ${errorMsg}`); + } + }, + result: async (event) => { + const status = event.status || "unknown"; + const duration2 = event.stats?.duration_ms || 0; + const toolCalls = event.stats?.tool_calls || 0; + log.info( + `\u{1F3C1} OpenCode result: status=${status}, duration=${duration2}ms, tool_calls=${toolCalls}` + ); + if (event.status === "error") { + log.error(`\u274C OpenCode CLI failed: ${JSON.stringify(event)}`); + } else { + const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0; + const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0; + const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens; + log.info( + `\u{1F4CA} OpenCode final stats: input=${inputTokens}, output=${outputTokens}, total=${totalTokens}, tool_calls=${toolCalls}, duration=${duration2}ms` + ); + if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) { + await log.summaryTable([ + [ + { data: "Input Tokens", header: true }, + { data: "Output Tokens", header: true }, + { data: "Total Tokens", header: true } + ], + [String(inputTokens), String(outputTokens), String(totalTokens)] + ]); + tokensLogged = true; + } + } + } +}; +var opencode = agent({ + name: "opencode", + install: async () => { + return await installFromNpmTarball({ + packageName: "opencode-ai", + version: "latest", + executablePath: "bin/opencode", + installDependencies: true + }); + }, + run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath }) => { + const tempHome = process.env.PULLFROG_TEMP_DIR; + const configDir = join7(tempHome, ".config", "opencode"); + mkdirSync4(configDir, { recursive: true }); + configureOpenCodeMcpServers({ mcpServers }); + configureOpenCodeSandbox({ sandbox: payload.sandbox ?? false }); + const prompt = addInstructions(payload); + const args3 = ["run", "--format", "json", prompt]; + if (payload.sandbox) { + log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); + } + setupProcessAgentEnv({ HOME: tempHome }); + const env3 = { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([key, value2]) => value2 !== void 0 && key !== "GITHUB_TOKEN" + ) + ), + HOME: tempHome + }; + for (const [key, value2] of Object.entries(apiKeys || {})) { + env3[key.toUpperCase()] = value2; + } + const repoDir = process.cwd(); + log.info(`\u{1F680} Starting OpenCode CLI: ${cliPath} ${args3.join(" ")}`); + log.info(`\u{1F4C1} Working directory: ${repoDir}`); + const startTime = Date.now(); + let lastActivityTime = startTime; + let eventCount = 0; + let output = ""; + const result = await spawn4({ + cmd: cliPath, + args: args3, + cwd: repoDir, + env: env3, + timeout: 6e5, + // 10 minutes timeout to prevent infinite hangs + stdio: ["ignore", "pipe", "pipe"], + onStdout: async (chunk) => { + const text = chunk.toString(); + output += text; + const lines = text.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const event = JSON.parse(trimmed); + eventCount++; + const timeSinceLastActivity = Date.now() - lastActivityTime; + if (timeSinceLastActivity > 1e4) { + const activeToolCalls = toolCallTimings.size; + const toolCallInfo = activeToolCalls > 0 ? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})` : " (OpenCode may be processing internally - LLM calls, planning, etc.)"; + log.warning( + `\u26A0\uFE0F No activity for ${(timeSinceLastActivity / 1e3).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)` + ); + } + lastActivityTime = Date.now(); + const handler2 = messageHandlers4[event.type]; + if (handler2) { + await handler2(event); + } else { + if (event.type && ![ + "init", + "message", + "text", + "step_start", + "step_finish", + "tool_use", + "tool_result", + "result" + ].includes(event.type)) { + log.debug(`\u{1F4CB} OpenCode event (unhandled): type=${event.type}`); + } + } + } catch { + } + } + }, + onStderr: (chunk) => { + const trimmed = chunk.trim(); + if (trimmed) { + log.warning(trimmed); + } + } + }); + const duration2 = Date.now() - startTime; + log.info(`\u2705 OpenCode CLI completed in ${duration2}ms with exit code ${result.exitCode}`); + if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) { + const totalTokens = accumulatedTokens.input + accumulatedTokens.output; + await log.summaryTable([ + [ + { data: "Input Tokens", header: true }, + { data: "Output Tokens", header: true }, + { data: "Total Tokens", header: true } + ], + [String(accumulatedTokens.input), String(accumulatedTokens.output), String(totalTokens)] + ]); + } + if (result.exitCode !== 0) { + const errorMessage = result.stderr || result.stdout || "Unknown error - no output from OpenCode CLI"; + log.error(`OpenCode CLI exited with code ${result.exitCode}: ${errorMessage}`); + log.debug(`OpenCode stdout: ${result.stdout?.substring(0, 500)}`); + log.debug(`OpenCode stderr: ${result.stderr?.substring(0, 500)}`); + return { + success: false, + output: finalOutput || output, + error: errorMessage + }; + } + return { + success: true, + output: finalOutput || output + }; + } +}); +function configureOpenCodeMcpServers({ + mcpServers +}) { + const tempHome = process.env.PULLFROG_TEMP_DIR; + const configDir = join7(tempHome, ".config", "opencode"); + mkdirSync4(configDir, { recursive: true }); + const configPath = join7(configDir, "opencode.json"); + const opencodeMcpServers = {}; + for (const [serverName, serverConfig] of Object.entries(mcpServers)) { + if (serverConfig.type !== "http") { + throw new Error( + `Unsupported MCP server type for OpenCode: ${serverConfig.type || "unknown"}` + ); + } + opencodeMcpServers[serverName] = { + type: "remote", + url: serverConfig.url, + enabled: true + }; + } + let config2 = {}; + try { + if (existsSync3(configPath)) { + const existingConfig = readFileSync2(configPath, "utf-8"); + config2 = JSON.parse(existingConfig); + } + } catch { + } + config2.mcp = opencodeMcpServers; + writeFileSync3(configPath, JSON.stringify(config2, null, 2), "utf-8"); + log.info(`MCP config written to ${configPath}`); +} +function configureOpenCodeSandbox({ sandbox }) { + const tempHome = process.env.PULLFROG_TEMP_DIR; + const configDir = join7(tempHome, ".config", "opencode"); + mkdirSync4(configDir, { recursive: true }); + const configPath = join7(configDir, "opencode.json"); + let config2 = {}; + try { + if (existsSync3(configPath)) { + const existingConfig = readFileSync2(configPath, "utf-8"); + config2 = JSON.parse(existingConfig); + } + } catch { + } + if (sandbox) { + config2.tools = { + write: false, + bash: false, + webfetch: false + }; + } else { + config2.tools = { + write: true, + bash: true, + webfetch: true + }; + } + writeFileSync3(configPath, JSON.stringify(config2, null, 2), "utf-8"); + log.info(`OpenCode config written to ${configPath} (sandbox: ${sandbox})`); +} + // agents/index.ts var agents = { claude, codex, cursor, - gemini + gemini, + opencode }; // main.ts import { mkdtemp as mkdtemp2 } from "node:fs/promises"; import { tmpdir as tmpdir2 } from "node:os"; -import { join as join7 } from "node:path"; +import { join as join8 } from "node:path"; // node_modules/.pnpm/universal-user-agent@7.0.3/node_modules/universal-user-agent/index.js function getUserAgent() { @@ -97743,11 +98171,43 @@ function sanitizeSchema(schema2) { if (Array.isArray(schema2)) { return schema2.map(sanitizeSchema); } + if (schema2.anyOf && Array.isArray(schema2.anyOf) && schema2.anyOf.length > 0) { + const enumValues2 = []; + let allAreEnumObjects = true; + for (const item of schema2.anyOf) { + if (item && typeof item === "object" && Array.isArray(item.enum)) { + const stringEnums = item.enum.filter((v) => typeof v === "string"); + if (stringEnums.length > 0) { + enumValues2.push(...stringEnums); + } else { + allAreEnumObjects = false; + break; + } + } else { + allAreEnumObjects = false; + break; + } + } + if (allAreEnumObjects && enumValues2.length > 0) { + const uniqueEnums = [...new Set(enumValues2)]; + const result = { + type: "string", + enum: uniqueEnums + }; + if (schema2.description) { + result.description = schema2.description; + } + return result; + } + } const sanitized = {}; for (const [key, value2] of Object.entries(schema2)) { if (key === "$schema") { continue; } + if (key === "anyOf" && schema2.anyOf) { + continue; + } if (key === "$defs") { sanitized.definitions = sanitizeSchema(value2); continue; @@ -97784,7 +98244,7 @@ function sanitizeTool(tool2) { }; } var addTools = (server, tools) => { - const shouldSanitize = mcpInitContext?.agentName === "gemini"; + const shouldSanitize = mcpInitContext?.agentName === "gemini" || mcpInitContext?.agentName === "opencode"; for (const tool2 of tools) { const processedTool = shouldSanitize ? sanitizeTool(tool2) : tool2; server.addTool(processedTool); @@ -122479,6 +122939,147 @@ var DebugShellCommandTool = tool({ } }); +// utils/secrets.ts +function getAllSecrets() { + const secrets = []; + for (const agent2 of Object.values(agentsManifest)) { + for (const keyName of agent2.apiKeyNames) { + const envKey = keyName.toUpperCase(); + const value2 = process.env[envKey]; + if (value2) { + secrets.push(value2); + } + } + } + const opencodeAgent = agentsManifest.opencode; + if (opencodeAgent && opencodeAgent.apiKeyNames.length === 0) { + for (const [key, value2] of Object.entries(process.env)) { + if (value2 && typeof value2 === "string" && key.includes("API_KEY")) { + secrets.push(value2); + } + } + } + try { + const token = getGitHubInstallationToken(); + if (token) { + secrets.push(token); + } + } catch { + } + return secrets; +} +function containsSecrets(content, secrets) { + const secretsToCheck = secrets ?? getAllSecrets(); + return secretsToCheck.some((secret) => secret && content.includes(secret)); +} + +// mcp/git.ts +var CreateBranch = type({ + branchName: type.string.describe( + "The name of the branch to create (e.g., 'pullfrog/123-fix-bug')" + ), + baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main") +}); +var CreateBranchTool = tool({ + name: "create_branch", + description: "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", + parameters: CreateBranch, + execute: contextualize(async ({ branchName, baseBranch }) => { + if (containsSecrets(branchName)) { + throw new Error( + "Branch creation blocked: secrets detected in branch name. Please remove any sensitive information (API keys, tokens, passwords) before creating a branch." + ); + } + log.info(`Creating branch ${branchName} from ${baseBranch}`); + $("git", ["fetch", "origin", baseBranch, "--depth=1"]); + $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); + $("git", ["checkout", "-b", branchName]); + $("git", ["push", "-u", "origin", branchName]); + log.info(`Successfully created and pushed branch ${branchName}`); + return { + success: true, + branchName, + baseBranch, + message: `Branch ${branchName} created from ${baseBranch} and pushed to remote` + }; + }) +}); +var CommitFiles = type({ + message: type.string.describe("The commit message"), + files: type.string.array().describe( + "Array of file paths to commit (relative to repo root). If empty, commits all staged changes." + ) +}); +var CommitFilesTool = tool({ + name: "commit_files", + description: "Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.", + parameters: CommitFiles, + execute: contextualize(async ({ message, files }) => { + if (containsSecrets(message)) { + throw new Error( + "Commit blocked: secrets detected in commit message. Please remove any sensitive information (API keys, tokens, passwords) before committing." + ); + } + if (files.length > 0) { + for (const file of files) { + try { + const content = $("cat", [file], { log: false }); + if (containsSecrets(content)) { + throw new Error( + `Commit blocked: secrets detected in file ${file}. Please remove any sensitive information (API keys, tokens, passwords) before committing.` + ); + } + } catch (error41) { + if (error41 instanceof Error && error41.message.includes("Commit blocked")) { + throw error41; + } + } + } + } + const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); + log.info(`Committing files on branch ${currentBranch}`); + if (files.length > 0) { + $("git", ["add", ...files]); + } else { + $("git", ["add", "."]); + } + $("git", ["commit", "-m", message]); + const commitSha = $("git", ["rev-parse", "HEAD"], { log: false }); + log.info(`Successfully committed: ${commitSha.substring(0, 7)}`); + return { + success: true, + commitSha, + branch: currentBranch, + message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}` + }; + }) +}); +var PushBranch = type({ + branchName: type.string.describe("The branch name to push (defaults to current branch)").optional(), + force: type.boolean.describe("Force push (use with caution)").default(false) +}); +var PushBranchTool = tool({ + name: "push_branch", + description: "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.", + parameters: PushBranch, + execute: contextualize(async ({ branchName, force }) => { + const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); + if (force) { + log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); + $("git", ["push", "--force", "origin", branch]); + } else { + log.info(`Pushing branch ${branch} to remote`); + $("git", ["push", "origin", branch]); + } + return { + success: true, + branch, + force, + message: `Successfully pushed branch ${branch} to remote` + }; + }) +}); + // mcp/issue.ts var Issue = type({ title: type.string.describe("the title of the issue"), @@ -122683,32 +123284,6 @@ var AddLabelsTool = tool({ }) }); -// utils/secrets.ts -function getAllSecrets() { - const secrets = []; - for (const agent2 of Object.values(agentsManifest)) { - for (const keyName of agent2.apiKeyNames) { - const envKey = keyName.toUpperCase(); - const value2 = process.env[envKey]; - if (value2) { - secrets.push(value2); - } - } - } - try { - const token = getGitHubInstallationToken(); - if (token) { - secrets.push(token); - } - } catch { - } - return secrets; -} -function containsSecrets(content, secrets) { - const secretsToCheck = secrets ?? getAllSecrets(); - return secretsToCheck.some((secret) => secret && content.includes(secret)); -} - // mcp/pr.ts var PullRequest = type({ title: type.string.describe("the title of the pull request"), @@ -123002,7 +123577,10 @@ async function startMcpHttpServer(state) { ListPullRequestReviewsTool, GetCheckSuiteLogsTool, DebugShellCommandTool, - AddLabelsTool + AddLabelsTool, + CreateBranchTool, + CommitFilesTool, + PushBranchTool ]; if (!isProgressCommentDisabled()) { tools.push(ReportProgressTool); @@ -123262,9 +123840,12 @@ async function main(inputs) { } } function getAvailableAgents(inputs) { - return Object.values(agents).filter( - (agent2) => agent2.apiKeyNames.some((inputKey) => inputs[inputKey]) - ); + return Object.values(agents).filter((agent2) => { + if (agent2.name === "opencode") { + return Object.keys(inputs).some((key) => key.includes("api_key")); + } + return agent2.apiKeyNames.some((inputKey) => inputs[inputKey]); + }); } function getAllPossibleKeyNames() { return Object.keys( @@ -123280,11 +123861,17 @@ async function throwMissingApiKeyError({ }) { const apiUrl = process.env.API_URL || "https://pullfrog.com"; const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`; - const inputKeys = agent2?.apiKeyNames || getAllPossibleKeyNames(); - const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``); - const secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`; const githubRepoUrl = `https://github.com/${repoContext.owner}/${repoContext.name}`; const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`; + const isOpenCode = agent2?.name === "opencode"; + let secretNameList; + if (isOpenCode) { + secretNameList = "any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)"; + } else { + const inputKeys = agent2?.apiKeyNames || getAllPossibleKeyNames(); + const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``); + secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`; + } let message = `${agent2 === null ? "Pullfrog has no agent configured and no API keys are available in the environment." : `Pullfrog is configured to use ${agent2.displayName}, but the associated API key was not provided.`} To fix this, add the required secret to your GitHub repository: @@ -123338,8 +123925,20 @@ async function resolveAgent(inputs, payload, githubInstallationToken2, repoConte if (!agent3) { throw new Error(`invalid agent name: ${agentName2}`); } - log.info(`Selected configured agent: ${agentName2}`); - return { agentName: agentName2, agent: agent3 }; + const isExplicitOverride = agentOverride !== void 0 || payload.agent !== null; + if (isExplicitOverride) { + log.info(`Selected configured agent: ${agentName2}`); + return { agentName: agentName2, agent: agent3 }; + } + const hasMatchingKey = agent3.name === "opencode" ? Object.keys(inputs).some((key) => key.includes("api_key")) : agent3.apiKeyNames.some((inputKey) => inputs[inputKey]); + if (!hasMatchingKey) { + log.warning( + `Repo default agent ${agentName2} has no matching API keys. Available agents: ${getAvailableAgents(inputs).map((a) => a.name).join(", ") || "none"}` + ); + } else { + log.info(`Selected configured agent: ${agentName2}`); + return { agentName: agentName2, agent: agent3 }; + } } const availableAgents = getAvailableAgents(inputs); const availableAgentNames = availableAgents.map((agent3) => agent3.name).join(", "); @@ -123356,7 +123955,7 @@ async function resolveAgent(inputs, payload, githubInstallationToken2, repoConte return { agentName, agent: agent2 }; } async function setupTempDirectory(ctx) { - ctx.sharedTempDir = await mkdtemp2(join7(tmpdir2(), "pullfrog-")); + ctx.sharedTempDir = await mkdtemp2(join8(tmpdir2(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; log.info(`\u{1F4C2} PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); } @@ -123413,15 +124012,30 @@ async function installAgentCli(ctx) { } } async function validateApiKey(ctx) { - const matchingInputKey = ctx.agent.apiKeyNames.find((inputKey) => ctx.inputs[inputKey]); - if (!matchingInputKey) { + const apiKeys = {}; + for (const inputKey of ctx.agent.apiKeyNames) { + const value2 = ctx.inputs[inputKey]; + if (value2) { + apiKeys[inputKey] = value2; + } + } + if (ctx.agentName === "opencode" && Object.keys(apiKeys).length === 0) { + for (const [key, value2] of Object.entries(process.env)) { + if (value2 && typeof value2 === "string" && key.includes("API_KEY")) { + const inputKey = key.toLowerCase(); + apiKeys[inputKey] = value2; + } + } + } + if (Object.keys(apiKeys).length === 0) { await throwMissingApiKeyError({ agent: ctx.agent, repoContext: ctx.repoContext }); return; } - ctx.apiKey = ctx.inputs[matchingInputKey]; + ctx.apiKey = Object.values(apiKeys)[0]; + ctx.apiKeys = apiKeys; } async function runAgent(ctx) { log.info(`Running ${ctx.agentName}...`); @@ -123434,6 +124048,7 @@ ${encode(eventWithoutContext)}`; payload: ctx.payload, mcpServers: ctx.mcpServers, apiKey: ctx.apiKey, + apiKeys: ctx.apiKeys, cliPath: ctx.cliPath }); } diff --git a/package.json b/package.json index 157dd55..7d09b5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/action", - "version": "0.0.129", + "version": "0.0.130", "type": "module", "files": [ "index.js",