Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2735b2916 | |||
| 9714d5fea6 | |||
| a57866a8cd | |||
| 9903072286 | |||
| edb7603587 | |||
| 45f837cedb | |||
| c6572f0987 | |||
| 89e93d3398 | |||
| c335032c37 | |||
| 2f3ae3e481 | |||
| 308781793f | |||
| 1765e04d77 | |||
| c5d201ce60 | |||
| c89f1b9537 | |||
| 7fe0233c24 | |||
| e10f756560 | |||
| 48108b137a | |||
| 074a860a95 | |||
| 0c03428488 | |||
| 5fa8c3603d |
@@ -0,0 +1,35 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
@@ -4,10 +4,3 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
pnpm lock
|
||||
git add pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
# Check if entry needs rebuilding (entry.ts, esbuild.config.js, or any .ts files)
|
||||
if git diff --cached --name-only | grep -qE "^(entry\.ts|esbuild\.config\.js|.*\.ts)$"; then
|
||||
echo "🔨 Building action..."
|
||||
pnpm build
|
||||
git add entry
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<p align="center">
|
||||
|
||||
<p align="center">
|
||||
<h1 align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/frog-white-200px.png">
|
||||
@@ -84,6 +83,7 @@ on:
|
||||
prompt:
|
||||
description: 'Agent prompt'
|
||||
type: string
|
||||
secrets: inherit
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -105,14 +105,14 @@ jobs:
|
||||
- name: Run agent
|
||||
uses: pullfrog/action@v0
|
||||
with:
|
||||
prompt: ${{ github.event.inputs.prompt }}
|
||||
|
||||
prompt: ${{ inputs.prompt }}
|
||||
env:
|
||||
# feel free to comment out any you won't use
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
||||
google_api_key: ${{ secrets.GOOGLE_API_KEY }}
|
||||
gemini_api_key: ${{ secrets.GEMINI_API_KEY }}
|
||||
cursor_api_key: ${{ secrets.CURSOR_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
|
||||
```
|
||||
|
||||
|
||||
+9
-18
@@ -1,30 +1,21 @@
|
||||
name: "Pullfrog Claude Code Action"
|
||||
description: "Execute Claude Code with a prompt using Anthropic API"
|
||||
name: "Pullfrog Action"
|
||||
description: "Execute coding agents with a prompt"
|
||||
author: "Pullfrog"
|
||||
|
||||
inputs:
|
||||
prompt:
|
||||
description: "Prompt to send to Claude Code"
|
||||
description: "Prompt to send to the agent"
|
||||
required: true
|
||||
default: "Hello from Claude Code!"
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key for Claude Code authentication"
|
||||
effort:
|
||||
description: "Effort level: nothink (fast), think (default), max (most capable)"
|
||||
required: false
|
||||
openai_api_key:
|
||||
description: "OpenAI API key for Codex authentication"
|
||||
required: false
|
||||
google_api_key:
|
||||
description: "Google API key for Jules authentication"
|
||||
required: false
|
||||
gemini_api_key:
|
||||
description: "Gemini API key for Jules authentication"
|
||||
required: false
|
||||
cursor_api_key:
|
||||
description: "Cursor API key for Cursor authentication"
|
||||
default: "think"
|
||||
cwd:
|
||||
description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "node20"
|
||||
using: "node24"
|
||||
main: "entry"
|
||||
|
||||
branding:
|
||||
|
||||
+29
-8
@@ -1,9 +1,24 @@
|
||||
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Effort } from "../external.ts";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
|
||||
|
||||
// Model selection based on effort level
|
||||
// Note: nothink uses Haiku for speed, think uses Sonnet for balance, max uses Opus for capability
|
||||
const claudeEffortModels: Record<Effort, string> = {
|
||||
nothink: "haiku",
|
||||
think: "opusplan",
|
||||
max: "opus",
|
||||
};
|
||||
|
||||
// FUTURE: Consider using Anthropic's "effort" parameter (beta) with Opus 4.5 for all tasks.
|
||||
// This would allow a single model with effort levels ("low", "medium", "high") controlling
|
||||
// token spend across responses, tool calls, and thinking. Requires beta header "effort-2025-11-24".
|
||||
// See: https://platform.claude.com/docs/en/build-with-claude/effort
|
||||
// This approach could replace model selection if effort proves effective for controlling capability.
|
||||
|
||||
export const claude = agent({
|
||||
name: "claude",
|
||||
install: async () => {
|
||||
@@ -14,13 +29,17 @@ export const claude = agent({
|
||||
executablePath: "cli.js",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
||||
// Ensure API key is NOT in process.env - only pass via SDK's env option
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
const prompt = addInstructions({ payload, repo });
|
||||
log.group("Full prompt", () => log.info(prompt));
|
||||
|
||||
// select model based on effort level
|
||||
const model = claudeEffortModels[effort];
|
||||
log.info(`Using model: ${model} (effort: ${effort})`);
|
||||
|
||||
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
|
||||
// disable native Bash; agents use MCP bash tool which filters secrets.
|
||||
// for private repos, native Bash is allowed since secrets are less exposed.
|
||||
@@ -46,15 +65,17 @@ export const claude = agent({
|
||||
|
||||
// Pass secrets via SDK's env option only (not process.env)
|
||||
// This ensures secrets are only available to Claude Code subprocess, not user code
|
||||
const queryOptions: Options = {
|
||||
...sandboxOptions,
|
||||
mcpServers,
|
||||
model,
|
||||
pathToClaudeCodeExecutable: cliPath,
|
||||
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
||||
};
|
||||
|
||||
const queryInstance = query({
|
||||
prompt,
|
||||
options: {
|
||||
...sandboxOptions,
|
||||
mcpServers,
|
||||
// model: "claude-opus-4-5",
|
||||
pathToClaudeCodeExecutable: cliPath,
|
||||
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
||||
},
|
||||
options: queryOptions,
|
||||
});
|
||||
|
||||
// Stream the results
|
||||
|
||||
+54
-16
@@ -1,11 +1,33 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
|
||||
import {
|
||||
Codex,
|
||||
type CodexOptions,
|
||||
type ModelReasoningEffort,
|
||||
type ThreadEvent,
|
||||
type ThreadOptions,
|
||||
} from "@openai/codex-sdk";
|
||||
import type { Effort } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
|
||||
|
||||
// model configuration based on effort level
|
||||
const codexModel: Record<Effort, string> = {
|
||||
nothink: "gpt-5.1-codex-mini",
|
||||
think: "gpt-5.1-codex",
|
||||
max: "gpt-5.1-codex-max",
|
||||
} as const;
|
||||
|
||||
// reasoning effort configuration based on effort level
|
||||
// uses modelReasoningEffort parameter from ThreadOptions
|
||||
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
|
||||
nothink: "low",
|
||||
think: undefined, // use default
|
||||
max: "high",
|
||||
};
|
||||
|
||||
interface WriteCodexConfigParams {
|
||||
tempHome: string;
|
||||
mcpServers: Record<string, McpHttpServerConfig>;
|
||||
@@ -60,7 +82,7 @@ export const codex = agent({
|
||||
executablePath: "bin/codex.js",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||
|
||||
// create config directory for codex before setting HOME
|
||||
@@ -79,6 +101,14 @@ export const codex = agent({
|
||||
CODEX_HOME: codexDir, // point Codex to our config directory
|
||||
});
|
||||
|
||||
// get model and reasoning effort based on effort level
|
||||
const model = codexModel[effort];
|
||||
const modelReasoningEffort = codexReasoningEffort[effort];
|
||||
log.info(`Using model: ${model}`);
|
||||
if (modelReasoningEffort) {
|
||||
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`);
|
||||
}
|
||||
|
||||
// Configure Codex
|
||||
const codexOptions: CodexOptions = {
|
||||
apiKey,
|
||||
@@ -90,20 +120,28 @@ export const codex = agent({
|
||||
}
|
||||
|
||||
const codex = new Codex(codexOptions);
|
||||
const thread = codex.startThread(
|
||||
payload.sandbox
|
||||
? {
|
||||
approvalPolicy: "never",
|
||||
sandboxMode: "read-only",
|
||||
networkAccessEnabled: false,
|
||||
}
|
||||
: {
|
||||
approvalPolicy: "never",
|
||||
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
|
||||
sandboxMode: "danger-full-access",
|
||||
networkAccessEnabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Build thread options with model and optional model_reasoning_effort
|
||||
const baseThreadOptions = payload.sandbox
|
||||
? {
|
||||
model,
|
||||
approvalPolicy: "never" as const,
|
||||
sandboxMode: "read-only" as const,
|
||||
networkAccessEnabled: false,
|
||||
}
|
||||
: {
|
||||
model,
|
||||
approvalPolicy: "never" as const,
|
||||
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
|
||||
sandboxMode: "danger-full-access" as const,
|
||||
networkAccessEnabled: true,
|
||||
};
|
||||
|
||||
const threadOptions: ThreadOptions = modelReasoningEffort
|
||||
? { ...baseThreadOptions, modelReasoningEffort }
|
||||
: baseThreadOptions;
|
||||
|
||||
const thread = codex.startThread(threadOptions);
|
||||
|
||||
try {
|
||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
||||
|
||||
+46
-11
@@ -1,7 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Effort } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import {
|
||||
@@ -11,6 +12,14 @@ import {
|
||||
installFromCurl,
|
||||
} from "./shared.ts";
|
||||
|
||||
// effort configuration for Cursor
|
||||
// only "max" overrides the model; nothink/think use default ("auto")
|
||||
const cursorEffortModels: Record<Effort, string | null> = {
|
||||
nothink: null, // use default (auto)
|
||||
think: null, // use default (auto)
|
||||
max: "opus-4.5-thinking",
|
||||
} as const;
|
||||
|
||||
// cursor cli event types inferred from stream-json output
|
||||
interface CursorSystemEvent {
|
||||
type: "system";
|
||||
@@ -91,10 +100,36 @@ export const cursor = agent({
|
||||
executableName: "cursor-agent",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
||||
run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => {
|
||||
configureCursorMcpServers({ mcpServers, cliPath });
|
||||
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
|
||||
|
||||
// determine model based on effort level
|
||||
// respect project's .cursor/cli.json if it specifies a model
|
||||
const projectCliConfigPath = join(process.cwd(), ".cursor", "cli.json");
|
||||
let modelOverride: string | null = null;
|
||||
|
||||
if (existsSync(projectCliConfigPath)) {
|
||||
try {
|
||||
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
|
||||
if (projectConfig.model) {
|
||||
log.info(`Using model from project .cursor/cli.json: ${projectConfig.model}`);
|
||||
} else {
|
||||
modelOverride = cursorEffortModels[effort];
|
||||
}
|
||||
} catch {
|
||||
modelOverride = cursorEffortModels[effort];
|
||||
}
|
||||
} else {
|
||||
modelOverride = cursorEffortModels[effort];
|
||||
}
|
||||
|
||||
if (modelOverride) {
|
||||
log.info(`Using model: ${modelOverride} (effort: ${effort})`);
|
||||
} else if (!existsSync(projectCliConfigPath)) {
|
||||
log.info(`Using default model (effort: ${effort})`);
|
||||
}
|
||||
|
||||
// track logged model_call_ids to avoid duplicates
|
||||
// cursor emits each assistant message twice: once without model_call_id, then again with it
|
||||
const loggedModelCallIds = new Set<string>();
|
||||
@@ -171,16 +206,16 @@ export const cursor = agent({
|
||||
|
||||
// configure sandbox mode if enabled
|
||||
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
|
||||
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"];
|
||||
|
||||
// add model flag if we have an override
|
||||
if (modelOverride) {
|
||||
baseArgs.push("--model", modelOverride);
|
||||
}
|
||||
|
||||
const cursorArgs = payload.sandbox
|
||||
? [
|
||||
"--print",
|
||||
fullPrompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--approve-mcps",
|
||||
// --force removed in sandbox mode to enforce safety checks
|
||||
]
|
||||
: ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps", "--force"];
|
||||
? baseArgs // --force removed in sandbox mode to enforce safety checks
|
||||
: [...baseArgs, "--force"];
|
||||
|
||||
if (payload.sandbox) {
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
|
||||
+42
-11
@@ -1,6 +1,7 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Effort } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
@@ -11,6 +12,15 @@ import {
|
||||
installFromGithub,
|
||||
} from "./shared.ts";
|
||||
|
||||
// effort configuration: model + thinking level
|
||||
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
|
||||
// see: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels
|
||||
const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string }> = {
|
||||
nothink: { model: "gemini-2.5-flash", thinkingLevel: "LOW" },
|
||||
think: { model: "gemini-2.5-flash", thinkingLevel: "HIGH" },
|
||||
max: { model: "gemini-2.5-pro", thinkingLevel: "HIGH" },
|
||||
} as const;
|
||||
|
||||
// gemini cli event types inferred from stream-json output (NDJSON format)
|
||||
interface GeminiInitEvent {
|
||||
type: "init";
|
||||
@@ -156,8 +166,12 @@ export const gemini = agent({
|
||||
...(githubInstallationToken && { githubInstallationToken }),
|
||||
});
|
||||
},
|
||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
||||
configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic });
|
||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
|
||||
// get model and thinking level based on effort
|
||||
const { model, thinkingLevel } = geminiEffortConfig[effort];
|
||||
log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
|
||||
configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel });
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
||||
@@ -172,6 +186,8 @@ export const gemini = agent({
|
||||
if (payload.sandbox) {
|
||||
// sandbox mode: read-only tools only
|
||||
args = [
|
||||
"--model",
|
||||
model,
|
||||
"--allowed-tools",
|
||||
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
||||
"--allowed-mcp-server-names",
|
||||
@@ -183,7 +199,7 @@ export const gemini = agent({
|
||||
} else {
|
||||
// normal mode: --yolo for auto-approval
|
||||
// for public repos, shell is excluded via settings.json excludeTools
|
||||
args = ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
||||
args = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
||||
if (repo.isPublic) {
|
||||
log.info("🔒 public repo: native shell disabled via excludeTools, using MCP bash");
|
||||
}
|
||||
@@ -276,16 +292,22 @@ export const gemini = agent({
|
||||
type ConfigureGeminiParams = {
|
||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||
isPublicRepo: boolean;
|
||||
thinkingLevel: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure MCP servers for Gemini by writing to settings.json.
|
||||
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport.
|
||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||
* Configure Gemini CLI settings by writing to settings.json.
|
||||
* - MCP servers: uses `httpUrl` for HTTP/streamable transport
|
||||
* - thinkingLevel: configured via modelConfig.generateContentConfig.thinkingConfig
|
||||
* - For public repos, excludeTools disables native shell
|
||||
*
|
||||
* For public repos, also configures excludeTools to disable native shell.
|
||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||
*/
|
||||
function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGeminiParams): void {
|
||||
function configureGeminiSettings({
|
||||
mcpServers,
|
||||
isPublicRepo,
|
||||
thinkingLevel,
|
||||
}: ConfigureGeminiParams): void {
|
||||
const realHome = homedir();
|
||||
const geminiConfigDir = join(realHome, ".gemini");
|
||||
const settingsPath = join(geminiConfigDir, "settings.json");
|
||||
@@ -329,17 +351,26 @@ function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGemini
|
||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
||||
}
|
||||
|
||||
// merge with existing settings, overwriting mcpServers
|
||||
// for public repos, exclude native shell tool to prevent secret leakage via env
|
||||
// merge with existing settings, overwriting mcpServers and modelConfig
|
||||
const newSettings: Record<string, unknown> = {
|
||||
...existingSettings,
|
||||
mcpServers: geminiMcpServers,
|
||||
// configure thinking level via modelConfig
|
||||
// see: https://ai.google.dev/api/generate-content (ThinkingConfig)
|
||||
modelConfig: {
|
||||
generateContentConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingLevel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// for public repos, exclude native shell tool to prevent secret leakage via env
|
||||
if (isPublicRepo) {
|
||||
newSettings.excludeTools = ["run_shell_command"];
|
||||
}
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
||||
log.info(`» MCP config written to ${settingsPath}`);
|
||||
log.info(`» Gemini settings written to ${settingsPath}`);
|
||||
}
|
||||
|
||||
+11
-4
@@ -22,7 +22,15 @@ export const opencode = agent({
|
||||
installDependencies: true,
|
||||
});
|
||||
},
|
||||
run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, repo }) => {
|
||||
run: async ({
|
||||
payload,
|
||||
apiKey: _apiKey,
|
||||
apiKeys,
|
||||
mcpServers,
|
||||
cliPath,
|
||||
repo,
|
||||
effort: _effort,
|
||||
}) => {
|
||||
// 1. configure home/config directory
|
||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||
const configDir = join(tempHome, ".config", "opencode");
|
||||
@@ -60,10 +68,9 @@ export const opencode = agent({
|
||||
|
||||
// add API keys from apiKeys object
|
||||
for (const [key, value] of Object.entries(apiKeys || {})) {
|
||||
const upperKey = key.toUpperCase();
|
||||
env[upperKey] = value;
|
||||
env[key.toUpperCase()] = value;
|
||||
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
|
||||
if (upperKey === "GEMINI_API_KEY") {
|
||||
if (key === "GEMINI_API_KEY") {
|
||||
env.GOOGLE_GENERATIVE_AI_API_KEY = value;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -6,7 +6,13 @@ import { join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { show } from "@ark/util";
|
||||
import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts";
|
||||
import {
|
||||
type AgentManifest,
|
||||
type AgentName,
|
||||
agentsManifest,
|
||||
type Effort,
|
||||
type Payload,
|
||||
} from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { getGitHubInstallationToken } from "../utils/github.ts";
|
||||
|
||||
@@ -40,6 +46,7 @@ export interface AgentConfig {
|
||||
mcpServers: Record<string, McpHttpServerConfig>;
|
||||
cliPath: string;
|
||||
repo: RepoInfo;
|
||||
effort: Effort;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
# WebFetch Tool Analysis
|
||||
|
||||
Analysis of webfetch/URL fetching implementations across three AI coding agents to inform the design of pullfrog's custom webfetch MCP tool.
|
||||
|
||||
---
|
||||
|
||||
## 1. OpenCode Implementation
|
||||
|
||||
**Source**: `packages/opencode/src/tool/webfetch.ts`
|
||||
|
||||
### Architecture
|
||||
|
||||
OpenCode's webfetch is straightforward - a simple fetch wrapper with HTML-to-markdown conversion:
|
||||
|
||||
```typescript
|
||||
const response = await fetch(params.url, {
|
||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...",
|
||||
Accept: acceptHeader,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|---------------|
|
||||
| **Output formats** | `text`, `markdown`, `html` (default: markdown) |
|
||||
| **HTML→Markdown** | Uses `turndown` library |
|
||||
| **Max response size** | 5MB hard limit |
|
||||
| **Timeout** | 30s default, 120s max |
|
||||
| **Permission system** | Application-level `ctx.ask()` prompt |
|
||||
| **Domain blocking** | None - relies on user approval |
|
||||
| **Caching** | None |
|
||||
| **Redirect handling** | Native fetch behavior |
|
||||
|
||||
### HTML Processing
|
||||
|
||||
Two methods depending on output format:
|
||||
|
||||
1. **`extractTextFromHTML()`** - Uses Bun's `HTMLRewriter` to strip scripts/styles and extract text
|
||||
2. **`convertHTMLToMarkdown()`** - Uses `turndown` with sensible defaults (ATX headings, fenced code blocks)
|
||||
|
||||
### Permission Model
|
||||
|
||||
```typescript
|
||||
await ctx.ask({
|
||||
permission: "webfetch",
|
||||
patterns: [params.url],
|
||||
always: ["*"], // User can allow all future requests
|
||||
metadata: { url, format, timeout },
|
||||
})
|
||||
```
|
||||
|
||||
**Verdict**: No enforcement - purely advisory. If user approves, the fetch proceeds with no restrictions.
|
||||
|
||||
### What I Like
|
||||
- Clean, minimal implementation
|
||||
- Good HTML processing with `turndown`
|
||||
- Sensible size limits (5MB)
|
||||
- Format flexibility
|
||||
|
||||
### What I Don't Like
|
||||
- No domain whitelisting/blocklisting
|
||||
- No caching (repeated requests to same URL are wasteful)
|
||||
- Permission system is advisory-only
|
||||
- No redirect safety checks
|
||||
|
||||
---
|
||||
|
||||
## 2. Claude Code Implementation
|
||||
|
||||
**Source**: Extracted from bundled `claude` CLI binary
|
||||
|
||||
### Architecture
|
||||
|
||||
Claude Code uses a more sophisticated approach with server-side domain validation:
|
||||
|
||||
```javascript
|
||||
// Domain validation before fetch
|
||||
async function Ci5(domain) {
|
||||
const response = await fetch(
|
||||
`https://claude.ai/api/web/domain_info?domain=${encodeURIComponent(domain)}`
|
||||
);
|
||||
if (response.status === 200) {
|
||||
return response.data.can_fetch === true
|
||||
? { status: "allowed" }
|
||||
: { status: "blocked" };
|
||||
}
|
||||
return { status: "check_failed" };
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|---------------|
|
||||
| **Domain blocklist** | Server-side API at `claude.ai/api/web/domain_info` |
|
||||
| **Permission format** | `WebFetch(domain:example.com)` - domain-only, not URLs |
|
||||
| **Wildcard support** | `domain:*.google.com` patterns |
|
||||
| **HTTP→HTTPS upgrade** | Automatic protocol upgrade |
|
||||
| **Caching** | 15-minute self-cleaning cache |
|
||||
| **HTML→Markdown** | Uses `turndown` |
|
||||
| **Redirect handling** | Special handling - informs user of cross-host redirects |
|
||||
| **Enterprise override** | `skipWebFetchPreflight` setting |
|
||||
|
||||
### Domain Permission Model
|
||||
|
||||
Claude Code enforces domain-level permissions, not URL-level:
|
||||
|
||||
```javascript
|
||||
WebFetch: (A) => {
|
||||
if (A.includes("://") || A.startsWith("http"))
|
||||
return {
|
||||
valid: false,
|
||||
error: "WebFetch permissions use domain format, not URLs",
|
||||
suggestion: 'Use "domain:hostname" format',
|
||||
examples: ["WebFetch(domain:example.com)", "WebFetch(domain:github.com)"]
|
||||
};
|
||||
if (!A.startsWith("domain:"))
|
||||
return {
|
||||
valid: false,
|
||||
error: 'WebFetch permissions must use "domain:" prefix',
|
||||
examples: ["WebFetch(domain:example.com)", "WebFetch(domain:*.google.com)"]
|
||||
};
|
||||
return { valid: true };
|
||||
}
|
||||
```
|
||||
|
||||
### Blocklist Enforcement Flow
|
||||
|
||||
```
|
||||
User requests URL
|
||||
↓
|
||||
Extract hostname
|
||||
↓
|
||||
Check claude.ai/api/web/domain_info?domain=hostname
|
||||
↓
|
||||
┌──────────────────────────────────┐
|
||||
│ allowed → proceed with fetch │
|
||||
│ blocked → throw AC0 error │
|
||||
│ check_failed → throw QC0 error │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Redirect Handling
|
||||
|
||||
When a URL redirects to a different host:
|
||||
```javascript
|
||||
// Returns special response asking user to manually re-request
|
||||
return `To complete your request, I need to fetch content from the redirected URL.
|
||||
Please use WebFetch again with these parameters:
|
||||
- url: "${redirectUrl}"
|
||||
- prompt: "${originalPrompt}"`;
|
||||
```
|
||||
|
||||
This prevents open redirect attacks where a "safe" domain redirects to a malicious one.
|
||||
|
||||
### What I Like
|
||||
- **Server-side blocklist** - centralized, updateable without client changes
|
||||
- **Domain-level permissions** - prevents path-based bypasses
|
||||
- **Redirect safety** - cross-host redirects require explicit user action
|
||||
- **15-minute caching** - reduces redundant requests
|
||||
- **Enterprise override** - `skipWebFetchPreflight` for corporate environments
|
||||
|
||||
### What I Don't Like
|
||||
- **External dependency** - requires `claude.ai` API to be available
|
||||
- **No local blocklist** - can't work offline or with custom blocklists
|
||||
- **Opaque blocklist** - users can't see what's blocked or why
|
||||
|
||||
---
|
||||
|
||||
## 3. Gemini CLI Implementation
|
||||
|
||||
**Source**: `@google/gemini-cli` npm package
|
||||
|
||||
### Architecture
|
||||
|
||||
Gemini CLI takes a fundamentally different approach - it doesn't have a dedicated webfetch tool. Instead it relies on:
|
||||
|
||||
1. **Google Search grounding** - built into the Gemini API
|
||||
2. **MCP servers** - external tools can provide fetch capabilities
|
||||
3. **No native URL fetching** - by design
|
||||
|
||||
### Key Observations
|
||||
|
||||
From searching the codebase:
|
||||
- No `webfetch`, `url_fetch`, or similar tool definitions
|
||||
- Has `github_fetch.ts` for fetching GitHub releases (internal use)
|
||||
- Relies on model's built-in capabilities or MCP extensions
|
||||
|
||||
### Why No WebFetch?
|
||||
|
||||
Gemini's design philosophy appears to be:
|
||||
1. Use the model's grounding capabilities for web information
|
||||
2. Delegate specialized fetching to MCP servers
|
||||
3. Avoid building network access into the CLI itself
|
||||
|
||||
### What I Like
|
||||
- **Clean separation** - network access is opt-in via MCP
|
||||
- **Security by default** - no built-in way to exfiltrate data
|
||||
|
||||
### What I Don't Like
|
||||
- **Missing functionality** - can't fetch arbitrary URLs
|
||||
- **Requires MCP setup** - more complex for users who need fetching
|
||||
|
||||
---
|
||||
|
||||
## 4. Pullfrog Design Decisions
|
||||
|
||||
### Core Requirements
|
||||
|
||||
1. **Domain-level whitelisting** - enforced in-tool, not advisory
|
||||
2. **Simple implementation** - no external API dependencies
|
||||
3. **GitHub-focused** - optimized for common development URLs
|
||||
|
||||
### Proposed Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ WebFetch Tool │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ 1. Parse URL → extract hostname │
|
||||
│ 2. Check against DOMAIN_ALLOWLIST │
|
||||
│ 3. If not allowed → return error (not throw) │
|
||||
│ 4. Fetch with timeout + size limits │
|
||||
│ 5. Convert HTML → Markdown if needed │
|
||||
│ 6. Return content │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Domain Allowlist Strategy
|
||||
|
||||
**Included in initial allowlist**:
|
||||
```typescript
|
||||
const DOMAIN_ALLOWLIST = new Set([
|
||||
// Documentation sites
|
||||
"docs.github.com",
|
||||
"developer.mozilla.org",
|
||||
"nodejs.org",
|
||||
"docs.python.org",
|
||||
"go.dev",
|
||||
"doc.rust-lang.org",
|
||||
"docs.microsoft.com",
|
||||
"learn.microsoft.com",
|
||||
|
||||
// Package registries (documentation)
|
||||
"npmjs.com",
|
||||
"www.npmjs.com",
|
||||
"pypi.org",
|
||||
"crates.io",
|
||||
"pkg.go.dev",
|
||||
|
||||
// GitHub (raw content, gists)
|
||||
"raw.githubusercontent.com",
|
||||
"gist.githubusercontent.com",
|
||||
|
||||
// Common API documentation
|
||||
"api.github.com", // Already have GitHub tools, but for reference docs
|
||||
]);
|
||||
```
|
||||
|
||||
**Explicitly NOT included**:
|
||||
- `github.com` itself - we have dedicated GitHub MCP tools
|
||||
- Social media sites
|
||||
- General web pages
|
||||
- Arbitrary user-provided domains
|
||||
|
||||
### Features Borrowed from Each Agent
|
||||
|
||||
| Feature | Source | Included? | Rationale |
|
||||
|---------|--------|-----------|-----------|
|
||||
| HTML→Markdown via turndown | OpenCode | ✅ | Clean, proven library |
|
||||
| 5MB size limit | OpenCode | ✅ | Sensible default |
|
||||
| Domain-level permissions | Claude Code | ✅ | Core requirement |
|
||||
| Redirect safety checks | Claude Code | ✅ | Prevents open redirect attacks |
|
||||
| 15-minute caching | Claude Code | ❌ | Adds complexity, MCP is stateless |
|
||||
| Server-side blocklist | Claude Code | ❌ | External dependency |
|
||||
| Enterprise override | Claude Code | ❌ | Not needed for GitHub Actions |
|
||||
| No built-in fetching | Gemini | ❌ | We need this functionality |
|
||||
|
||||
### Features NOT Included (and why)
|
||||
|
||||
1. **Caching** - MCP tools are stateless by design. Caching would require shared state across requests. The agent can cache results itself.
|
||||
|
||||
2. **Server-side blocklist** - Would require standing up an API endpoint. The allowlist approach is simpler and more transparent.
|
||||
|
||||
3. **User permission prompts** - In GitHub Actions context, there's no interactive user. Allowlist is enforced automatically.
|
||||
|
||||
4. **Wildcard domain patterns** - Adds complexity. Start with explicit domains, add patterns if needed.
|
||||
|
||||
5. **Multiple output formats** - Start with markdown only. Can add `text` and `html` later if needed.
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
Unlike OpenCode/Claude which throw errors, we return errors as content:
|
||||
|
||||
```typescript
|
||||
// Domain not allowed - return message, don't throw
|
||||
if (!isDomainAllowed(hostname)) {
|
||||
return {
|
||||
output: `Domain "${hostname}" is not in the allowlist. Allowed domains: ${Array.from(DOMAIN_ALLOWLIST).join(", ")}`,
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This lets the agent understand the limitation and potentially find alternative approaches.
|
||||
|
||||
### Redirect Handling
|
||||
|
||||
Adopt Claude Code's approach with modification:
|
||||
|
||||
```typescript
|
||||
// If redirect crosses domains, check the new domain
|
||||
if (response.redirected) {
|
||||
const redirectUrl = new URL(response.url);
|
||||
if (!isDomainAllowed(redirectUrl.hostname)) {
|
||||
return {
|
||||
output: `URL redirected to "${redirectUrl.hostname}" which is not in the allowlist.`,
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Plan
|
||||
|
||||
### Summary
|
||||
|
||||
Add a new `webfetch` MCP tool that fetches web content with domain-level whitelisting enforced server-side. The whitelist is configured via the payload (from GitHub App), and non-whitelisted domains return a helpful message guiding the LLM to alternative approaches.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
| Aspect | OpenCode | Claude Code | Our Implementation |
|
||||
|--------|----------|-------------|-------------------|
|
||||
| **Whitelisting** | Permission prompt (advisory) | External API `domain_info` | Payload-configured whitelist |
|
||||
| **Enforcement** | None (user approval) | Server-side check | Server-side check |
|
||||
| **HTML Processing** | Turndown for markdown | Turndown for markdown | Turndown for markdown |
|
||||
| **Redirects** | Follows automatically | Detects cross-host redirects | Follow with host check |
|
||||
| **Timeout** | 30s default, 120s max | Configurable | 30s default, 120s max |
|
||||
|
||||
### Step 1: Add whitelist to payload type
|
||||
|
||||
Update `index.ts` to include `allowedWebFetchDomains`:
|
||||
|
||||
```typescript
|
||||
interface Payload {
|
||||
// ... existing fields
|
||||
allowedWebFetchDomains?: string[]; // e.g. ["github.com", "*.npmjs.com", "docs.python.org"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create `mcp/webfetch.ts`
|
||||
|
||||
```typescript
|
||||
// Core structure
|
||||
export const WebFetchParams = type({
|
||||
url: "string",
|
||||
"format?": "'markdown' | 'text' | 'html'",
|
||||
"timeout?": "number",
|
||||
});
|
||||
|
||||
export function WebFetchTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "webfetch",
|
||||
description: `Fetch content from whitelisted web URLs...`,
|
||||
parameters: WebFetchParams,
|
||||
execute: execute(async (params) => {
|
||||
// 1. Validate URL format
|
||||
// 2. Check domain against whitelist (from ctx.payload)
|
||||
// 3. Fetch with timeout and size limits
|
||||
// 4. Convert HTML to markdown if needed
|
||||
// 5. Return content or guidance message
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Domain Matching Logic
|
||||
|
||||
Support wildcards for subdomains:
|
||||
|
||||
- `github.com` - exact match
|
||||
- `*.github.com` - any subdomain (e.g., `docs.github.com`, `api.github.com`)
|
||||
- `*.npmjs.com` - matches `www.npmjs.com`, `registry.npmjs.com`, etc.
|
||||
|
||||
```typescript
|
||||
function isDomainAllowed(hostname: string, whitelist: string[]): boolean {
|
||||
for (const pattern of whitelist) {
|
||||
if (pattern.startsWith("*.")) {
|
||||
const suffix = pattern.slice(1); // ".github.com"
|
||||
if (hostname.endsWith(suffix) || hostname === pattern.slice(2)) {
|
||||
return true;
|
||||
}
|
||||
} else if (hostname === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Response for Non-Whitelisted Domains
|
||||
|
||||
When domain is not whitelisted, return guidance (not an error):
|
||||
|
||||
```typescript
|
||||
return {
|
||||
allowed: false,
|
||||
message: `The domain "${hostname}" is not in the allowed list for direct fetching. ` +
|
||||
`Consider using web_search to find relevant information, or ask the user to ` +
|
||||
`provide the content directly. Allowed domains: ${whitelist.join(", ")}`,
|
||||
};
|
||||
```
|
||||
|
||||
### Step 5: HTML to Markdown Conversion
|
||||
|
||||
Use Turndown (same as OpenCode) for HTML-to-markdown conversion:
|
||||
|
||||
```typescript
|
||||
import TurndownService from "turndown";
|
||||
|
||||
function htmlToMarkdown(html: string): string {
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
});
|
||||
turndown.remove(["script", "style", "meta", "link"]);
|
||||
return turndown.turndown(html);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Register the Tool
|
||||
|
||||
Add to `mcp/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { WebFetchTool } from "./webfetch.ts";
|
||||
|
||||
// In the tools array
|
||||
WebFetchTool(ctx),
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant LLM
|
||||
participant MCP as MCP Server
|
||||
participant WF as WebFetch Tool
|
||||
participant Web as External URL
|
||||
|
||||
LLM->>MCP: webfetch(url, format)
|
||||
MCP->>WF: execute(params)
|
||||
WF->>WF: Parse URL, extract hostname
|
||||
WF->>WF: Check whitelist from payload
|
||||
alt Domain allowed
|
||||
WF->>Web: fetch(url)
|
||||
Web-->>WF: Response
|
||||
WF->>WF: Convert to markdown
|
||||
WF-->>MCP: {content, contentType}
|
||||
MCP-->>LLM: Success result
|
||||
else Domain not allowed
|
||||
WF-->>MCP: {allowed: false, guidance}
|
||||
MCP-->>LLM: Guidance message
|
||||
end
|
||||
```
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `mcp/webfetch.ts` | Create - main tool implementation |
|
||||
| `mcp/index.ts` | Modify - register the tool |
|
||||
| `index.ts` | Modify - add `allowedWebFetchDomains` to payload type |
|
||||
| `package.json` | Modify - add `turndown` dependency |
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `package.json`:
|
||||
|
||||
- `turndown` - HTML to markdown conversion (same as OpenCode)
|
||||
- `@types/turndown` - TypeScript types
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Checklist
|
||||
|
||||
- [ ] Add `allowedWebFetchDomains` field to payload type in `index.ts`
|
||||
- [ ] Create `mcp/webfetch.ts` with domain whitelisting and HTML conversion
|
||||
- [ ] Register `WebFetchTool` in `mcp/index.ts`
|
||||
- [ ] Add `turndown` and `@types/turndown` dependencies to `package.json`
|
||||
- [ ] Test with allowed domains
|
||||
- [ ] Test with blocked domains
|
||||
- [ ] Test redirect behavior
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions
|
||||
|
||||
1. **Should we support query parameters in allowlist?**
|
||||
- e.g., allow `api.example.com/v1/*` but not `api.example.com/admin/*`
|
||||
- Initial decision: No, domain-level only
|
||||
|
||||
2. **Should we allow configurable allowlists?**
|
||||
- Via environment variable or config file?
|
||||
- Initial decision: No, hardcoded for simplicity
|
||||
|
||||
3. **Should we support authentication headers?**
|
||||
- For private documentation sites
|
||||
- Initial decision: No, security risk
|
||||
|
||||
4. **Rate limiting?**
|
||||
- Prevent agent from hammering a site
|
||||
- Initial decision: Rely on timeout, add if needed
|
||||
@@ -0,0 +1,435 @@
|
||||
# Web Search Functionality by Agent
|
||||
|
||||
This document describes how each supported agent implements web search functionality.
|
||||
|
||||
## Summary
|
||||
|
||||
| Agent | Tool Name | Search Provider | API/Method |
|
||||
|-------|-----------|-----------------|------------|
|
||||
| Claude Code | `WebSearch` | Anthropic internal | Claude Code SDK |
|
||||
| Gemini CLI | `google_web_search` | Google Search via Gemini API | `generateContent` with `model: 'web-search'` |
|
||||
| OpenCode | `websearch` | Exa AI | MCP protocol to `https://mcp.exa.ai/mcp` |
|
||||
|
||||
All three agents also support a separate **web fetch** tool for directly retrieving and parsing web page content.
|
||||
|
||||
---
|
||||
|
||||
## Claude Code
|
||||
|
||||
### Tools
|
||||
- `WebSearch` - Search the web for information
|
||||
- `WebFetch` - Fetch and process web content
|
||||
|
||||
### Implementation
|
||||
Native functionality through `@anthropic-ai/claude-agent-sdk` (closed source). The actual search provider is internal to Anthropic's infrastructure.
|
||||
|
||||
### Configuration in Pullfrog
|
||||
|
||||
Web search can be disabled via the `disallowedTools` option:
|
||||
|
||||
```typescript
|
||||
// In sandbox mode, web tools are disabled
|
||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gemini CLI
|
||||
|
||||
### Tools
|
||||
- `google_web_search` - Perform web searches using Google Search
|
||||
- `web_fetch` - Fetch and process content from URLs
|
||||
|
||||
### Implementation
|
||||
|
||||
Source: [`packages/core/src/tools/web-search.ts`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/tools/web-search.ts)
|
||||
|
||||
**How it works:**
|
||||
1. Sends query to Gemini API using `generateContent` with `model: 'web-search'`
|
||||
2. Google performs the search and returns results with grounding metadata
|
||||
3. Response includes inline citations, source URLs, and titles
|
||||
|
||||
```typescript
|
||||
const response = await geminiClient.generateContent(
|
||||
{ model: 'web-search' },
|
||||
[{ role: 'user', parts: [{ text: this.params.query }] }],
|
||||
signal,
|
||||
);
|
||||
```
|
||||
|
||||
### Features
|
||||
- Returns processed summary (not raw search results)
|
||||
- Inline citations with grounding metadata
|
||||
- Sources list with titles and URIs
|
||||
- UTF-8 byte position handling for accurate citation insertion
|
||||
|
||||
### Parameters
|
||||
- `query` (string, required): The search query
|
||||
|
||||
### Web Fetch
|
||||
|
||||
The `web_fetch` tool processes content from URLs:
|
||||
- Uses Gemini API's `urlContext` feature
|
||||
- Fallback to direct HTTP fetch with `html-to-text` conversion
|
||||
- Supports up to 20 URLs per request
|
||||
- Converts GitHub blob URLs to raw URLs automatically
|
||||
|
||||
---
|
||||
|
||||
## OpenCode
|
||||
|
||||
### Tools
|
||||
- `websearch` - Search the web using Exa AI
|
||||
- `webfetch` - Fetch and read web pages
|
||||
|
||||
### Implementation
|
||||
|
||||
Source: [`packages/opencode/src/tool/websearch.ts`](https://github.com/sst/opencode/blob/main/packages/opencode/src/tool/websearch.ts)
|
||||
|
||||
**How it works:**
|
||||
1. Calls Exa AI's MCP endpoint at `https://mcp.exa.ai/mcp`
|
||||
2. Uses JSON-RPC protocol to invoke the `web_search_exa` tool
|
||||
3. Parses SSE response for search results
|
||||
|
||||
```typescript
|
||||
const searchRequest: McpSearchRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: params.query,
|
||||
type: params.type || "auto",
|
||||
numResults: params.numResults || 8,
|
||||
livecrawl: params.livecrawl || "fallback",
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
- Real-time web searches with content scraping
|
||||
- Configurable result count (default: 8)
|
||||
- Live crawl modes: `fallback` (backup if cached unavailable) or `preferred` (prioritize live crawling)
|
||||
- Search types: `auto` (balanced), `fast` (quick results), `deep` (comprehensive)
|
||||
- Context max characters for LLM optimization
|
||||
|
||||
### Parameters
|
||||
- `query` (string, required): The search query
|
||||
- `numResults` (number, optional): Number of results to return (default: 8)
|
||||
- `livecrawl` (enum, optional): `"fallback"` | `"preferred"`
|
||||
- `type` (enum, optional): `"auto"` | `"fast"` | `"deep"`
|
||||
- `contextMaxCharacters` (number, optional): Maximum characters for context
|
||||
|
||||
### Configuration in Pullfrog
|
||||
|
||||
Web tools are configured via the permission config in `opencode.json`:
|
||||
|
||||
```typescript
|
||||
// In sandbox mode
|
||||
permission: {
|
||||
webfetch: "deny",
|
||||
// ...
|
||||
}
|
||||
|
||||
// In normal mode
|
||||
permission: {
|
||||
webfetch: "allow",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
- `OPENCODE_ENABLE_EXA` - Enable Exa web search tools (required for "zen" users)
|
||||
|
||||
### Web Fetch
|
||||
|
||||
The `webfetch` tool directly fetches URLs:
|
||||
- Direct HTTP fetch with browser-like User-Agent
|
||||
- HTML to Markdown conversion using Turndown
|
||||
- Configurable timeout (max 120 seconds)
|
||||
- 5MB response size limit
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Claude Code | Gemini CLI | OpenCode |
|
||||
|---------|-------------|------------|----------|
|
||||
| Search Provider | Anthropic | Google | Exa AI |
|
||||
| Result Format | Summary | Summary + Citations | Raw content |
|
||||
| URL Fetching | Yes (`WebFetch`) | Yes (`web_fetch`) | Yes (`webfetch`) |
|
||||
| Grounding/Citations | Unknown | Yes | No |
|
||||
| Configurable Results | No | No | Yes (numResults) |
|
||||
| Search Depth Options | No | No | Yes (auto/fast/deep) |
|
||||
| Live Crawling | Unknown | Fallback only | Configurable |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
In Pullfrog's sandbox mode:
|
||||
- **Claude Code**: `WebSearch` and `WebFetch` are explicitly disabled via `disallowedTools`
|
||||
- **Gemini CLI**: No explicit disable mechanism in the wrapper (relies on default behavior)
|
||||
- **OpenCode**: `webfetch` permission set to `"deny"` in sandbox mode
|
||||
|
||||
For public repositories, consider the implications of web search/fetch:
|
||||
- Fetched content could potentially be used to inject prompts
|
||||
- Search queries might leak information about the codebase context
|
||||
|
||||
---
|
||||
|
||||
## Proposed Implementation Plan
|
||||
|
||||
### Option 1: Use Native Agent Web Search (Current State)
|
||||
|
||||
Each agent uses its own built-in web search:
|
||||
- **Pros**: No additional implementation, leverages each provider's strengths
|
||||
- **Cons**: Inconsistent behavior across agents, no unified control
|
||||
|
||||
**Current gaps:**
|
||||
- Gemini CLI has no explicit disable mechanism for web search in sandbox mode
|
||||
- No unified way to configure web search across all agents
|
||||
|
||||
### Option 2: Unified MCP Web Search Tool
|
||||
|
||||
Add a `web_search` tool to the Pullfrog MCP server (`mcp/`) that all agents can use:
|
||||
|
||||
```
|
||||
mcp/
|
||||
├── bash.ts
|
||||
├── webSearch.ts # New unified web search tool
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Implementation approach:**
|
||||
|
||||
1. **Create `mcp/webSearch.ts`** with a provider-agnostic interface:
|
||||
```typescript
|
||||
export const webSearchTool = {
|
||||
name: "web_search",
|
||||
description: "Search the web for information",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
numResults: { type: "number", description: "Number of results (default: 5)" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
2. **Choose a search provider** (options):
|
||||
- **Exa AI** - Already used by OpenCode, good LLM-optimized results
|
||||
- **Tavily** - Popular for AI agents, provides search + content extraction
|
||||
- **SerpAPI** - Google results via API
|
||||
- **Brave Search API** - Privacy-focused alternative
|
||||
|
||||
3. **Add to MCP server** in `mcp/server.ts`:
|
||||
```typescript
|
||||
import { webSearchTool, handleWebSearch } from "./webSearch.ts";
|
||||
// Register tool...
|
||||
```
|
||||
|
||||
4. **Disable native web search** for each agent:
|
||||
- Claude: Add `"WebSearch"` to `disallowedTools`
|
||||
- Gemini: Add `"google_web_search"` to `excludeTools` in settings.json
|
||||
- OpenCode: Set `websearch: "deny"` in permission config
|
||||
|
||||
**Pros:**
|
||||
- Consistent behavior across all agents
|
||||
- Centralized control for security/sandbox modes
|
||||
- Can filter/sanitize results before returning to agent
|
||||
- Single API key management
|
||||
|
||||
**Cons:**
|
||||
- Additional API costs (search provider)
|
||||
- Loses provider-specific features (e.g., Gemini's grounding metadata)
|
||||
|
||||
### Option 3: Hybrid Approach
|
||||
|
||||
Allow native web search for private repos, use MCP tool for public repos:
|
||||
|
||||
```typescript
|
||||
// In agent configuration
|
||||
const useNativeWebSearch = !repo.isPublic;
|
||||
|
||||
// Claude
|
||||
disallowedTools: repo.isPublic ? ["WebSearch", "WebFetch"] : [];
|
||||
|
||||
// Gemini
|
||||
excludeTools: repo.isPublic ? ["google_web_search", "web_fetch"] : [];
|
||||
|
||||
// OpenCode
|
||||
permission: {
|
||||
websearch: repo.isPublic ? "deny" : "allow",
|
||||
}
|
||||
```
|
||||
|
||||
Then for public repos, agents would use the MCP `web_search` tool which:
|
||||
- Filters sensitive queries
|
||||
- Sanitizes returned content
|
||||
- Logs all searches for audit
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
**Short-term**: Implement Option 3 (Hybrid) with these steps:
|
||||
|
||||
1. [ ] Add `excludeTools: ["google_web_search"]` for Gemini in public repo mode
|
||||
2. [ ] Ensure OpenCode `websearch` permission is properly set for sandbox mode
|
||||
3. [ ] Document the current native web search behavior for each agent
|
||||
|
||||
**Medium-term**: Implement Option 2 (Unified MCP) for public repos:
|
||||
|
||||
1. [ ] Create `mcp/webSearch.ts` using Exa AI (consistent with OpenCode)
|
||||
2. [ ] Add `EXA_API_KEY` to secrets handling
|
||||
3. [ ] Register web search in MCP server
|
||||
4. [ ] Disable native web search for all agents when MCP tool is available
|
||||
5. [ ] Add result sanitization to prevent prompt injection
|
||||
|
||||
### API Key Requirements
|
||||
|
||||
| Provider | Environment Variable | Notes |
|
||||
|----------|---------------------|-------|
|
||||
| Exa AI | `EXA_API_KEY` | Already used by OpenCode |
|
||||
| Tavily | `TAVILY_API_KEY` | Popular alternative |
|
||||
| Brave | `BRAVE_API_KEY` | Privacy-focused |
|
||||
|
||||
For the unified MCP approach, only one search provider API key would be needed.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Implementation Plan
|
||||
|
||||
### Option A: Unified MCP Web Search Tool
|
||||
|
||||
Create a custom MCP tool that provides consistent web search across all agents.
|
||||
|
||||
**Pros:**
|
||||
- Consistent behavior and results across agents
|
||||
- Full control over search provider and rate limiting
|
||||
- Can implement caching and deduplication
|
||||
- Single point for security filtering
|
||||
|
||||
**Cons:**
|
||||
- Additional infrastructure (need a search API key)
|
||||
- Latency from proxying through MCP server
|
||||
|
||||
**Implementation:**
|
||||
1. Add `websearch` tool to `mcp/` directory
|
||||
2. Integrate with a search provider (options: Exa AI, SerpAPI, Brave Search, Tavily)
|
||||
3. Configure each agent to use MCP tool instead of native:
|
||||
- Claude: Add to `disallowedTools` and provide via MCP
|
||||
- Gemini: Use `excludeTools` in settings.json for `google_web_search`
|
||||
- OpenCode: Disable native via permission config
|
||||
|
||||
```typescript
|
||||
// mcp/websearch.ts
|
||||
export const websearchTool = {
|
||||
name: "websearch",
|
||||
description: "Search the web for current information",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
numResults: { type: "number", description: "Number of results (1-10)" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
handler: async ({ query, numResults = 5 }) => {
|
||||
// Use Exa, Brave, or other search API
|
||||
const results = await searchProvider.search(query, numResults);
|
||||
return formatResults(results);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Option B: Native Tools with Configuration
|
||||
|
||||
Keep using each agent's native web search but add consistent configuration.
|
||||
|
||||
**Pros:**
|
||||
- No additional infrastructure
|
||||
- Agents can use optimized native implementations
|
||||
- Less latency
|
||||
|
||||
**Cons:**
|
||||
- Inconsistent results across agents
|
||||
- Different capabilities per agent
|
||||
- Harder to control/audit searches
|
||||
|
||||
**Implementation:**
|
||||
1. Add `websearch_enabled` option to payload/config
|
||||
2. Update each agent wrapper:
|
||||
- Claude: Toggle `WebSearch` in `disallowedTools`
|
||||
- Gemini: Add `google_web_search` to `excludeTools` in settings.json
|
||||
- OpenCode: Set `websearch` permission in config
|
||||
|
||||
```typescript
|
||||
// agents/claude.ts
|
||||
const disallowedTools = payload.websearchEnabled
|
||||
? ["Bash"]
|
||||
: ["Bash", "WebSearch", "WebFetch"];
|
||||
|
||||
// agents/gemini.ts
|
||||
if (!payload.websearchEnabled) {
|
||||
newSettings.excludeTools = [...(newSettings.excludeTools || []), "google_web_search"];
|
||||
}
|
||||
|
||||
// agents/opencode.ts
|
||||
permission: {
|
||||
websearch: payload.websearchEnabled ? "allow" : "deny",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Option C: Hybrid Approach (Recommended)
|
||||
|
||||
Use native tools when available, with MCP fallback for consistency.
|
||||
|
||||
**Implementation:**
|
||||
1. Define a `websearch` MCP tool as fallback
|
||||
2. For agents with good native search (Claude, Gemini): use native
|
||||
3. For agents without (or with unreliable) search: use MCP tool
|
||||
4. Add configuration to force MCP-only mode if needed
|
||||
|
||||
```typescript
|
||||
// Per-agent configuration
|
||||
const agentWebSearchConfig = {
|
||||
claude: { useNative: true, mcpFallback: false },
|
||||
gemini: { useNative: true, mcpFallback: false },
|
||||
opencode: { useNative: false, mcpFallback: true }, // Exa requires API key
|
||||
};
|
||||
```
|
||||
|
||||
### Required Changes by Option
|
||||
|
||||
| Change | Option A | Option B | Option C |
|
||||
|--------|----------|----------|----------|
|
||||
| New MCP tool | Yes | No | Yes |
|
||||
| Search API key | Yes | No | Optional |
|
||||
| Agent wrapper changes | Yes | Yes | Yes |
|
||||
| Action input changes | No | Yes | Yes |
|
||||
| External dependencies | Yes | No | Optional |
|
||||
|
||||
### Recommended Next Steps
|
||||
|
||||
1. **Decide on search provider** - If going with MCP approach:
|
||||
- Exa AI: Already used by OpenCode, good for code-related searches
|
||||
- Brave Search: Privacy-focused, good general search
|
||||
- Tavily: Designed for AI agents, includes content extraction
|
||||
|
||||
2. **Add configuration** - New action inputs:
|
||||
```yaml
|
||||
websearch:
|
||||
description: 'Enable web search functionality'
|
||||
required: false
|
||||
default: 'false'
|
||||
```
|
||||
|
||||
3. **Implement per-agent** - Start with Option B (simplest), upgrade to C if needed
|
||||
|
||||
4. **Add security controls** - Query filtering, domain allowlists, rate limiting
|
||||
@@ -0,0 +1,92 @@
|
||||
# Effort Levels
|
||||
|
||||
Pullfrog supports three effort levels that control model selection and reasoning depth:
|
||||
|
||||
- **`nothink`** — Fast, minimal reasoning. Best for simple tasks.
|
||||
- **`think`** — Balanced (default). Good for most tasks.
|
||||
- **`max`** — Maximum capability. Best for complex tasks requiring deep reasoning.
|
||||
|
||||
The effort level can be specified via the `effort` input in `action.yml` or in the payload's `effort` field.
|
||||
|
||||
---
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code uses model selection based on effort level.
|
||||
|
||||
| Effort | Model | Description |
|
||||
|--------|-------|-------------|
|
||||
| `nothink` | `haiku` | Fast, efficient |
|
||||
| `think` | `opusplan` | Opus for planning, Sonnet for execution |
|
||||
| `max` | `opus` | Full Opus |
|
||||
|
||||
> **Future direction:** Anthropic's beta `effort` parameter (`low`/`medium`/`high`) could replace model selection, using Opus 4.5 for all tasks with effort controlling token spend. See [Anthropic Effort Docs](https://platform.claude.com/docs/en/build-with-claude/effort).
|
||||
|
||||
---
|
||||
|
||||
## Codex (OpenAI)
|
||||
|
||||
Codex uses both model selection and the `modelReasoningEffort` parameter from `ThreadOptions`.
|
||||
|
||||
| Effort | Model | `modelReasoningEffort` | Description |
|
||||
|--------|-------|------------------------|-------------|
|
||||
| `nothink` | `gpt-5.1-codex-mini` | `"low"` | Smaller model, reduced reasoning |
|
||||
| `think` | `gpt-5.1-codex` | default | Standard model, default reasoning |
|
||||
| `max` | `gpt-5.1-codex-max` | `"high"` | Largest model, maximum reasoning |
|
||||
|
||||
Valid values for `modelReasoningEffort`: `"minimal"` | `"low"` | `"medium"` | `"high"`
|
||||
|
||||
Reference: [Codex Config Reference](https://developers.openai.com/codex/config-reference/)
|
||||
|
||||
---
|
||||
|
||||
## Gemini
|
||||
|
||||
Gemini uses a combination of model selection and `thinkingLevel` configuration via `settings.json`.
|
||||
|
||||
| Effort | Model | `thinkingLevel` | Description |
|
||||
|--------|-------|-----------------|-------------|
|
||||
| `nothink` | `gemini-2.5-flash` | `LOW` | Fast model, minimal thinking |
|
||||
| `think` | `gemini-2.5-flash` | `HIGH` | Fast model, deep thinking |
|
||||
| `max` | `gemini-2.5-pro` | `HIGH` | Most capable model, deep thinking |
|
||||
|
||||
The `thinkingLevel` is configured via:
|
||||
```json
|
||||
{
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"thinkingConfig": {
|
||||
"thinkingLevel": "LOW"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Gemini Thinking Docs](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels)
|
||||
|
||||
---
|
||||
|
||||
## Cursor
|
||||
|
||||
Cursor uses model selection via the `--model` CLI flag. Project-level configuration in `.cursor/cli.json` takes precedence if a `model` is specified there.
|
||||
|
||||
| Effort | Model | Description |
|
||||
|--------|-------|-------------|
|
||||
| `nothink` | `auto` (default) | Let Cursor select optimal model |
|
||||
| `think` | `auto` (default) | Let Cursor select optimal model |
|
||||
| `max` | `opus-4.5-thinking` | Claude 4.5 Opus with thinking |
|
||||
|
||||
**Note:** If the project has `.cursor/cli.json` with a `model` field, that model is used regardless of effort level.
|
||||
|
||||
---
|
||||
|
||||
## OpenCode
|
||||
|
||||
OpenCode does not currently have affordances for effort-level configuration. The effort parameter is ignored.
|
||||
|
||||
| Effort | Behavior |
|
||||
|--------|----------|
|
||||
| `nothink` | No effect |
|
||||
| `think` | No effect |
|
||||
| `max` | No effect |
|
||||
@@ -5,27 +5,23 @@
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { flatMorph } from "@ark/util";
|
||||
import { agents } from "./agents/index.ts";
|
||||
import { type Inputs, main } from "./main.ts";
|
||||
import { Inputs, main } from "./main.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
// Change to GITHUB_WORKSPACE if set (this is where actions/checkout puts the repo)
|
||||
// Change to cwd input or GITHUB_WORKSPACE (where actions/checkout puts the repo)
|
||||
// JavaScript actions run from the action's directory, not the checked out repo
|
||||
if (process.env.GITHUB_WORKSPACE && process.cwd() !== process.env.GITHUB_WORKSPACE) {
|
||||
log.debug(`Changing to GITHUB_WORKSPACE: ${process.env.GITHUB_WORKSPACE}`);
|
||||
process.chdir(process.env.GITHUB_WORKSPACE);
|
||||
log.debug(`New working directory: ${process.cwd()}`);
|
||||
const cwd = core.getInput("cwd") || process.env.GITHUB_WORKSPACE;
|
||||
if (cwd && process.cwd() !== cwd) {
|
||||
log.debug(`changing to working directory: ${cwd}`);
|
||||
process.chdir(cwd);
|
||||
}
|
||||
|
||||
try {
|
||||
const inputs: Required<Inputs> = {
|
||||
const inputs = Inputs.assert({
|
||||
prompt: core.getInput("prompt", { required: true }),
|
||||
...flatMorph(agents, (_, agent) =>
|
||||
agent.apiKeyNames.map((inputKey) => [inputKey, core.getInput(inputKey)])
|
||||
),
|
||||
};
|
||||
effort: core.getInput("effort") || "think",
|
||||
});
|
||||
|
||||
const result = await main(inputs);
|
||||
|
||||
|
||||
@@ -67,4 +67,12 @@ await build({
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
// Build the get-installation-token action
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./get-installation-token/entry.ts"],
|
||||
outfile: "./get-installation-token/entry",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
console.log("✅ Build completed successfully!");
|
||||
|
||||
+14
-4
@@ -20,22 +20,22 @@ export interface AgentManifest {
|
||||
export const agentsManifest = {
|
||||
claude: {
|
||||
displayName: "Claude Code",
|
||||
apiKeyNames: ["anthropic_api_key"],
|
||||
apiKeyNames: ["ANTHROPIC_API_KEY"],
|
||||
url: "https://claude.com/claude-code",
|
||||
},
|
||||
codex: {
|
||||
displayName: "Codex CLI",
|
||||
apiKeyNames: ["openai_api_key"],
|
||||
apiKeyNames: ["OPENAI_API_KEY"],
|
||||
url: "https://platform.openai.com/docs/guides/codex",
|
||||
},
|
||||
cursor: {
|
||||
displayName: "Cursor CLI",
|
||||
apiKeyNames: ["cursor_api_key"],
|
||||
apiKeyNames: ["CURSOR_API_KEY"],
|
||||
url: "https://cursor.com/",
|
||||
},
|
||||
gemini: {
|
||||
displayName: "Gemini CLI",
|
||||
apiKeyNames: ["google_api_key", "gemini_api_key"],
|
||||
apiKeyNames: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||
url: "https://ai.google.dev/gemini-api/docs",
|
||||
},
|
||||
opencode: {
|
||||
@@ -51,6 +51,10 @@ export const AgentName = type.enumerated(...Object.keys(agentsManifest));
|
||||
|
||||
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
|
||||
|
||||
// effort level type - controls model selection and thinking level
|
||||
export const Effort = type.enumerated("nothink", "think", "max");
|
||||
export type Effort = typeof Effort.infer;
|
||||
|
||||
// base interface for common payload event fields
|
||||
interface BasePayloadEvent {
|
||||
issue_number?: number;
|
||||
@@ -279,6 +283,12 @@ export interface Payload extends DispatchOptions {
|
||||
*/
|
||||
modes: readonly Mode[];
|
||||
|
||||
/**
|
||||
* Effort level for model selection (nothink, think, max)
|
||||
* Defaults to "think" if not specified
|
||||
*/
|
||||
readonly effort?: Effort;
|
||||
|
||||
/**
|
||||
* Optional IDs of the issue, PR, or comment that the agent is working on
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Effort, Payload } from "../external.ts";
|
||||
|
||||
/**
|
||||
* Test fixture for Cursor effort levels.
|
||||
* Runs all three effort levels in sequence.
|
||||
*
|
||||
* Run with:
|
||||
* AGENT_OVERRIDE=cursor pnpm play cursor-effort.ts
|
||||
*
|
||||
* Effort levels:
|
||||
* - "nothink": auto (default model)
|
||||
* - "think": auto (default model)
|
||||
* - "max": opus-4.5-thinking
|
||||
*
|
||||
* Note: If project has .cursor/cli.json with "model" specified,
|
||||
* that takes precedence over effort-based model selection.
|
||||
*/
|
||||
|
||||
const efforts: Effort[] = ["nothink", "think", "max"];
|
||||
|
||||
export default efforts.map((effort) => ({
|
||||
"~pullfrog": true,
|
||||
agent: "cursor",
|
||||
prompt: "What is 2 + 2? Reply with just the number.",
|
||||
event: {
|
||||
trigger: "workflow_dispatch",
|
||||
},
|
||||
modes: [],
|
||||
effort,
|
||||
})) satisfies Payload[];
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Effort, Payload } from "../external.ts";
|
||||
|
||||
/**
|
||||
* Test fixture for Gemini effort levels.
|
||||
* Runs all three effort levels in sequence.
|
||||
*
|
||||
* Run with:
|
||||
* AGENT_OVERRIDE=gemini pnpm play gemini-effort.ts
|
||||
*
|
||||
* Effort levels:
|
||||
* - "nothink": gemini-2.5-flash + LOW thinking
|
||||
* - "think": gemini-2.5-flash + HIGH thinking
|
||||
* - "max": gemini-2.5-pro + HIGH thinking
|
||||
*/
|
||||
|
||||
const efforts: Effort[] = ["nothink", "think", "max"];
|
||||
|
||||
export default efforts.map((effort) => ({
|
||||
"~pullfrog": true,
|
||||
agent: "gemini",
|
||||
prompt: "What is 2 + 2? Reply with just the number.",
|
||||
event: {
|
||||
trigger: "workflow_dispatch",
|
||||
},
|
||||
modes: [],
|
||||
effort,
|
||||
})) satisfies Payload[];
|
||||
@@ -0,0 +1,21 @@
|
||||
name: "Get Installation Token"
|
||||
description: "Get a GitHub App installation token for the current repository"
|
||||
author: "Pullfrog"
|
||||
|
||||
inputs:
|
||||
repos:
|
||||
description: "Comma-separated list of additional repo names to grant access to (e.g., 'repo1,repo2'). Current repo is always included."
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
token:
|
||||
description: "GitHub App installation token"
|
||||
|
||||
runs:
|
||||
using: "node24"
|
||||
main: "entry"
|
||||
post: "entry"
|
||||
|
||||
branding:
|
||||
icon: "key"
|
||||
color: "green"
|
||||
Executable
+26030
File diff suppressed because one or more lines are too long
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* entry point for get-installation-token action.
|
||||
* handles both main and post execution using the isPost state pattern.
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { acquireInstallationToken, revokeInstallationToken } from "./token.ts";
|
||||
|
||||
const STATE_TOKEN = "token";
|
||||
const STATE_IS_POST = "isPost";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
core.saveState(STATE_IS_POST, "true");
|
||||
|
||||
const reposInput = core.getInput("repos");
|
||||
const additionalRepos = reposInput
|
||||
? reposInput
|
||||
.split(",")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const token = await acquireInstallationToken({ repos: additionalRepos });
|
||||
|
||||
// mask the token in logs
|
||||
core.setSecret(token);
|
||||
|
||||
// save token to state for post cleanup
|
||||
core.saveState(STATE_TOKEN, token);
|
||||
|
||||
// set as output
|
||||
core.setOutput("token", token);
|
||||
|
||||
const scope = additionalRepos.length
|
||||
? `current repo + ${additionalRepos.join(", ")}`
|
||||
: "current repo only";
|
||||
core.info(`» installation token acquired (${scope})`);
|
||||
}
|
||||
|
||||
async function post(): Promise<void> {
|
||||
const token = core.getState(STATE_TOKEN);
|
||||
|
||||
if (!token) {
|
||||
core.debug("no token found in state, skipping revocation");
|
||||
return;
|
||||
}
|
||||
|
||||
await revokeInstallationToken(token);
|
||||
core.info("» installation token revoked");
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
const isPost = core.getState(STATE_IS_POST) === "true";
|
||||
|
||||
if (isPost) {
|
||||
await post();
|
||||
} else {
|
||||
await main();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(message);
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* token acquisition and revocation for get-installation-token action.
|
||||
* reuses the existing github.ts utilities.
|
||||
*/
|
||||
|
||||
import { acquireNewToken, revokeGitHubInstallationToken } from "../utils/github.ts";
|
||||
|
||||
export async function acquireInstallationToken(opts?: { repos?: string[] }): Promise<string> {
|
||||
return acquireNewToken(opts);
|
||||
}
|
||||
|
||||
export async function revokeInstallationToken(token: string): Promise<void> {
|
||||
return revokeGitHubInstallationToken(token);
|
||||
}
|
||||
@@ -7,8 +7,7 @@ import { encode as toonEncode } from "@toon-format/toon";
|
||||
import { type } from "arktype";
|
||||
import { type Agent, agents } from "./agents/index.ts";
|
||||
import type { AgentResult } from "./agents/shared.ts";
|
||||
import type { AgentName, Payload } from "./external.ts";
|
||||
import { agentsManifest } from "./external.ts";
|
||||
import { type AgentName, agentsManifest, Effort, type Payload } from "./external.ts";
|
||||
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
||||
import { createMcpConfigs } from "./mcp/config.ts";
|
||||
import { startMcpHttpServer } from "./mcp/server.ts";
|
||||
@@ -18,29 +17,13 @@ import type { PrepResult } from "./prep/index.ts";
|
||||
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { reportErrorToComment } from "./utils/errorReport.ts";
|
||||
import {
|
||||
createOctokit,
|
||||
parseRepoContext,
|
||||
setupGitHubInstallationToken,
|
||||
} from "./utils/github.ts";
|
||||
import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
|
||||
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
|
||||
import { Timer } from "./utils/timer.ts";
|
||||
|
||||
// runtime validation using agents (needed for ArkType)
|
||||
// Note: The AgentName type is defined in external.ts, this is the runtime validator
|
||||
|
||||
export const AgentInputKey = type.enumerated(
|
||||
...Object.values(agents).flatMap((agent) => agent.apiKeyNames)
|
||||
);
|
||||
export type AgentInputKey = typeof AgentInputKey.infer;
|
||||
|
||||
const keyInputDefs = flatMorph(agents, (_, agent) =>
|
||||
agent.apiKeyNames.map((inputKey) => [inputKey, "string | undefined?"] as const)
|
||||
);
|
||||
|
||||
export const Inputs = type({
|
||||
prompt: "string",
|
||||
...keyInputDefs,
|
||||
"effort?": Effort,
|
||||
});
|
||||
|
||||
export type Inputs = typeof Inputs.infer;
|
||||
@@ -84,7 +67,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
|
||||
// phase 3: resolve agent (needs repo settings)
|
||||
const agent = resolveAgent({
|
||||
inputs,
|
||||
payload,
|
||||
repoSettings: githubSetup.repoSettings,
|
||||
});
|
||||
@@ -93,7 +75,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
// phase 4: validate API key (sync, needs agent) - fail fast before long-running operations
|
||||
const apiKeySetup = validateApiKey({
|
||||
agent,
|
||||
inputs,
|
||||
owner: githubSetup.owner,
|
||||
name: githubSetup.name,
|
||||
});
|
||||
@@ -225,24 +206,19 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents that have matching API keys in the inputs
|
||||
* Check if an agent has API keys available (from process.env)
|
||||
*/
|
||||
/**
|
||||
* Check if an agent has API keys available (inputs or process.env for opencode)
|
||||
*/
|
||||
function agentHasApiKeys(agent: Agent, inputs: Inputs): boolean {
|
||||
function agentHasApiKeys(agent: Agent): boolean {
|
||||
if (agent.name === "opencode") {
|
||||
// check inputs first, then process.env
|
||||
const hasInputKey = Object.keys(inputs).some((key) => key.includes("api_key"));
|
||||
if (hasInputKey) return true;
|
||||
// opencode accepts any API_KEY from environment
|
||||
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
|
||||
}
|
||||
const inputsRecord = inputs as Record<string, string | undefined>;
|
||||
return agent.apiKeyNames.some((inputKey) => inputsRecord[inputKey]);
|
||||
// check if any of the agent's expected keys are in environment
|
||||
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
|
||||
}
|
||||
|
||||
function getAvailableAgents(inputs: Inputs): Agent[] {
|
||||
return Object.values(agents).filter((agent) => agentHasApiKeys(agent, inputs));
|
||||
function getAvailableAgents(): Agent[] {
|
||||
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,7 +251,7 @@ function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: st
|
||||
} else {
|
||||
const inputKeys =
|
||||
params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames();
|
||||
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
|
||||
const secretNames = inputKeys.map((key) => `\`${key}\``);
|
||||
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
||||
}
|
||||
|
||||
@@ -361,16 +337,16 @@ async function initializeGitHub(token: string): Promise<GitHubSetup> {
|
||||
}
|
||||
|
||||
function resolveAgent({
|
||||
inputs,
|
||||
payload,
|
||||
repoSettings,
|
||||
}: {
|
||||
inputs: Inputs;
|
||||
payload: Payload;
|
||||
repoSettings: RepoSettings;
|
||||
}): Agent {
|
||||
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
||||
log.debug(`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`);
|
||||
log.debug(
|
||||
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`
|
||||
);
|
||||
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
||||
|
||||
if (configuredAgentName) {
|
||||
@@ -388,13 +364,13 @@ function resolveAgent({
|
||||
}
|
||||
|
||||
// for repo-level defaults, check if agent has matching keys before selecting
|
||||
if (agentHasApiKeys(agent, inputs)) {
|
||||
if (agentHasApiKeys(agent)) {
|
||||
log.info(`Selected configured agent: ${agent.name}`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
// fall through to auto-selection
|
||||
const availableAgents = getAvailableAgents(inputs);
|
||||
const availableAgents = getAvailableAgents();
|
||||
log.warning(
|
||||
`Repo default agent ${agent.name} has no matching API keys. Available: ${
|
||||
availableAgents.map((a) => a.name).join(", ") || "none"
|
||||
@@ -402,7 +378,7 @@ function resolveAgent({
|
||||
);
|
||||
}
|
||||
|
||||
const availableAgents = getAvailableAgents(inputs);
|
||||
const availableAgents = getAvailableAgents();
|
||||
if (availableAgents.length === 0) {
|
||||
throw new Error("no agents available - missing API keys");
|
||||
}
|
||||
@@ -425,8 +401,13 @@ function parsePayload(inputs: Inputs): Payload {
|
||||
if (!("~pullfrog" in parsedPrompt)) {
|
||||
throw new Error();
|
||||
}
|
||||
return parsedPrompt as Payload;
|
||||
// internal invocation: use effort from payload, fallback to input, default to "think"
|
||||
return {
|
||||
...parsedPrompt,
|
||||
effort: parsedPrompt.effort ?? inputs.effort ?? "think",
|
||||
} as Payload;
|
||||
} catch {
|
||||
// external invocation: use effort from input
|
||||
return {
|
||||
"~pullfrog": true,
|
||||
agent: null,
|
||||
@@ -435,6 +416,7 @@ function parsePayload(inputs: Inputs): Payload {
|
||||
trigger: "unknown",
|
||||
},
|
||||
modes,
|
||||
effort: inputs.effort ?? "think",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -447,22 +429,22 @@ async function installAgentCli(params: { agent: Agent; token: string }): Promise
|
||||
return params.agent.install();
|
||||
}
|
||||
|
||||
function collectApiKeys(agent: Agent, inputs: Inputs): Record<string, string> {
|
||||
function collectApiKeys(agent: Agent): Record<string, string> {
|
||||
const apiKeys: Record<string, string> = {};
|
||||
const inputsRecord = inputs as Record<string, string | undefined>;
|
||||
|
||||
for (const inputKey of agent.apiKeyNames) {
|
||||
const value = inputsRecord[inputKey];
|
||||
// read API keys from environment variables
|
||||
for (const envKey of agent.apiKeyNames) {
|
||||
const value = process.env[envKey];
|
||||
if (value) {
|
||||
apiKeys[inputKey] = value;
|
||||
apiKeys[envKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// for OpenCode: also check process.env for any API_KEY variables
|
||||
// for OpenCode: check process.env for any API_KEY variables
|
||||
if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) {
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
||||
apiKeys[key.toLowerCase()] = value;
|
||||
apiKeys[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,13 +452,8 @@ function collectApiKeys(agent: Agent, inputs: Inputs): Record<string, string> {
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
function validateApiKey(params: {
|
||||
agent: Agent;
|
||||
inputs: Inputs;
|
||||
owner: string;
|
||||
name: string;
|
||||
}): ApiKeySetup {
|
||||
const apiKeys = collectApiKeys(params.agent, params.inputs);
|
||||
function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
|
||||
const apiKeys = collectApiKeys(params.agent);
|
||||
|
||||
if (Object.keys(apiKeys).length === 0) {
|
||||
return {
|
||||
@@ -497,7 +474,8 @@ function validateApiKey(params: {
|
||||
}
|
||||
|
||||
async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
||||
log.info(`Running ${ctx.agent.name}...`);
|
||||
const effort = ctx.payload.effort ?? "think";
|
||||
log.info(`Running ${ctx.agent.name} with effort=${effort}...`);
|
||||
// strip context from event
|
||||
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
||||
// format: prompt + two newlines + TOON encoded event
|
||||
@@ -516,6 +494,7 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
||||
defaultBranch: ctx.repo.default_branch,
|
||||
isPublic: !ctx.repo.private,
|
||||
},
|
||||
effort,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -1,10 +1,16 @@
|
||||
import * as core from "@actions/core";
|
||||
import { type } from "arktype";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { agentsManifest } from "../external.ts";
|
||||
import type { ToolContext } from "../main.ts";
|
||||
import { fetchWorkflowRunInfo } from "../utils/api.ts";
|
||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { createOctokit, getGitHubInstallationToken, parseRepoContext, type OctokitWithPlugins } from "../utils/github.ts";
|
||||
import {
|
||||
createOctokit,
|
||||
getGitHubInstallationToken,
|
||||
type OctokitWithPlugins,
|
||||
parseRepoContext,
|
||||
} from "../utils/github.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
/**
|
||||
@@ -14,6 +20,8 @@ import { execute, tool } from "./shared.ts";
|
||||
*/
|
||||
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||
|
||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
|
||||
interface BuildCommentFooterParams {
|
||||
payload: Payload;
|
||||
octokit?: OctokitWithPlugins | undefined;
|
||||
@@ -79,7 +87,11 @@ function buildImplementPlanLink(
|
||||
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
|
||||
}
|
||||
|
||||
async function addFooter(body: string, payload: Payload, octokit?: OctokitWithPlugins): Promise<string> {
|
||||
async function addFooter(
|
||||
body: string,
|
||||
payload: Payload,
|
||||
octokit?: OctokitWithPlugins
|
||||
): Promise<string> {
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = await buildCommentFooter({ payload, octokit });
|
||||
return `${bodyWithoutFooter}${footer}`;
|
||||
@@ -188,6 +200,10 @@ export const ReportProgress = type({
|
||||
body: type.string.describe("the progress update content to share"),
|
||||
});
|
||||
|
||||
|
||||
/** Updates job summary with the given text if running in GitHub Actions. */
|
||||
const updateSummary = (text: string) => isGitHubActions && core.summary.addRaw(text).write({ overwrite: true });
|
||||
|
||||
/**
|
||||
* Standalone function to report progress to GitHub comment.
|
||||
* Can be called directly without going through the MCP tool interface.
|
||||
@@ -234,6 +250,8 @@ export async function reportProgress(
|
||||
|
||||
progressCommentWasUpdated = true;
|
||||
|
||||
await updateSummary(bodyWithFooter);
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
@@ -281,6 +299,8 @@ export async function reportProgress(
|
||||
body: bodyWithPlanLink,
|
||||
});
|
||||
|
||||
await updateSummary(bodyWithPlanLink);
|
||||
|
||||
return {
|
||||
commentId: updateResult.data.id,
|
||||
url: updateResult.data.html_url,
|
||||
@@ -289,6 +309,8 @@ export async function reportProgress(
|
||||
};
|
||||
}
|
||||
|
||||
await updateSummary(initialBody);
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
@@ -481,6 +503,6 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
body: result.data.body,
|
||||
in_reply_to_id: result.data.in_reply_to_id,
|
||||
};
|
||||
}),
|
||||
}, "reply_to_review_comment"),
|
||||
});
|
||||
}
|
||||
|
||||
+86
-86
@@ -93,95 +93,95 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||
"Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.",
|
||||
parameters: GetReviewComments,
|
||||
execute: execute(async ({ pull_number, review_id }) => {
|
||||
// fetch all review threads using graphql
|
||||
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pullNumber: pull_number,
|
||||
});
|
||||
// fetch all review threads using graphql
|
||||
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pullNumber: pull_number,
|
||||
});
|
||||
|
||||
const pullRequest = response.repository?.pullRequest;
|
||||
if (!pullRequest) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
comments: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const threadNodes = pullRequest.reviewThreads?.nodes;
|
||||
if (!threadNodes) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
comments: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const allComments: {
|
||||
id: number;
|
||||
body: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
side: "LEFT" | "RIGHT";
|
||||
start_line: number | null;
|
||||
start_side: "LEFT" | "RIGHT" | null;
|
||||
user: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
in_reply_to_id: number | null;
|
||||
pull_request_review_id: number | null;
|
||||
}[] = [];
|
||||
|
||||
// iterate through all threads (filter out nulls)
|
||||
for (const thread of threadNodes) {
|
||||
if (!thread?.comments?.nodes) continue;
|
||||
|
||||
// filter out null comments
|
||||
const threadComments = thread.comments.nodes.filter(
|
||||
(c): c is GraphQLReviewComment => c !== null
|
||||
);
|
||||
if (threadComments.length === 0) continue;
|
||||
|
||||
// find the root comment (the one with replyTo == null) to determine thread ownership
|
||||
const rootComment = threadComments.find((c) => c.replyTo === null);
|
||||
if (!rootComment) continue;
|
||||
|
||||
// check if this thread belongs to the target review using the root comment
|
||||
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
|
||||
if (!threadBelongsToReview) continue;
|
||||
|
||||
// include all comments from this thread (original + replies)
|
||||
// side info comes from thread level, not comment level
|
||||
for (const comment of threadComments) {
|
||||
allComments.push({
|
||||
id: comment.databaseId,
|
||||
body: comment.body,
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
start_line: comment.startLine,
|
||||
side: thread.diffSide,
|
||||
start_side: thread.startDiffSide,
|
||||
user: comment.author?.login ?? null,
|
||||
created_at: comment.createdAt,
|
||||
updated_at: comment.updatedAt,
|
||||
html_url: comment.url,
|
||||
in_reply_to_id: comment.replyTo?.databaseId ?? null,
|
||||
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
|
||||
});
|
||||
const pullRequest = response.repository?.pullRequest;
|
||||
if (!pullRequest) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
comments: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
comments: allComments,
|
||||
count: allComments.length,
|
||||
};
|
||||
}),
|
||||
const threadNodes = pullRequest.reviewThreads?.nodes;
|
||||
if (!threadNodes) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
comments: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const allComments: {
|
||||
id: number;
|
||||
body: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
side: "LEFT" | "RIGHT";
|
||||
start_line: number | null;
|
||||
start_side: "LEFT" | "RIGHT" | null;
|
||||
user: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
in_reply_to_id: number | null;
|
||||
pull_request_review_id: number | null;
|
||||
}[] = [];
|
||||
|
||||
// iterate through all threads (filter out nulls)
|
||||
for (const thread of threadNodes) {
|
||||
if (!thread?.comments?.nodes) continue;
|
||||
|
||||
// filter out null comments
|
||||
const threadComments = thread.comments.nodes.filter(
|
||||
(c): c is GraphQLReviewComment => c !== null
|
||||
);
|
||||
if (threadComments.length === 0) continue;
|
||||
|
||||
// find the root comment (the one with replyTo == null) to determine thread ownership
|
||||
const rootComment = threadComments.find((c) => c.replyTo === null);
|
||||
if (!rootComment) continue;
|
||||
|
||||
// check if this thread belongs to the target review using the root comment
|
||||
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
|
||||
if (!threadBelongsToReview) continue;
|
||||
|
||||
// include all comments from this thread (original + replies)
|
||||
// side info comes from thread level, not comment level
|
||||
for (const comment of threadComments) {
|
||||
allComments.push({
|
||||
id: comment.databaseId,
|
||||
body: comment.body,
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
start_line: comment.startLine,
|
||||
side: thread.diffSide,
|
||||
start_side: thread.startDiffSide,
|
||||
user: comment.author?.login ?? null,
|
||||
created_at: comment.createdAt,
|
||||
updated_at: comment.updatedAt,
|
||||
html_url: comment.url,
|
||||
in_reply_to_id: comment.replyTo?.databaseId ?? null,
|
||||
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
comments: allComments,
|
||||
count: allComments.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||
import { encode as toonEncode } from "@toon-format/toon";
|
||||
import type { FastMCP, Tool } from "fastmcp";
|
||||
import type { ToolContext } from "../main.ts";
|
||||
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||
|
||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
||||
|
||||
@@ -36,13 +37,22 @@ export const handleToolError = (error: unknown): ToolResult => {
|
||||
/**
|
||||
* Helper to wrap a tool execute function with error handling.
|
||||
* Captures ctx in closure so tools don't need to handle try/catch.
|
||||
* @param fn - the function to execute
|
||||
* @param toolName - optional tool name for error logging
|
||||
*/
|
||||
export const execute = <T>(fn: (params: T) => Promise<Record<string, any> | string>) => {
|
||||
export const execute = <T>(
|
||||
fn: (params: T) => Promise<Record<string, any> | string>,
|
||||
toolName?: string
|
||||
) => {
|
||||
return async (params: T): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = await fn(params);
|
||||
return handleToolSuccess(result);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const prefix = toolName ? `[${toolName}]` : "tool";
|
||||
log.error(`${prefix} error: ${errorMessage}`);
|
||||
log.debug(`${prefix} params: ${formatJsonValue(params)}`);
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.156",
|
||||
"version": "0.0.157",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
@@ -13,7 +13,7 @@
|
||||
"main.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node esbuild.config.js",
|
||||
"play": "node play.ts",
|
||||
@@ -47,7 +47,8 @@
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -3,10 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fromHere } from "@ark/fs";
|
||||
import { flatMorph } from "@ark/util";
|
||||
import arg from "arg";
|
||||
import { config } from "dotenv";
|
||||
import { agents } from "./agents/index.ts";
|
||||
import type { AgentResult } from "./agents/shared.ts";
|
||||
import { type Inputs, main } from "./main.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
@@ -25,26 +23,9 @@ export async function run(prompt: string): Promise<AgentResult> {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
// check if prompt is a pullfrog payload and extract agent
|
||||
// note: agent from payload will be used by determineAgent with highest precedence
|
||||
// we don't need to extract it here since main() will parse the payload
|
||||
const inputs = {
|
||||
const inputs: Inputs = {
|
||||
prompt,
|
||||
...flatMorph(agents, (_, agent) => {
|
||||
// for OpenCode, scan all API_KEY environment variables
|
||||
if (agent.name === "opencode") {
|
||||
const opencodeKeys: Array<[string, string | undefined]> = [];
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
||||
opencodeKeys.push([key.toLowerCase(), value]);
|
||||
}
|
||||
}
|
||||
return opencodeKeys;
|
||||
}
|
||||
// for other agents, use apiKeyNames
|
||||
return agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]);
|
||||
}),
|
||||
} as Required<Inputs>;
|
||||
};
|
||||
|
||||
const result = await main(inputs);
|
||||
|
||||
@@ -205,6 +186,30 @@ Examples:
|
||||
|
||||
if (typeof module.default === "string") {
|
||||
prompt = module.default;
|
||||
} else if (Array.isArray(module.default)) {
|
||||
// Array of Payloads - run each in sequence
|
||||
const payloads = module.default;
|
||||
log.info(`Running ${payloads.length} payloads in sequence...`);
|
||||
|
||||
let allSuccess = true;
|
||||
for (let i = 0; i < payloads.length; i++) {
|
||||
const payload = payloads[i];
|
||||
const label = payload.effort
|
||||
? `[${i + 1}/${payloads.length}] effort=${payload.effort}`
|
||||
: `[${i + 1}/${payloads.length}]`;
|
||||
log.info(`\n${"=".repeat(60)}`);
|
||||
log.info(`${label}`);
|
||||
log.info(`${"=".repeat(60)}\n`);
|
||||
|
||||
const payloadPrompt = JSON.stringify(payload, null, 2);
|
||||
const result = await run(payloadPrompt);
|
||||
if (!result.success) {
|
||||
allSuccess = false;
|
||||
log.error(`Payload ${i + 1} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(allSuccess ? 0 : 1);
|
||||
} else if (typeof module.default === "object") {
|
||||
// Payload objects (with ~pullfrog) should be stringified
|
||||
prompt = JSON.stringify(module.default, null, 2);
|
||||
|
||||
Generated
+874
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["vitest/globals"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
+4
-1
@@ -178,7 +178,10 @@ async function summaryTable(
|
||||
if (title) {
|
||||
core.info(`\n${title}`);
|
||||
}
|
||||
const tableText = formattedRows.map((row) => row.map((cell) => cell.data).join(" | ")).join("\n");
|
||||
const tableData = formattedRows.map((row) => row.map((cell) => cell.data));
|
||||
const tableText = isGitHubActions
|
||||
? tableData.map((row) => row.join(" | ")).join("\n")
|
||||
: table(tableData);
|
||||
core.info(`\n${tableText}\n`);
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -54,12 +54,17 @@ function isOIDCAvailable(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function acquireTokenViaOIDC(): Promise<string> {
|
||||
async function acquireTokenViaOIDC(opts?: { repos?: string[] }): Promise<string> {
|
||||
log.info("» generating OIDC token...");
|
||||
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.repos?.length) {
|
||||
params.set("repos", opts.repos.join(","));
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : "";
|
||||
|
||||
log.info("» exchanging OIDC token for installation token...");
|
||||
|
||||
@@ -68,7 +73,7 @@ async function acquireTokenViaOIDC(): Promise<string> {
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
|
||||
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token${queryString}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
@@ -84,7 +89,11 @@ async function acquireTokenViaOIDC(): Promise<string> {
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||
log.info(`» installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||
const owner = tokenData.repository?.split("/")[0];
|
||||
const repoList = opts?.repos?.length
|
||||
? [tokenData.repository, ...opts.repos.map((r) => `${owner}/${r}`)].join(", ")
|
||||
: tokenData.repository;
|
||||
log.info(`» installation token obtained for ${repoList}`);
|
||||
|
||||
return tokenData.token;
|
||||
} catch (error) {
|
||||
@@ -238,9 +247,9 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function acquireNewToken(): Promise<string> {
|
||||
export async function acquireNewToken(opts?: { repos?: string[] }): Promise<string> {
|
||||
if (isOIDCAvailable()) {
|
||||
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
||||
return await retry(() => acquireTokenViaOIDC(opts), { label: "token exchange" });
|
||||
} else {
|
||||
return await acquireTokenViaGitHubApp();
|
||||
}
|
||||
@@ -277,7 +286,7 @@ export function getGitHubInstallationToken(): string {
|
||||
return githubInstallationToken;
|
||||
}
|
||||
|
||||
async function revokeGitHubInstallationToken(token: string): Promise<void> {
|
||||
export async function revokeGitHubInstallationToken(token: string): Promise<void> {
|
||||
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Timer } from './timer.ts';
|
||||
import * as cli from './cli.ts';
|
||||
|
||||
describe('Timer', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(cli.log, 'debug');
|
||||
// Mock Date.now to have predictable timestamps
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with current timestamp', () => {
|
||||
const mockTime = 1000000;
|
||||
vi.setSystemTime(mockTime);
|
||||
|
||||
const timer = new Timer();
|
||||
timer.checkpoint('test');
|
||||
|
||||
expect(cli.log.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkpoint', () => {
|
||||
it('should log duration from initial timestamp on first checkpoint', () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
const timer = new Timer();
|
||||
|
||||
const checkpointTime = startTime + 100;
|
||||
vi.setSystemTime(checkpointTime);
|
||||
timer.checkpoint('first');
|
||||
|
||||
expect(cli.log.debug).toHaveBeenCalledWith('» first: 100ms');
|
||||
});
|
||||
|
||||
it('should log duration from last checkpoint on subsequent checkpoints', () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
const timer = new Timer();
|
||||
|
||||
// First checkpoint
|
||||
const firstCheckpointTime = startTime + 50;
|
||||
vi.setSystemTime(firstCheckpointTime);
|
||||
timer.checkpoint('first');
|
||||
|
||||
// Second checkpoint
|
||||
const secondCheckpointTime = firstCheckpointTime + 75;
|
||||
vi.setSystemTime(secondCheckpointTime);
|
||||
timer.checkpoint('second');
|
||||
|
||||
expect(cli.log.debug).toHaveBeenCalledTimes(2);
|
||||
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» first: 50ms');
|
||||
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» second: 75ms');
|
||||
});
|
||||
|
||||
it('should handle multiple checkpoints correctly', () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
const timer = new Timer();
|
||||
|
||||
// First checkpoint
|
||||
vi.setSystemTime(startTime + 10);
|
||||
timer.checkpoint('step1');
|
||||
|
||||
// Second checkpoint
|
||||
vi.setSystemTime(startTime + 25);
|
||||
timer.checkpoint('step2');
|
||||
|
||||
// Third checkpoint
|
||||
vi.setSystemTime(startTime + 45);
|
||||
timer.checkpoint('step3');
|
||||
|
||||
expect(cli.log.debug).toHaveBeenCalledTimes(3);
|
||||
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» step1: 10ms');
|
||||
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» step2: 15ms');
|
||||
expect(cli.log.debug).toHaveBeenNthCalledWith(3, '» step3: 20ms');
|
||||
});
|
||||
|
||||
it('should handle zero duration correctly', () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
const timer = new Timer();
|
||||
|
||||
// Checkpoint immediately
|
||||
timer.checkpoint('immediate');
|
||||
|
||||
expect(cli.log.debug).toHaveBeenCalledWith('» immediate: 0ms');
|
||||
});
|
||||
|
||||
it('should handle custom checkpoint names', () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
const timer = new Timer();
|
||||
|
||||
vi.setSystemTime(startTime + 200);
|
||||
timer.checkpoint('Custom Checkpoint Name');
|
||||
|
||||
expect(cli.log.debug).toHaveBeenCalledWith(
|
||||
'» Custom Checkpoint Name: 200ms'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
exclude: ['node_modules', '.temp'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user