Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2735b2916 | |||
| 9714d5fea6 | |||
| a57866a8cd | |||
| 9903072286 | |||
| edb7603587 | |||
| 45f837cedb | |||
| c6572f0987 | |||
| 89e93d3398 | |||
| c335032c37 | |||
| 2f3ae3e481 | |||
| 308781793f | |||
| 1765e04d77 | |||
| c5d201ce60 | |||
| c89f1b9537 | |||
| 7fe0233c24 | |||
| e10f756560 | |||
| 48108b137a | |||
| 074a860a95 | |||
| 0c03428488 | |||
| 5fa8c3603d | |||
| 78c22085bf | |||
| b55cda579d | |||
| 1d1d80c3f9 | |||
| 3a97ba04fc | |||
| fe7ce4af11 | |||
| 6260b23de7 | |||
| 9291ee5952 | |||
| d30532979a | |||
| c8b65327ee | |||
| 879d33403c | |||
| 2cc081c912 | |||
| b9a7a19ca1 | |||
| 7ee08d37a6 | |||
| ff913feb3c | |||
| 317ebd3431 | |||
| 2bd12b9553 | |||
| d99a852e24 | |||
| 6e289e9310 | |||
| a483711fee | |||
| 244c7d4d8d | |||
| 0504fc42ff | |||
| ad1f51d704 | |||
| 573c473dc1 | |||
| c200c7aff9 | |||
| 8a7db7bba2 | |||
| 3f996b4759 | |||
| 0a7a38a9a5 | |||
| 72a040aafa | |||
| cc59a16472 | |||
| 8db0c40487 | |||
| 7fb788a883 | |||
| 0cf88e1752 | |||
| dcb672b5be | |||
| 7103f5f991 | |||
| c518e8b6fd | |||
| 615a3bc8e1 | |||
| 17ad3bd0e7 | |||
| 25896559f0 |
@@ -23,8 +23,6 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -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
|
||||
@@ -45,3 +45,5 @@ examples
|
||||
# Temporary directory for cloned repos
|
||||
.temp/
|
||||
dist
|
||||
|
||||
.pnpm-store/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v24.3.0
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 pullfrog
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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:
|
||||
|
||||
+52
-24
@@ -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,35 +29,34 @@ 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));
|
||||
log.group("Full prompt", () => log.info(prompt));
|
||||
|
||||
// configure sandbox mode if enabled
|
||||
// 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.
|
||||
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||
const sandboxOptions: Options = payload.sandbox
|
||||
? {
|
||||
permissionMode: "default",
|
||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
||||
async canUseTool(toolName, input, _options) {
|
||||
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input,
|
||||
updatedPermissions: [],
|
||||
};
|
||||
|
||||
console.error("can i use this tool?", toolName);
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "You are not allowed to use this tool.",
|
||||
};
|
||||
return { behavior: "allow", updatedInput: input, updatedPermissions: [] };
|
||||
return { behavior: "deny", message: "tool not allowed in sandbox mode" };
|
||||
},
|
||||
}
|
||||
: {
|
||||
permissionMode: "bypassPermissions" as const,
|
||||
disallowedTools,
|
||||
};
|
||||
|
||||
if (payload.sandbox) {
|
||||
@@ -51,14 +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,
|
||||
pathToClaudeCodeExecutable: cliPath,
|
||||
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
||||
},
|
||||
options: queryOptions,
|
||||
});
|
||||
|
||||
// Stream the results
|
||||
@@ -146,16 +163,27 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
},
|
||||
result: async (data) => {
|
||||
if (data.subtype === "success") {
|
||||
const usage = data.usage;
|
||||
const inputTokens = usage?.input_tokens || 0;
|
||||
const cacheRead = usage?.cache_read_input_tokens || 0;
|
||||
const cacheWrite = usage?.cache_creation_input_tokens || 0;
|
||||
const outputTokens = usage?.output_tokens || 0;
|
||||
const totalInput = inputTokens + cacheRead + cacheWrite;
|
||||
|
||||
await log.summaryTable([
|
||||
[
|
||||
{ data: "Cost", header: true },
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
{ data: "Input", header: true },
|
||||
{ data: "Cache Read", header: true },
|
||||
{ data: "Cache Write", header: true },
|
||||
{ data: "Output", header: true },
|
||||
],
|
||||
[
|
||||
`$${data.total_cost_usd?.toFixed(4) || "0.0000"}`,
|
||||
String(data.usage?.input_tokens || 0),
|
||||
String(data.usage?.output_tokens || 0),
|
||||
String(totalInput),
|
||||
String(cacheRead),
|
||||
String(cacheWrite),
|
||||
String(outputTokens),
|
||||
],
|
||||
]);
|
||||
} else if (data.subtype === "error_max_turns") {
|
||||
|
||||
+110
-57
@@ -1,15 +1,77 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
|
||||
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-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,
|
||||
type ConfigureMcpServersParams,
|
||||
installFromNpmTarball,
|
||||
setupProcessAgentEnv,
|
||||
} from "./shared.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>;
|
||||
isPublicRepo: boolean;
|
||||
}
|
||||
|
||||
function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConfigParams): string {
|
||||
const codexDir = join(tempHome, ".codex");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
const configPath = join(codexDir, "config.toml");
|
||||
|
||||
// build MCP servers section
|
||||
const mcpServerSections: string[] = [];
|
||||
for (const [name, config] of Object.entries(mcpServers)) {
|
||||
if (config.type !== "http") continue;
|
||||
log.info(`» Adding MCP server '${name}' at ${config.url}`);
|
||||
mcpServerSections.push(`[mcp_servers.${name}]\nurl = "${config.url}"`);
|
||||
}
|
||||
|
||||
// SECURITY: for public repos, enforce env filtering via shell_environment_policy
|
||||
// this prevents vuln if user's ~/.codex/config.toml has ignore_default_excludes=true
|
||||
// for private repos, no filtering - agents use native shell with full env access
|
||||
const shellPolicy = isPublicRepo
|
||||
? `[shell_environment_policy]
|
||||
ignore_default_excludes = false`
|
||||
: "";
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`# written by pullfrog
|
||||
${shellPolicy}
|
||||
|
||||
${mcpServerSections.join("\n\n")}
|
||||
`.trim() + "\n"
|
||||
);
|
||||
|
||||
if (isPublicRepo) {
|
||||
log.info(`» Codex config written to ${configPath} (env filtering: enabled)`);
|
||||
} else {
|
||||
log.info(`» Codex config written to ${configPath} (private repo: no env filtering)`);
|
||||
}
|
||||
|
||||
return codexDir;
|
||||
}
|
||||
|
||||
export const codex = agent({
|
||||
name: "codex",
|
||||
@@ -20,18 +82,32 @@ export const codex = agent({
|
||||
executablePath: "bin/codex.js",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
|
||||
// create config directory for codex before setting HOME
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||
|
||||
// create config directory for codex before setting HOME
|
||||
const configDir = join(tempHome, ".config", "codex");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
const codexDir = writeCodexConfig({
|
||||
tempHome,
|
||||
mcpServers,
|
||||
isPublicRepo: repo.isPublic,
|
||||
});
|
||||
|
||||
setupProcessAgentEnv({
|
||||
OPENAI_API_KEY: apiKey,
|
||||
HOME: tempHome,
|
||||
CODEX_HOME: codexDir, // point Codex to our config directory
|
||||
});
|
||||
|
||||
configureCodexMcpServers({ mcpServers, cliPath });
|
||||
// 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 = {
|
||||
@@ -44,21 +120,28 @@ export const codex = agent({
|
||||
}
|
||||
|
||||
const codex = new Codex(codexOptions);
|
||||
// valid sandbox modes: read-only, workspace-write, danger-full-access
|
||||
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 }));
|
||||
@@ -186,33 +269,3 @@ const messageHandlers: {
|
||||
log.error(`Error: ${event.message}`);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure MCP servers for Codex using the CLI.
|
||||
* For HTTP-based servers, use: codex mcp add <name> --url <url>
|
||||
*/
|
||||
function configureCodexMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
if (serverConfig.type === "http") {
|
||||
// HTTP-based MCP server - use --url flag
|
||||
const addArgs = ["mcp", "add", serverName, "--url", serverConfig.url];
|
||||
|
||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (addResult.status !== 0) {
|
||||
throw new Error(
|
||||
`codex mcp add failed: ${addResult.stderr || addResult.stdout || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
log.info(`✓ MCP server '${serverName}' configured`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported MCP server type for Codex: ${(serverConfig as any).type || "unknown"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+73
-31
@@ -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,9 +100,35 @@ 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 });
|
||||
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
|
||||
@@ -167,20 +202,20 @@ export const cursor = agent({
|
||||
|
||||
try {
|
||||
const fullPrompt = addInstructions({ payload, repo });
|
||||
log.group("» Full prompt", () => log.info(fullPrompt));
|
||||
log.group("Full prompt", () => log.info(fullPrompt));
|
||||
|
||||
// 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");
|
||||
@@ -314,39 +349,46 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
||||
|
||||
/**
|
||||
* Configure Cursor CLI sandbox mode via cli-config.json.
|
||||
* When sandbox is enabled, denies all file writes and shell commands.
|
||||
* In print mode without --force, writes are blocked by default, but we add
|
||||
* explicit deny rules as defense in depth.
|
||||
*
|
||||
* See: https://cursor.com/docs/cli/reference/permissions
|
||||
* SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys.
|
||||
* We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
|
||||
* filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
|
||||
* For private repos, native Shell is allowed.
|
||||
*
|
||||
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
|
||||
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
|
||||
*/
|
||||
function configureCursorSandbox({ sandbox }: { sandbox: boolean }): void {
|
||||
function configureCursorSandbox({
|
||||
sandbox,
|
||||
isPublicRepo,
|
||||
}: {
|
||||
sandbox: boolean;
|
||||
isPublicRepo: boolean;
|
||||
}): void {
|
||||
const realHome = homedir();
|
||||
const cursorConfigDir = join(realHome, ".cursor");
|
||||
const cursorConfigDir = join(realHome, ".config", "cursor");
|
||||
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
||||
mkdirSync(cursorConfigDir, { recursive: true });
|
||||
|
||||
// deny native shell for public repos to prevent secret leakage
|
||||
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||
|
||||
const config = sandbox
|
||||
? {
|
||||
// sandbox mode: deny all writes and shell commands
|
||||
permissions: {
|
||||
allow: [
|
||||
"Read(**)", // allow reading all files
|
||||
],
|
||||
deny: [
|
||||
"Write(**)", // deny all file writes
|
||||
"Shell(**)", // deny all shell commands
|
||||
],
|
||||
allow: ["Read(**)"],
|
||||
deny: ["Write(**)", ...denyShell],
|
||||
},
|
||||
}
|
||||
: {
|
||||
// normal mode: allow everything
|
||||
permissions: {
|
||||
allow: ["Read(**)", "Write(**)", "Shell(**)"],
|
||||
deny: [],
|
||||
allow: ["Read(**)", "Write(**)"],
|
||||
deny: denyShell,
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
log.info(`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`);
|
||||
log.info(
|
||||
`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
|
||||
);
|
||||
}
|
||||
|
||||
+124
-47
@@ -1,4 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
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";
|
||||
@@ -9,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";
|
||||
@@ -154,44 +166,57 @@ export const gemini = agent({
|
||||
...(githubInstallationToken && { githubInstallationToken }),
|
||||
});
|
||||
},
|
||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
||||
configureGeminiMcpServers({ mcpServers, cliPath });
|
||||
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");
|
||||
}
|
||||
|
||||
const sessionPrompt = addInstructions({ payload, repo });
|
||||
log.group("» Full prompt", () => log.info(sessionPrompt));
|
||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
||||
|
||||
// configure sandbox mode if enabled
|
||||
// --allowed-tools restricts which tools are available (removes others from registry entirely)
|
||||
// in sandbox mode: only read-only tools available (no write_file, run_shell_command, web_fetch)
|
||||
const args = payload.sandbox
|
||||
? [
|
||||
"--allowed-tools",
|
||||
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
||||
"--allowed-mcp-server-names",
|
||||
"gh_pullfrog",
|
||||
"--output-format=stream-json",
|
||||
"-p",
|
||||
sessionPrompt,
|
||||
]
|
||||
: ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
||||
// build CLI args based on sandbox mode
|
||||
// for public repos, native shell is disabled via excludeTools in settings.json
|
||||
let args: string[];
|
||||
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",
|
||||
"gh_pullfrog",
|
||||
"--output-format=stream-json",
|
||||
"-p",
|
||||
sessionPrompt,
|
||||
];
|
||||
} else {
|
||||
// normal mode: --yolo for auto-approval
|
||||
// for public repos, shell is excluded via settings.json excludeTools
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.sandbox) {
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
}
|
||||
|
||||
let finalOutput = "";
|
||||
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
|
||||
let stdoutBuffer = "";
|
||||
|
||||
try {
|
||||
const result = await spawn({
|
||||
cmd: "node",
|
||||
args: [cliPath, ...args],
|
||||
env: createAgentEnv({
|
||||
GEMINI_API_KEY: apiKey,
|
||||
}),
|
||||
env: createAgentEnv({ GEMINI_API_KEY: apiKey }),
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
finalOutput += text;
|
||||
@@ -264,36 +289,88 @@ export const gemini = agent({
|
||||
},
|
||||
});
|
||||
|
||||
type ConfigureGeminiParams = {
|
||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||
isPublicRepo: boolean;
|
||||
thinkingLevel: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure MCP servers for Gemini using the CLI.
|
||||
* Gemini CLI syntax: gemini mcp add <name> <commandOrUrl> [args...] --transport <type>
|
||||
* For HTTP-based servers, use: gemini mcp add <name> <url> --transport http
|
||||
* 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
|
||||
*
|
||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||
*/
|
||||
function configureGeminiMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
||||
function configureGeminiSettings({
|
||||
mcpServers,
|
||||
isPublicRepo,
|
||||
thinkingLevel,
|
||||
}: ConfigureGeminiParams): void {
|
||||
const realHome = homedir();
|
||||
const geminiConfigDir = join(realHome, ".gemini");
|
||||
const settingsPath = join(geminiConfigDir, "settings.json");
|
||||
mkdirSync(geminiConfigDir, { recursive: true });
|
||||
|
||||
// read existing settings if present
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
try {
|
||||
const content = readFileSync(settingsPath, "utf-8");
|
||||
existingSettings = JSON.parse(content);
|
||||
} catch {
|
||||
// file doesn't exist or is invalid - start fresh
|
||||
}
|
||||
|
||||
// convert to Gemini's expected format (httpUrl for HTTP transport, no type field)
|
||||
type GeminiMcpServerConfig = {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
httpUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
};
|
||||
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {};
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
if (serverConfig.type === "http") {
|
||||
// HTTP-based MCP server - use URL with --transport http flag
|
||||
const addArgs = ["mcp", "add", serverName, serverConfig.url, "--transport", "http"];
|
||||
|
||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
});
|
||||
|
||||
if (addResult.status !== 0) {
|
||||
throw new Error(
|
||||
`gemini mcp add failed: ${addResult.stderr || addResult.stdout || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
log.info(`✓ MCP server '${serverName}' configured`);
|
||||
} else {
|
||||
if (serverConfig.type !== "http") {
|
||||
throw new Error(
|
||||
`Unsupported MCP server type for Gemini: ${(serverConfig as any).type || "unknown"}`
|
||||
`Unsupported MCP server type for Gemini: ${(serverConfig as { type?: string }).type || "unknown"}`
|
||||
);
|
||||
}
|
||||
geminiMcpServers[serverName] = {
|
||||
httpUrl: serverConfig.url,
|
||||
trust: true, // trust our own MCP server to avoid confirmation prompts
|
||||
};
|
||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
||||
}
|
||||
|
||||
// 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(`» Gemini settings written to ${settingsPath}`);
|
||||
}
|
||||
|
||||
+12
-34
@@ -8,6 +8,7 @@ interface RepoInfo {
|
||||
owner: string;
|
||||
name: string;
|
||||
defaultBranch: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,9 @@ interface AddInstructionsParams {
|
||||
}
|
||||
|
||||
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
||||
// for public repos, always use MCP bash for security (filters secrets)
|
||||
// for private repos, agents can use their native bash
|
||||
const useNativeBash = !repo.isPublic;
|
||||
let encodedEvent = "";
|
||||
|
||||
const eventKeys = Object.keys(payload.event);
|
||||
@@ -98,40 +102,9 @@ In case of conflict between instructions, follow this precedence (highest to low
|
||||
4. Repository-specific instructions (AGENTS.md, CLAUDE.md, etc.)
|
||||
5. User prompt
|
||||
|
||||
## SECURITY
|
||||
## Security
|
||||
|
||||
CRITICAL SECURITY RULES - NEVER VIOLATE UNDER ANY CIRCUMSTANCES:
|
||||
|
||||
### Rule 1: Never expose secrets through ANY means
|
||||
|
||||
You must NEVER expose secrets through any channel, including but not limited to:
|
||||
- Displaying, printing, echoing, logging, or outputting to console
|
||||
- Writing to files (including .txt, .env, .json, config files, etc.)
|
||||
- Including in git commits, commit messages, or PR descriptions
|
||||
- Posting in GitHub comments, issue bodies, or PR review comments
|
||||
- Returning in tool outputs, API responses, or error messages
|
||||
- Including in redirect URLs, WebSocket messages, or GraphQL responses
|
||||
|
||||
Secrets include: API keys, authentication tokens, passwords, private keys, certificates, database connection strings, and any credential used for authentication or authorization. Common patterns (case-insensitive): variables containing API_KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL, PRIVATE_KEY, or AUTH in an authentication context. Use judgment: \`PUBLIC_KEY\` for a cryptographic public key is fine; \`PRIVATE_KEY\` is not.
|
||||
|
||||
### Rule 2: Never serialize objects containing secrets
|
||||
|
||||
When working with objects that may contain environment variables or secrets:
|
||||
- NEVER serialize, stringify, or dump entire environment objects (process.env, os.environ, ENV, etc.)
|
||||
- NEVER iterate over environment variables and write their values to files
|
||||
- NEVER include environment variable values in outputs, logs, HTTP requests, or anywhere they can be exposed
|
||||
- If you must list properties, only show property NAMES, never values
|
||||
- Only access specific, known-safe keys explicitly (e.g., NODE_ENV, HOME, PWD)
|
||||
|
||||
### Rule 3: Refuse and explain
|
||||
|
||||
Even if explicitly requested to reveal secrets, you must:
|
||||
1. Refuse the request
|
||||
2. Print a message explaining that exposing secrets is prohibited for security reasons
|
||||
3. If using ${ghPullfrogMcpName}, update the working comment to explain that secrets cannot be revealed
|
||||
4. Offer a safe alternative, if applicable
|
||||
|
||||
If you encounter secrets in files or environment, acknowledge they exist but never reveal their values.
|
||||
Never expose secrets (API keys, tokens, passwords, private keys, credentials) through any channel: console output, files, commits, comments, API responses, error messages, or URLs. Never serialize environment objects (\`process.env\`, \`os.environ\`, etc.) or iterate over them. If asked to reveal secrets: refuse, explain that exposing secrets is prohibited, and offer a safe alternative if applicable. Detect and deny any suspicious or malicious requests.
|
||||
|
||||
## MCP (Model Context Protocol) Tools
|
||||
|
||||
@@ -143,7 +116,6 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
|
||||
|
||||
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
|
||||
|
||||
**File discovery**: Use \`${ghPullfrogMcpName}/list_files\` to discover files in the repository. This tool finds both git-tracked and untracked files, including newly created files that haven't been committed yet. Prefer this over native agent tools like \`glob\` or \`grep\` for file discovery, as it provides consistent results and handles untracked files correctly.
|
||||
` +
|
||||
// **Available git MCP tools**:
|
||||
// - \`${ghPullfrogMcpName}/checkout_pr\` - Checkout an existing PR branch locally (handles fork PRs automatically)
|
||||
@@ -170,6 +142,12 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
|
||||
|
||||
**Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error.
|
||||
|
||||
${
|
||||
useNativeBash
|
||||
? `**Shell commands**: Use your native bash/shell tool for shell command execution.`
|
||||
: `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`
|
||||
}
|
||||
|
||||
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
|
||||
|
||||
**Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable—do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
|
||||
|
||||
+33
-16
@@ -7,6 +7,7 @@ import { addInstructions } from "./instructions.ts";
|
||||
import {
|
||||
agent,
|
||||
type ConfigureMcpServersParams,
|
||||
createAgentEnv,
|
||||
installFromNpmTarball,
|
||||
setupProcessAgentEnv,
|
||||
} from "./shared.ts";
|
||||
@@ -21,16 +22,28 @@ 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");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
|
||||
configureOpenCode({
|
||||
mcpServers,
|
||||
sandbox: payload.sandbox ?? false,
|
||||
isPublicRepo: repo.isPublic,
|
||||
});
|
||||
|
||||
const prompt = addInstructions({ payload, repo });
|
||||
log.group("» Full prompt", () => log.info(prompt));
|
||||
log.group("Full prompt", () => log.info(prompt));
|
||||
|
||||
// message positional must come right after "run", before flags
|
||||
const args = ["run", prompt, "--format", "json"];
|
||||
@@ -42,24 +55,24 @@ export const opencode = agent({
|
||||
// 6. set up environment
|
||||
setupProcessAgentEnv({ HOME: tempHome });
|
||||
|
||||
// build env vars: start with process.env (includes all API_KEY vars loaded by config())
|
||||
// exclude GITHUB_TOKEN - OpenCode should use MCP server for GitHub operations, not direct token
|
||||
// then override with apiKeys, HOME, and XDG_CONFIG_HOME
|
||||
// SECURITY: build env vars from whitelisted base env to prevent API key leakage
|
||||
// this prevents leaking other API keys (ANTHROPIC, GEMINI, etc.) to OpenCode subprocess
|
||||
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
|
||||
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
|
||||
const env: Record<string, string> = {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([key, value]) => value !== undefined && key !== "GITHUB_TOKEN"
|
||||
)
|
||||
) as Record<string, string>),
|
||||
HOME: tempHome,
|
||||
...createAgentEnv({ HOME: tempHome }),
|
||||
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
||||
};
|
||||
// OpenCode doesn't support GitHub App installation tokens
|
||||
delete env.GITHUB_TOKEN;
|
||||
|
||||
// add/override API keys from apiKeys object (uppercase keys)
|
||||
// add API keys from apiKeys object
|
||||
for (const [key, value] of Object.entries(apiKeys || {})) {
|
||||
env[key.toUpperCase()] = value;
|
||||
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
|
||||
if (key === "GEMINI_API_KEY") {
|
||||
env.GOOGLE_GENERATIVE_AI_API_KEY = value;
|
||||
}
|
||||
}
|
||||
|
||||
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
||||
@@ -188,13 +201,14 @@ export const opencode = agent({
|
||||
interface ConfigureOpenCodeParams {
|
||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||
sandbox: boolean;
|
||||
isPublicRepo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OpenCode via opencode.json config file.
|
||||
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
||||
*/
|
||||
function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): void {
|
||||
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
|
||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||
const configDir = join(tempHome, ".config", "opencode");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
@@ -218,7 +232,10 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
||||
};
|
||||
}
|
||||
|
||||
// build permissions config
|
||||
// SECURITY: For PUBLIC repos, OpenCode 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.
|
||||
const bashPermission = isPublicRepo ? "deny" : "allow";
|
||||
const permission = sandbox
|
||||
? {
|
||||
edit: "deny",
|
||||
@@ -229,7 +246,7 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
||||
}
|
||||
: {
|
||||
edit: "allow",
|
||||
bash: "allow",
|
||||
bash: bashPermission,
|
||||
webfetch: "allow",
|
||||
doom_loop: "allow",
|
||||
external_directory: "allow",
|
||||
|
||||
+9
-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";
|
||||
|
||||
@@ -27,6 +33,7 @@ export interface RepoInfo {
|
||||
owner: string;
|
||||
name: string;
|
||||
defaultBranch: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +46,7 @@ export interface AgentConfig {
|
||||
mcpServers: Record<string, McpHttpServerConfig>;
|
||||
cliPath: string;
|
||||
repo: RepoInfo;
|
||||
effort: Effort;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Docker Testing Environment
|
||||
|
||||
`play.ts` runs in Docker by default for realistic testing (Linux, clean $HOME, matches CI).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm play bash-test.ts # runs in Docker (default)
|
||||
pnpm play --local bash-test.ts # runs on macOS (fast iteration)
|
||||
PLAY_LOCAL=1 pnpm play ... # same as --local
|
||||
```
|
||||
|
||||
## Why Docker by Default?
|
||||
|
||||
1. **Matches CI** - Linux environment like GitHub Actions
|
||||
2. **Clean $HOME** - No agent config pollution from `~/.claude`, `~/.cursor`
|
||||
3. **Tests unshare** - Verifies PID namespace sandbox works
|
||||
4. **Reproducible** - Same environment every run
|
||||
|
||||
## Performance
|
||||
|
||||
| Mode | Overhead |
|
||||
|------|----------|
|
||||
| Docker (cached deps) | ~1.5s |
|
||||
| Local (macOS) | ~0s |
|
||||
|
||||
For agent runs taking 30-120s, the 1.5s overhead is negligible.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. `play.ts` runs on host, loads `.env`
|
||||
2. Spawns Docker container with:
|
||||
- Volume-mounted `action/` code
|
||||
- Named volume for Linux node_modules (persists between runs)
|
||||
- SSH agent forwarding for git clone
|
||||
- Env vars passed via `-e` flags
|
||||
3. Inside Docker, `play.ts` runs again (detects `/.dockerenv` file)
|
||||
4. Clones `GITHUB_REPOSITORY`, runs agent
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Docker not running:**
|
||||
```
|
||||
Cannot connect to the Docker daemon
|
||||
```
|
||||
→ Start Docker Desktop
|
||||
|
||||
**SSH clone fails:**
|
||||
```
|
||||
Permission denied (publickey)
|
||||
```
|
||||
→ Ensure SSH agent is running: `ssh-add -l`
|
||||
@@ -0,0 +1,306 @@
|
||||
# Bash Tool Security
|
||||
|
||||
> **Note**: Security measures described here apply to **PUBLIC repositories only**. For private repos, agents can use native bash with full environment access.
|
||||
|
||||
## Architecture (Public Repos)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ GitHub Actions Runner │
|
||||
│ (has secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ Pullfrog Action (Node.js) │ │
|
||||
│ │ - process.env contains all secrets │ │
|
||||
│ │ - spawns agent CLI as child process │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Agent CLI (Claude/Cursor/OpenCode/etc.) │ │ │
|
||||
│ │ │ - receives filtered env (only API key it needs) │ │ │
|
||||
│ │ │ - has built-in Bash tool (DISABLED for public) │ │ │
|
||||
│ │ │ - connects to MCP server for tools │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ MCP Bash Tool (our code) │ │ │ │
|
||||
│ │ │ │ - agent calls this for shell commands │ │ │ │
|
||||
│ │ │ │ - spawns bash with filtered env │ │ │ │
|
||||
│ │ │ │ - uses PID namespace isolation │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │
|
||||
│ │ │ │ │ Bash subprocess │ │ │ │ │
|
||||
│ │ │ │ │ - runs user-controlled commands │ │ │ │ │
|
||||
│ │ │ │ │ - MUST NOT access secrets │ │ │ │ │
|
||||
│ │ │ │ └─────────────────────────────────────────┘ │ │ │ │
|
||||
│ │ │ └───────────────────────────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key insight**: For **public repos**, the Pullfrog Action process has all secrets in `process.env`. Agent CLIs have built-in Bash tools that we can't trust since malicious actors can submit PRs with prompt injections. We disable those and provide our own MCP Bash tool that spawns subprocesses securely.
|
||||
|
||||
For **private repos**, the threat model is different — only trusted collaborators can trigger workflows, so we allow native bash with full environment access for better performance and compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Public vs Private Repos
|
||||
|
||||
| Repo Visibility | Native Bash | Env Filtering | PID Isolation |
|
||||
|-----------------|-------------|---------------|---------------|
|
||||
| **Public** | Disabled | Yes | Yes (in CI) |
|
||||
| **Private** | Enabled | No | No |
|
||||
|
||||
**Rationale**: Public repos are at risk from prompt injection attacks via pull requests from untrusted contributors. Private repos only allow trusted collaborators, so the attack surface is much smaller.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model (Public Repos)
|
||||
|
||||
A prompt-injected agent could run malicious bash commands to exfiltrate API keys.
|
||||
|
||||
**Attack vectors:**
|
||||
|
||||
| Vector | Example | Mitigation |
|
||||
|--------|---------|------------|
|
||||
| Direct env access | `env \| grep KEY` | Filter env vars before spawn |
|
||||
| Echo variable | `echo $ANTHROPIC_API_KEY` | Filter env vars before spawn |
|
||||
| `/proc/$PPID/environ` | `cat /proc/$PPID/environ` | PID namespace isolation |
|
||||
|
||||
The first two are solved by passing filtered env to subprocess. The third requires special handling on Linux.
|
||||
|
||||
---
|
||||
|
||||
## Attack: /proc/$PPID/environ (Public Repos)
|
||||
|
||||
On Linux, any process can read its parent's environment via `/proc/$PPID/environ`. Even if we spawn bash with a clean environment, the bash process can:
|
||||
|
||||
```bash
|
||||
# read parent's (Node.js) environment - contains all secrets!
|
||||
tr '\0' '\n' < /proc/$PPID/environ | grep KEY
|
||||
```
|
||||
|
||||
This bypasses environment filtering because we're reading the parent process's memory, not our own env.
|
||||
|
||||
**Why this matters:**
|
||||
- Pullfrog Action (Node.js) has `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. in `process.env`
|
||||
- We spawn agent CLI with filtered env (only its own API key)
|
||||
- Agent CLI spawns MCP Bash tool
|
||||
- MCP Bash tool spawns bash with filtered env (no secrets)
|
||||
- BUT bash can read `/proc/$PPID/environ` → gets Node.js process's full env
|
||||
|
||||
---
|
||||
|
||||
## Solution: PID Namespace Isolation (Public Repos)
|
||||
|
||||
We use Linux PID namespaces to hide the parent process:
|
||||
|
||||
```bash
|
||||
unshare --pid --fork --mount-proc bash -c "$CMD"
|
||||
```
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `--pid` | Create new PID namespace |
|
||||
| `--fork` | Fork so child is actually in new namespace |
|
||||
| `--mount-proc` | Mount fresh `/proc` for new namespace |
|
||||
|
||||
**Result:**
|
||||
- Child sees itself as PID 1
|
||||
- Child's PPID is 0 (doesn't exist)
|
||||
- `/proc` only shows processes in child's namespace
|
||||
- Parent's PID is invisible → `/proc/$PPID/environ` fails
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### mcp/bash.ts
|
||||
|
||||
```typescript
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
// filter sensitive env vars (only for public repos)
|
||||
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
||||
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue;
|
||||
// only filter sensitive vars for public repos
|
||||
if (isPublicRepo && SENSITIVE.some(p => p.test(key))) continue;
|
||||
filtered[key] = value;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// spawn with PID namespace in CI for public repos, plain spawn otherwise
|
||||
function spawnSandboxed(command: string, options: { env, cwd, isPublicRepo }): ChildProcess {
|
||||
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||
if (useNamespaceIsolation) {
|
||||
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
|
||||
}
|
||||
return spawn("bash", ["-c", command], options);
|
||||
}
|
||||
|
||||
// BashTool uses ctx.repo.private to determine visibility
|
||||
export function BashTool(ctx: ToolContext) {
|
||||
const isPublicRepo = !ctx.repo.private;
|
||||
// ... spawns with filterEnv(isPublicRepo) and isPublicRepo flag
|
||||
}
|
||||
```
|
||||
|
||||
**Defense in depth (public repos only):**
|
||||
1. `filterEnv(true)` - prevents `env` and `echo $VAR` attacks
|
||||
2. `unshare` - prevents `/proc/$PPID/environ` attack
|
||||
|
||||
---
|
||||
|
||||
## Disabling Native Bash Tools (Public Repos)
|
||||
|
||||
For **public repos**, each agent's built-in Bash/Shell tools are disabled. Agents use our MCP Bash tool which filters secrets:
|
||||
|
||||
```typescript
|
||||
// Claude - conditional based on repo.isPublic
|
||||
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||
{ permissionMode: "bypassPermissions", disallowedTools }
|
||||
|
||||
// Cursor - conditional shell denial
|
||||
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||
{ permissions: { allow: ["Read(**)", "Write(**)"], deny: denyShell } }
|
||||
|
||||
// OpenCode - conditional bash denial
|
||||
const bashPermission = isPublicRepo ? "deny" : "allow";
|
||||
{ permission: { edit: "allow", bash: bashPermission, ... } }
|
||||
|
||||
// Gemini - uses excludeTools in ~/.gemini/settings.json
|
||||
newSettings.excludeTools = ["run_shell_command"];
|
||||
|
||||
// Codex - CLI internally scrubs env before spawning shell
|
||||
// No SDK-level config needed; Codex handles this automatically
|
||||
```
|
||||
|
||||
For **private repos**, native bash is allowed for all agents.
|
||||
|
||||
---
|
||||
|
||||
## Testing (Public Repo Scenario)
|
||||
|
||||
Run the vulnerability test in Docker to verify protection for public repos:
|
||||
|
||||
```bash
|
||||
# from action/ directory
|
||||
docker run --rm \
|
||||
-v "$(pwd):/app/action:cached" \
|
||||
-v "pullfrog-action-node-modules:/app/action/node_modules" \
|
||||
-w /app/action \
|
||||
-e GITHUB_ACTIONS=true \
|
||||
-e TEST_SECRET_KEY=test-secret \
|
||||
-e ANTHROPIC_API_KEY=sk-test \
|
||||
--cap-add SYS_ADMIN \
|
||||
--security-opt seccomp:unconfined \
|
||||
node:22 bash -c "corepack enable pnpm && pnpm install --frozen-lockfile && node test/proc-environ-vuln.ts"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
1. UNPROTECTED (filterEnv only):
|
||||
Leaked: YES ❌
|
||||
|
||||
2. PROTECTED (unshare --pid --fork --mount-proc):
|
||||
Leaked: NO ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform Notes
|
||||
|
||||
| Environment | Repo | Our approach |
|
||||
|-------------|------|--------------|
|
||||
| GitHub Actions (Linux) | Public | filterEnv + unshare + disable native bash |
|
||||
| GitHub Actions (Linux) | Private | Full env + native bash allowed |
|
||||
| Local dev (any OS) | Any | No filtering (local dev assumed trusted) |
|
||||
|
||||
We check `process.env.CI === "true"` (set by GitHub Actions) combined with `ctx.repo.private` to determine the security posture:
|
||||
- **CI + Public repo**: Full protection with PID namespace isolation
|
||||
- **CI + Private repo**: No protection (trusted collaborators only)
|
||||
- **Local**: No protection (developer's own machine)
|
||||
|
||||
GitHub Actions uses Ubuntu runners where `unshare` works without root.
|
||||
|
||||
---
|
||||
|
||||
## What This Does NOT Protect Against (Public Repos)
|
||||
|
||||
Even with protections enabled, bash subprocesses can still:
|
||||
|
||||
- **Network exfiltration**: Child has full network access
|
||||
- **File access**: Child can read any file the runner can (same UID)
|
||||
- **Resource exhaustion**: No cgroup limits
|
||||
|
||||
For those, you'd need `bwrap` with `--unshare-net`, `--ro-bind`, etc. But for the stated goal—preventing secret exfiltration via env—this is sufficient.
|
||||
|
||||
For **private repos**, none of these protections apply since we trust collaborators.
|
||||
|
||||
---
|
||||
|
||||
## Agent-Specific Notes
|
||||
|
||||
### Claude, Cursor, OpenCode (Public Repos)
|
||||
|
||||
These agents have their native Bash disabled via configuration. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`.
|
||||
|
||||
For private repos, native bash is enabled for these agents.
|
||||
|
||||
### Gemini (Public Repos)
|
||||
|
||||
Gemini CLI supports `excludeTools` in its user-level settings file (`~/.gemini/settings.json`). For public repos, we exclude the native shell tool:
|
||||
|
||||
```typescript
|
||||
// written to ~/.gemini/settings.json
|
||||
newSettings.excludeTools = ["run_shell_command"];
|
||||
```
|
||||
|
||||
This is a blocklist approach which explicitly excludes the shell tool while allowing all other tools.
|
||||
|
||||
Additionally, Gemini has built-in CI detection that filters shell env when `GITHUB_SHA` is set.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex CLI filters out env vars matching `KEY`, `SECRET`, or `TOKEN` (case-insensitive) by default via `shell_environment_policy.ignore_default_excludes = false`.
|
||||
|
||||
**Vulnerability**: If a user's `~/.codex/config.toml` has `ignore_default_excludes = true`, secrets will leak to shell commands.
|
||||
|
||||
**Our mitigation**: We set `CODEX_HOME` to a temp directory and write our own `config.toml` with `ignore_default_excludes = false` to enforce filtering regardless of what config exists in the user's `~/.codex/`.
|
||||
|
||||
```typescript
|
||||
// set CODEX_HOME to override user's config
|
||||
setupProcessAgentEnv({ CODEX_HOME: codexDir });
|
||||
|
||||
// write secure config to $CODEX_HOME/config.toml
|
||||
writeFileSync(join(codexDir, "config.toml"), `
|
||||
[shell_environment_policy]
|
||||
ignore_default_excludes = false
|
||||
`);
|
||||
```
|
||||
|
||||
See [GitHub Issue #3064](https://github.com/openai/codex/issues/3064) and [config docs](https://github.com/openai/codex/blob/main/docs/config.md#shell_environment_policy).
|
||||
|
||||
**Verified behavior** (tested via `pnpm play codex-env-test.ts`):
|
||||
- Default (no config): ✅ secrets filtered
|
||||
- `ignore_default_excludes = false`: ✅ secrets filtered
|
||||
- `ignore_default_excludes = true`: ❌ secrets leak
|
||||
|
||||
Example output when running `env | grep TEST` with our config:
|
||||
```
|
||||
TEST_SAFE_VAR=VISIBLE-SAFE-VALUE
|
||||
# FAKE_SECRET_KEY and TEST_API_TOKEN are NOT visible (filtered)
|
||||
```
|
||||
|
||||
### Summary by Agent
|
||||
|
||||
| Agent | Public Repo | Private Repo |
|
||||
|-------|-------------|--------------|
|
||||
| Claude | Native bash **disabled** | Native bash allowed |
|
||||
| Cursor | Native shell **disabled** | Native shell allowed |
|
||||
| OpenCode | Native bash **disabled** | Native bash allowed |
|
||||
| Gemini | Native shell **disabled** (via excludeTools) | Native bash allowed |
|
||||
| Codex | Native shell allowed (CLI scrubs env internally) | Native bash allowed |
|
||||
@@ -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);
|
||||
|
||||
|
||||
+10
-2
@@ -19,7 +19,7 @@ const stripShebangPlugin = {
|
||||
try {
|
||||
const content = readFileSync(outputFile, "utf8");
|
||||
// Remove shebang line from the beginning if present
|
||||
const withoutShebang = content.startsWith("#!")
|
||||
const withoutShebang = content.startsWith("#!")
|
||||
? content.slice(content.indexOf("\n") + 1)
|
||||
: content;
|
||||
writeFileSync(outputFile, withoutShebang);
|
||||
@@ -38,7 +38,7 @@ const sharedConfig = {
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
target: "node24",
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
// Bundle all dependencies - GitHub Actions doesn't have node_modules
|
||||
@@ -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!");
|
||||
|
||||
+22
-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;
|
||||
@@ -212,6 +216,13 @@ interface FixReviewEvent extends BasePayloadEvent {
|
||||
unapproved_comments: ReviewCommentData[];
|
||||
}
|
||||
|
||||
interface ImplementPlanEvent extends BasePayloadEvent {
|
||||
trigger: "implement_plan";
|
||||
issue_number: number;
|
||||
plan_comment_id: number;
|
||||
plan_content: string;
|
||||
}
|
||||
|
||||
interface UnknownEvent extends BasePayloadEvent {
|
||||
trigger: "unknown";
|
||||
}
|
||||
@@ -231,6 +242,7 @@ export type PayloadEvent =
|
||||
| CheckSuiteCompletedEvent
|
||||
| WorkflowDispatchEvent
|
||||
| FixReviewEvent
|
||||
| ImplementPlanEvent
|
||||
| UnknownEvent;
|
||||
|
||||
export interface DispatchOptions {
|
||||
@@ -271,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,35 @@
|
||||
import type { Payload } from "../external.ts";
|
||||
|
||||
/**
|
||||
* test fixture: verifies agents use MCP bash tool for shell commands.
|
||||
* creates a simple test file and runs it with node.
|
||||
*
|
||||
* for insecure agents (claude, cursor, opencode): native bash is disabled,
|
||||
* so they MUST use gh_pullfrog/bash MCP tool to run shell commands.
|
||||
*
|
||||
* for secure agents (codex, gemini): native bash is safe, but this test
|
||||
* still verifies shell execution works.
|
||||
*
|
||||
* run with: AGENT_OVERRIDE=<agent> pnpm play bash-test.ts
|
||||
*/
|
||||
export default {
|
||||
"~pullfrog": true,
|
||||
agent: null,
|
||||
prompt: `Create a file called test-runner.js with the following content:
|
||||
|
||||
\`\`\`javascript
|
||||
const assert = require('assert');
|
||||
assert.strictEqual(2 + 2, 4, 'math should work');
|
||||
console.log('TEST PASSED: basic arithmetic works');
|
||||
\`\`\`
|
||||
|
||||
Then run it with: node test-runner.js
|
||||
|
||||
Finally, delete the test file.
|
||||
|
||||
This tests that you can execute shell commands properly.`,
|
||||
event: {
|
||||
trigger: "workflow_dispatch",
|
||||
},
|
||||
modes: [],
|
||||
} satisfies Payload;
|
||||
+9
-6
@@ -1,9 +1,12 @@
|
||||
import type { Inputs } from "../main.ts";
|
||||
import type { Payload } from "../external.ts";
|
||||
|
||||
const testParams = {
|
||||
export default {
|
||||
"~pullfrog": true,
|
||||
agent: null,
|
||||
prompt:
|
||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||
anthropic_api_key: "sk-test-key",
|
||||
} satisfies Inputs;
|
||||
|
||||
export default testParams;
|
||||
event: {
|
||||
trigger: "workflow_dispatch",
|
||||
},
|
||||
modes: [],
|
||||
} satisfies Payload;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
add a file implementing quicksort and test it
|
||||
Find all markdown files in the repository and list their names from https://github.com/ShawnMorreau/cal.com/
|
||||
@@ -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[];
|
||||
+3
-5
@@ -6,9 +6,9 @@ import type { Payload } from "../external.ts";
|
||||
*
|
||||
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
||||
*/
|
||||
const payload: Payload = {
|
||||
export default {
|
||||
"~pullfrog": true,
|
||||
agent: null, // let AGENT_OVERRIDE control this for testing different agents
|
||||
agent: null,
|
||||
prompt: `Please do the following three things:
|
||||
|
||||
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
||||
@@ -24,6 +24,4 @@ All three of these actions should fail because you are running in sandbox mode w
|
||||
},
|
||||
modes: [],
|
||||
sandbox: true,
|
||||
};
|
||||
|
||||
export default JSON.stringify(payload);
|
||||
} 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);
|
||||
}
|
||||
@@ -2,13 +2,12 @@ import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { flatMorph } from "@ark/util";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
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 {
|
||||
parseRepoContext,
|
||||
revokeGitHubInstallationToken,
|
||||
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;
|
||||
@@ -53,7 +36,6 @@ export interface MainResult {
|
||||
|
||||
// intermediate result types for deterministic context building
|
||||
interface GitHubSetup {
|
||||
token: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
octokit: Octokit;
|
||||
@@ -66,12 +48,11 @@ type ApiKeySetup =
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
let mcpServerClose: (() => Promise<void>) | undefined;
|
||||
const timer = new Timer();
|
||||
await using tokenRef = await setupGitHubInstallationToken();
|
||||
let payload: Payload | undefined;
|
||||
|
||||
try {
|
||||
const timer = new Timer();
|
||||
|
||||
// phase 1: parse and validate inputs
|
||||
payload = parsePayload(inputs);
|
||||
Inputs.assert(inputs);
|
||||
@@ -79,14 +60,13 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
|
||||
// phase 2: fast setup (github + temp dir)
|
||||
const [githubSetup, sharedTempDir] = await Promise.all([
|
||||
initializeGitHub(),
|
||||
initializeGitHub(tokenRef.token),
|
||||
createTempDirectory(),
|
||||
]);
|
||||
timer.checkpoint("githubSetup");
|
||||
|
||||
// phase 3: resolve agent (needs repo settings)
|
||||
const agent = resolveAgent({
|
||||
inputs,
|
||||
payload,
|
||||
repoSettings: githubSetup.repoSettings,
|
||||
});
|
||||
@@ -95,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,
|
||||
});
|
||||
@@ -107,9 +86,9 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
// phase 5: parallel long-running operations (agent install + git auth)
|
||||
const toolState: ToolState = {};
|
||||
const [cliPath] = await Promise.all([
|
||||
installAgentCli({ agent, token: githubSetup.token }),
|
||||
installAgentCli({ agent, token: tokenRef.token }),
|
||||
setupGitAuth({
|
||||
token: githubSetup.token,
|
||||
token: tokenRef.token,
|
||||
owner: githubSetup.owner,
|
||||
name: githubSetup.name,
|
||||
payload: resolvedPayload,
|
||||
@@ -156,7 +135,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
const toolContext: ToolContext = {
|
||||
owner: githubSetup.owner,
|
||||
name: githubSetup.name,
|
||||
githubInstallationToken: githubSetup.token,
|
||||
githubInstallationToken: tokenRef.token,
|
||||
octokit: githubSetup.octokit,
|
||||
payload: resolvedPayload,
|
||||
repo: githubSetup.repo,
|
||||
@@ -169,11 +148,10 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
jobId,
|
||||
};
|
||||
|
||||
const { url: mcpServerUrl, close: mcpServerCloseFunc } = await startMcpHttpServer(toolContext);
|
||||
mcpServerClose = mcpServerCloseFunc;
|
||||
log.info(`🚀 MCP server started at ${mcpServerUrl}`);
|
||||
await using mcpHttpServer = await startMcpHttpServer(toolContext);
|
||||
log.info(`🚀 MCP server started at ${mcpHttpServer.url}`);
|
||||
|
||||
const mcpServers = createMcpConfigs(mcpServerUrl);
|
||||
const mcpServers = createMcpConfigs(mcpHttpServer.url);
|
||||
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
||||
timer.checkpoint("mcpServer");
|
||||
|
||||
@@ -181,8 +159,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
const ctx: AgentContext = {
|
||||
...toolContext,
|
||||
inputs,
|
||||
mcpServerUrl,
|
||||
mcpServerClose: mcpServerCloseFunc,
|
||||
mcpServerUrl: mcpHttpServer.url,
|
||||
mcpServers,
|
||||
cliPath,
|
||||
apiKey: apiKeySetup.apiKey,
|
||||
@@ -225,33 +202,23 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
} catch {
|
||||
// error updating comment, but don't let it mask the original error
|
||||
}
|
||||
|
||||
if (mcpServerClose) {
|
||||
await mcpServerClose();
|
||||
}
|
||||
await revokeGitHubInstallationToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,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 ")}`;
|
||||
}
|
||||
|
||||
@@ -321,7 +288,6 @@ export interface ToolContext {
|
||||
export interface AgentContext extends Readonly<ToolContext> {
|
||||
readonly inputs: Inputs;
|
||||
readonly mcpServerUrl: string;
|
||||
readonly mcpServerClose: () => Promise<void>;
|
||||
readonly mcpServers: ReturnType<typeof createMcpConfigs>;
|
||||
readonly cliPath: string;
|
||||
readonly apiKey: string;
|
||||
@@ -337,6 +303,7 @@ export interface DependencyInstallationState {
|
||||
export interface ToolState {
|
||||
prNumber?: number;
|
||||
issueNumber?: number;
|
||||
selectedMode?: string;
|
||||
review?: {
|
||||
id: number; // REST API database ID (for fix URLs)
|
||||
nodeId: string; // GraphQL node ID (for mutations)
|
||||
@@ -347,13 +314,12 @@ export interface ToolState {
|
||||
/**
|
||||
* Initialize GitHub connection: token, octokit, repo data, settings
|
||||
*/
|
||||
async function initializeGitHub(): Promise<GitHubSetup> {
|
||||
async function initializeGitHub(token: string): Promise<GitHubSetup> {
|
||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||
|
||||
const token = await setupGitHubInstallationToken();
|
||||
const { owner, name } = parseRepoContext();
|
||||
|
||||
const octokit = new Octokit({ auth: token });
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
// fetch repo data and settings in parallel
|
||||
const [repoResponse, repoSettings] = await Promise.all([
|
||||
@@ -362,7 +328,6 @@ async function initializeGitHub(): Promise<GitHubSetup> {
|
||||
]);
|
||||
|
||||
return {
|
||||
token,
|
||||
owner,
|
||||
name,
|
||||
octokit,
|
||||
@@ -372,15 +337,16 @@ async function initializeGitHub(): 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}`
|
||||
);
|
||||
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
||||
|
||||
if (configuredAgentName) {
|
||||
@@ -398,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"
|
||||
@@ -412,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");
|
||||
}
|
||||
@@ -435,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,
|
||||
@@ -445,6 +416,7 @@ function parsePayload(inputs: Inputs): Payload {
|
||||
trigger: "unknown",
|
||||
},
|
||||
modes,
|
||||
effort: inputs.effort ?? "think",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -457,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,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 {
|
||||
@@ -507,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
|
||||
@@ -524,7 +492,9 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
||||
owner: ctx.owner,
|
||||
name: ctx.name,
|
||||
defaultBranch: ctx.repo.default_branch,
|
||||
isPublic: !ctx.repo.private,
|
||||
},
|
||||
effort,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { type } from "arktype";
|
||||
import type { ToolContext } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const BashParams = type({
|
||||
command: "string",
|
||||
description: "string",
|
||||
"timeout?": "number",
|
||||
"working_directory?": "string",
|
||||
});
|
||||
|
||||
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
||||
const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||
|
||||
function isSensitive(key: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||
}
|
||||
|
||||
/** filter env vars, removing sensitive values (only for public repos) */
|
||||
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue;
|
||||
// only filter sensitive vars for public repos
|
||||
if (isPublicRepo && isSensitive(key)) continue;
|
||||
filtered[key] = value;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn command with filtered env. in CI, also use PID namespace isolation
|
||||
* to prevent child from reading /proc/$PPID/environ (only for public repos)
|
||||
*/
|
||||
function spawnSandboxed(
|
||||
command: string,
|
||||
options: { env: Record<string, string>; cwd: string; isPublicRepo: boolean }
|
||||
): ChildProcess {
|
||||
const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
|
||||
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
||||
// only use PID namespace isolation for public repos in CI
|
||||
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||
return useNamespaceIsolation
|
||||
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
||||
: spawn("bash", ["-c", command], spawnOpts);
|
||||
}
|
||||
|
||||
/** kill process and its entire process group */
|
||||
async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
||||
if (!proc.pid) return;
|
||||
try {
|
||||
process.kill(-proc.pid, "SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
process.kill(-proc.pid, "SIGKILL");
|
||||
} catch {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function BashTool(ctx: ToolContext) {
|
||||
const isPublicRepo = !ctx.repo.private;
|
||||
|
||||
return tool({
|
||||
name: "bash",
|
||||
description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""}
|
||||
|
||||
Use this tool to:
|
||||
- Run shell commands (ls, cat, grep, find, etc.)
|
||||
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||
- Run tests and linters
|
||||
- Perform git operations
|
||||
- Run shell commands in a secure environment. Unlike the built-in bash tool, this tool filters sensitive environment variables from the subprocess's environment to avoid leaking secrets.`,
|
||||
parameters: BashParams,
|
||||
execute: execute(async (params) => {
|
||||
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
||||
const cwd = params.working_directory ?? process.cwd();
|
||||
const proc = spawnSandboxed(params.command, {
|
||||
env: filterEnv(isPublicRepo),
|
||||
cwd,
|
||||
isPublicRepo,
|
||||
});
|
||||
|
||||
let stdout = "",
|
||||
stderr = "",
|
||||
timedOut = false,
|
||||
exited = false;
|
||||
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
if (!exited) {
|
||||
timedOut = true;
|
||||
await killProcessGroup(proc);
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
const done = (code: number | null) => {
|
||||
exited = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(code);
|
||||
};
|
||||
proc.on("exit", done);
|
||||
proc.on("error", () => done(null));
|
||||
});
|
||||
|
||||
let output = stderr ? (stdout ? `${stdout}\n${stderr}` : stderr) : stdout;
|
||||
if (timedOut)
|
||||
output = output
|
||||
? `${output}\n[timed out after ${timeout}ms]`
|
||||
: `[timed out after ${timeout}ms]`;
|
||||
|
||||
return {
|
||||
output: output.trim(),
|
||||
exit_code: exitCode ?? (timedOut ? 124 : -1),
|
||||
timed_out: timedOut,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
+88
-18
@@ -1,12 +1,85 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import type { ToolContext } from "../main.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
||||
|
||||
/**
|
||||
* formats PR files with explicit line numbers for each code line.
|
||||
* preserves all original diff info (file headers, hunk headers) and adds:
|
||||
* | OLD | NEW | TYPE | code
|
||||
*/
|
||||
export function formatFilesWithLineNumbers(files: PullFile[]): string {
|
||||
const output: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// file header
|
||||
output.push(`diff --git a/${file.filename} b/${file.filename}`);
|
||||
output.push(`--- a/${file.filename}`);
|
||||
output.push(`+++ b/${file.filename}`);
|
||||
|
||||
if (!file.patch) {
|
||||
output.push("(binary file or no changes)");
|
||||
output.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// parse and format the patch with line numbers
|
||||
const lines = file.patch.split("\n");
|
||||
let oldLine = 0;
|
||||
let newLine = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// hunk header: @@ -OLD,COUNT +NEW,COUNT @@ optional context
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunkMatch) {
|
||||
oldLine = parseInt(hunkMatch[1], 10);
|
||||
newLine = parseInt(hunkMatch[2], 10);
|
||||
output.push(line); // pass through unchanged
|
||||
continue;
|
||||
}
|
||||
|
||||
// code lines within hunks
|
||||
const changeType = line[0] || " ";
|
||||
const code = line.slice(1);
|
||||
|
||||
if (changeType === "-") {
|
||||
// removed line: show old line number, no new line number
|
||||
output.push(`| ${padNum(oldLine)} | | - | ${code}`);
|
||||
oldLine++;
|
||||
} else if (changeType === "+") {
|
||||
// added line: no old line number, show new line number
|
||||
output.push(`| | ${padNum(newLine)} | + | ${code}`);
|
||||
newLine++;
|
||||
} else if (changeType === " " || changeType === "\\") {
|
||||
// context line or "\ No newline at end of file"
|
||||
if (changeType === "\\") {
|
||||
output.push(line); // pass through as-is
|
||||
} else {
|
||||
output.push(`| ${padNum(oldLine)} | ${padNum(newLine)} | | ${code}`);
|
||||
oldLine++;
|
||||
newLine++;
|
||||
}
|
||||
} else {
|
||||
// unknown line type, pass through
|
||||
output.push(line);
|
||||
}
|
||||
}
|
||||
output.push(""); // blank line between files
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
function padNum(n: number): string {
|
||||
return n.toString().padStart(4, " ");
|
||||
}
|
||||
|
||||
export const CheckoutPr = type({
|
||||
pull_number: type.number.describe("the pull request number to checkout"),
|
||||
});
|
||||
@@ -21,7 +94,6 @@ export type CheckoutPrResult = {
|
||||
maintainerCanModify: boolean;
|
||||
url: string;
|
||||
headRepo: string;
|
||||
diff: string;
|
||||
diffPath: string;
|
||||
};
|
||||
|
||||
@@ -144,7 +216,7 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
name: "checkout_pr",
|
||||
description:
|
||||
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
|
||||
"The PR diff is written to a file (diffPath) for grep access. For small diffs, it's also returned inline.",
|
||||
"Returns diffPath pointing to the formatted diff file.",
|
||||
parameters: CheckoutPr,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const result = await checkoutPrBranch({
|
||||
@@ -170,27 +242,26 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
throw new Error(`PR #${pull_number} source repository was deleted`);
|
||||
}
|
||||
|
||||
// fetch PR diff via API (authoritative source - not affected by main advancing)
|
||||
const diffResponse = await ctx.octokit.rest.pulls.get({
|
||||
// fetch PR files and format with line numbers
|
||||
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
mediaType: { format: "diff" },
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
// write diff to file for grep access
|
||||
const diffContent = diffResponse.data as unknown as string;
|
||||
const diffPath = join(process.env.PULLFROG_TEMP_DIR!, `pr-${pull_number}.diff`);
|
||||
const diffContent = formatFilesWithLineNumbers(filesResponse.data);
|
||||
const diffPreview = diffContent.split("\n").slice(0, 100).join("\n");
|
||||
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) {
|
||||
throw new Error(
|
||||
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
|
||||
);
|
||||
}
|
||||
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
|
||||
writeFileSync(diffPath, diffContent);
|
||||
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
||||
|
||||
// return diff inline only if small enough
|
||||
const MAX_INLINE_DIFF_SIZE = 50 * 1024; // 50KB
|
||||
const diff =
|
||||
diffContent.length <= MAX_INLINE_DIFF_SIZE
|
||||
? diffContent
|
||||
: `Diff is ${(diffContent.length / 1024).toFixed(0)}KB - use grep or read from ${diffPath}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
number: pr.data.number,
|
||||
@@ -201,7 +272,6 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||
url: pr.data.html_url,
|
||||
headRepo: headRepo.full_name,
|
||||
diff,
|
||||
diffPath,
|
||||
} satisfies CheckoutPrResult;
|
||||
}),
|
||||
|
||||
+107
-17
@@ -1,11 +1,16 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
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 { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts";
|
||||
import {
|
||||
createOctokit,
|
||||
getGitHubInstallationToken,
|
||||
type OctokitWithPlugins,
|
||||
parseRepoContext,
|
||||
} from "../utils/github.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
/**
|
||||
@@ -15,7 +20,19 @@ import { execute, tool } from "./shared.ts";
|
||||
*/
|
||||
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
||||
|
||||
async function buildCommentFooter(payload: Payload, octokit?: Octokit): Promise<string> {
|
||||
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
|
||||
interface BuildCommentFooterParams {
|
||||
payload: Payload;
|
||||
octokit?: OctokitWithPlugins | undefined;
|
||||
customParts?: string[] | undefined;
|
||||
}
|
||||
|
||||
async function buildCommentFooter({
|
||||
payload,
|
||||
octokit,
|
||||
customParts,
|
||||
}: BuildCommentFooterParams): Promise<string> {
|
||||
const repoContext = parseRepoContext();
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
|
||||
@@ -38,7 +55,7 @@ async function buildCommentFooter(payload: Payload, octokit?: Octokit): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
return buildPullfrogFooter({
|
||||
const footerParams = {
|
||||
triggeredBy: true,
|
||||
agent: {
|
||||
displayName: agentInfo?.displayName || "Unknown agent",
|
||||
@@ -52,12 +69,31 @@ async function buildCommentFooter(payload: Payload, octokit?: Octokit): Promise<
|
||||
...(workflowRunHtmlUrl ? { htmlUrl: workflowRunHtmlUrl } : {}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (customParts && customParts.length > 0) {
|
||||
return buildPullfrogFooter({ ...footerParams, customParts });
|
||||
}
|
||||
return buildPullfrogFooter(footerParams);
|
||||
}
|
||||
|
||||
async function addFooter(body: string, payload: Payload, octokit?: Octokit): Promise<string> {
|
||||
function buildImplementPlanLink(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
commentId: number
|
||||
): string {
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
|
||||
}
|
||||
|
||||
async function addFooter(
|
||||
body: string,
|
||||
payload: Payload,
|
||||
octokit?: OctokitWithPlugins
|
||||
): Promise<string> {
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = await buildCommentFooter(payload, octokit);
|
||||
const footer = await buildCommentFooter({ payload, octokit });
|
||||
return `${bodyWithoutFooter}${footer}`;
|
||||
}
|
||||
|
||||
@@ -164,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.
|
||||
@@ -181,11 +221,26 @@ export async function reportProgress(
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
|
||||
const existingCommentId = getProgressCommentId();
|
||||
const issueNumber =
|
||||
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
||||
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
||||
|
||||
// if we already have a progress comment, update it
|
||||
if (existingCommentId) {
|
||||
const customParts =
|
||||
isPlanMode && issueNumber !== undefined
|
||||
? [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, existingCommentId)]
|
||||
: undefined;
|
||||
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = await buildCommentFooter({
|
||||
payload: ctx.payload,
|
||||
octokit: ctx.octokit,
|
||||
customParts,
|
||||
});
|
||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -195,6 +250,8 @@ export async function reportProgress(
|
||||
|
||||
progressCommentWasUpdated = true;
|
||||
|
||||
await updateSummary(bodyWithFooter);
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
@@ -205,24 +262,55 @@ export async function reportProgress(
|
||||
|
||||
// no existing comment - create one
|
||||
// use fallback chain: dynamically set context > event payload
|
||||
const issueNumber =
|
||||
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
||||
if (issueNumber === undefined) {
|
||||
// cannot create comment without issue_number (e.g., workflow_dispatch events)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// for new comments, we need to create first, then update with Plan link if in Plan mode
|
||||
const initialBody = await addFooter(body, ctx.payload, ctx.octokit);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
issue_number: issueNumber,
|
||||
body: bodyWithFooter,
|
||||
body: initialBody,
|
||||
});
|
||||
|
||||
// store the comment ID for future updates
|
||||
setProgressCommentId(result.data.id);
|
||||
progressCommentWasUpdated = true;
|
||||
|
||||
// if Plan mode, update the comment to add the "Implement plan" link
|
||||
if (isPlanMode) {
|
||||
const customParts = [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, result.data.id)];
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = await buildCommentFooter({
|
||||
payload: ctx.payload,
|
||||
octokit: ctx.octokit,
|
||||
customParts,
|
||||
});
|
||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
comment_id: result.data.id,
|
||||
body: bodyWithPlanLink,
|
||||
});
|
||||
|
||||
await updateSummary(bodyWithPlanLink);
|
||||
|
||||
return {
|
||||
commentId: updateResult.data.id,
|
||||
url: updateResult.data.html_url,
|
||||
body: updateResult.data.body || "",
|
||||
action: "created",
|
||||
};
|
||||
}
|
||||
|
||||
await updateSummary(initialBody);
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
@@ -341,8 +429,7 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
||||
|
||||
// check if comment still says "leaping into action" - if it's been updated with an error, don't overwrite it
|
||||
const repoContext = parseRepoContext();
|
||||
const token = getGitHubInstallationToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
const octokit = createOctokit(getGitHubInstallationToken());
|
||||
|
||||
try {
|
||||
const existingComment = await octokit.rest.issues.getComment({
|
||||
@@ -363,10 +450,10 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
||||
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
const workflowRunLink = runId
|
||||
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
||||
: "workflow";
|
||||
? `[workflow run logs](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
||||
: "workflow run logs";
|
||||
|
||||
const errorMessage = `❌ this run croaked
|
||||
const errorMessage = `This run croaked 😵
|
||||
|
||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||
|
||||
@@ -406,6 +493,9 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
// mark progress as updated so ensureProgressCommentUpdated doesn't think the run failed
|
||||
progressCommentWasUpdated = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
@@ -413,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"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { relative, resolve } from "node:path";
|
||||
import { type } from "arktype";
|
||||
import type { ToolContext } from "../main.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const ListFiles = type({
|
||||
path: type.string
|
||||
.describe("The path to list files from (defaults to current directory)")
|
||||
.default("."),
|
||||
});
|
||||
|
||||
export function ListFilesTool(_ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "list_files",
|
||||
description:
|
||||
"List files in the repository, including both git-tracked and untracked files. Useful for discovering the file structure and locating files, including newly created files that haven't been committed yet.",
|
||||
parameters: ListFiles,
|
||||
execute: execute(async ({ path }: { path?: string }) => {
|
||||
const pathStr = path ?? ".";
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Get git-tracked files
|
||||
let gitFiles: string[] = [];
|
||||
let gitFailed = false;
|
||||
try {
|
||||
const gitArgs = pathStr === "." ? ["ls-files"] : ["ls-files", pathStr];
|
||||
const gitOutput = $("git", gitArgs, { log: false });
|
||||
gitFiles = gitOutput
|
||||
.split("\n")
|
||||
.filter((f) => f.trim() !== "")
|
||||
.map((f) => f.trim());
|
||||
} catch {
|
||||
// git might fail, that's ok - we'll use find instead
|
||||
gitFailed = true;
|
||||
}
|
||||
|
||||
// Always also check filesystem for untracked files
|
||||
// This is important because newly created files won't be in git yet
|
||||
let filesystemFiles: string[] = [];
|
||||
let findFailed = false;
|
||||
try {
|
||||
const findOutput = $(
|
||||
"find",
|
||||
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
|
||||
{ log: false }
|
||||
);
|
||||
filesystemFiles = findOutput
|
||||
.split("\n")
|
||||
.filter((f) => f.trim() !== "")
|
||||
.map((f) => {
|
||||
const trimmed = f.trim();
|
||||
// normalize to relative paths for comparison
|
||||
try {
|
||||
return relative(cwd, resolve(cwd, trimmed));
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// find might fail, that's ok - we'll just use git files
|
||||
findFailed = true;
|
||||
}
|
||||
|
||||
// if both methods failed, throw an error (execute helper will handle it)
|
||||
if (gitFailed && findFailed) {
|
||||
throw new Error(
|
||||
`Failed to list files: both git ls-files and find commands failed. ` +
|
||||
`Path: ${pathStr}, working directory: ${cwd}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create a Set of git files for efficient lookup
|
||||
const gitFilesSet = new Set(gitFiles);
|
||||
|
||||
// Combine both lists, removing duplicates
|
||||
const allFiles = [...new Set([...gitFiles, ...filesystemFiles])].sort();
|
||||
|
||||
// Calculate actual untracked count (files in filesystem but not in git)
|
||||
const untrackedFiles = filesystemFiles.filter((f) => !gitFilesSet.has(f));
|
||||
const untrackedCount = untrackedFiles.length;
|
||||
|
||||
return {
|
||||
files: allFiles,
|
||||
method: "combined",
|
||||
trackedCount: gitFiles.length,
|
||||
untrackedCount,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -28,11 +28,7 @@ export function GetIssueCommentsTool(ctx: ToolContext) {
|
||||
id: comment.id,
|
||||
body: comment.body,
|
||||
user: comment.user?.login,
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
html_url: comment.html_url,
|
||||
author_association: comment.author_association,
|
||||
reactions: comment.reactions,
|
||||
})),
|
||||
count: comments.length,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
||||
return `${bodyWithoutFooter}${footer}`;
|
||||
}
|
||||
|
||||
export function PullRequestTool(ctx: ToolContext) {
|
||||
export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "create_pull_request",
|
||||
description: "Create a pull request from the current branch",
|
||||
|
||||
+137
-129
@@ -6,16 +6,149 @@ import { log } from "../utils/cli.ts";
|
||||
import { deleteProgressComment } from "./comment.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
// one-shot review tool
|
||||
export const CreatePullRequestReview = type({
|
||||
pull_number: type.number.describe("The pull request number to review"),
|
||||
body: type.string
|
||||
.describe(
|
||||
"1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
|
||||
)
|
||||
.optional(),
|
||||
commit_id: type.string
|
||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||
.optional(),
|
||||
comments: type({
|
||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
||||
line: type.number.describe(
|
||||
"Line number from the diff. Each code line shows 'OLD | NEW | TYPE | CODE'. Use the NEW column (second column)."
|
||||
),
|
||||
side: type
|
||||
.enumerated("LEFT", "RIGHT")
|
||||
.describe(
|
||||
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
|
||||
)
|
||||
.optional(),
|
||||
body: type.string.describe(
|
||||
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
|
||||
),
|
||||
start_line: type.number
|
||||
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
||||
.optional(),
|
||||
})
|
||||
.array()
|
||||
.describe(
|
||||
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "create_pull_request_review",
|
||||
description:
|
||||
"Submit a review for an existing pull request. " +
|
||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
||||
parameters: CreatePullRequestReview,
|
||||
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
||||
// set PR context
|
||||
ctx.toolState.prNumber = pull_number;
|
||||
|
||||
// get the PR to determine the head commit if commit_id not provided
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
// compose the request
|
||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
event: "COMMENT",
|
||||
};
|
||||
if (body) params.body = body;
|
||||
if (commit_id) {
|
||||
params.commit_id = commit_id;
|
||||
} else {
|
||||
params.commit_id = pr.data.head.sha;
|
||||
}
|
||||
if (comments.length > 0) {
|
||||
type ReviewComment = (typeof params.comments & {})[number];
|
||||
// convert comments to the format expected by GitHub API
|
||||
params.comments = comments.map((comment) => {
|
||||
const reviewComment: ReviewComment = {
|
||||
...comment,
|
||||
};
|
||||
reviewComment.side = comment.side || "RIGHT";
|
||||
if (comment.start_line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.start_side = comment.side || "RIGHT";
|
||||
}
|
||||
return reviewComment;
|
||||
});
|
||||
}
|
||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
||||
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||
if (!result.data.id) {
|
||||
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
|
||||
}
|
||||
const reviewId = result.data.id;
|
||||
|
||||
// build quick links footer and update the review body
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||
|
||||
const footer = buildPullfrogFooter({
|
||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
||||
});
|
||||
|
||||
const updatedBody = (body || "") + footer;
|
||||
|
||||
// update the review with the footer
|
||||
await ctx.octokit.rest.pulls.updateReview({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
review_id: reviewId,
|
||||
body: updatedBody,
|
||||
});
|
||||
|
||||
await deleteProgressComment(ctx);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reviewId,
|
||||
html_url: result.data.html_url,
|
||||
state: result.data.state,
|
||||
user: result.data.user?.login,
|
||||
submitted_at: result.data.submitted_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMMENTED OUT: Three-step review flow (start_review, add_review_comment, submit_review)
|
||||
// This approach used GraphQL to add comments to a pending review one-by-one,
|
||||
// but GitHub's API was returning null for valid lines. Keeping for reference.
|
||||
// =============================================================================
|
||||
|
||||
/*
|
||||
// graphql mutation to add a comment thread to a pending review
|
||||
// note: REST API doesn't support adding comments to an existing pending review
|
||||
const ADD_PULL_REQUEST_REVIEW_THREAD = `
|
||||
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide) {
|
||||
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide, $subjectType: PullRequestReviewThreadSubjectType) {
|
||||
addPullRequestReviewThread(input: {
|
||||
pullRequestReviewId: $pullRequestReviewId,
|
||||
path: $path,
|
||||
line: $line,
|
||||
body: $body,
|
||||
side: $side
|
||||
side: $side,
|
||||
subjectType: $subjectType
|
||||
}) {
|
||||
thread {
|
||||
id
|
||||
@@ -180,6 +313,7 @@ export function AddReviewCommentTool(ctx: ToolContext) {
|
||||
line,
|
||||
body,
|
||||
side: side || "RIGHT",
|
||||
subjectType: "LINE",
|
||||
}
|
||||
);
|
||||
log.debug(`addPullRequestReviewThread response: ${JSON.stringify(result)}`);
|
||||
@@ -293,130 +427,4 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// legacy tool - kept for backwards compatibility
|
||||
export const Review = type({
|
||||
pull_number: type.number.describe("The pull request number to review"),
|
||||
body: type.string
|
||||
.describe(
|
||||
"1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead."
|
||||
)
|
||||
.optional(),
|
||||
commit_id: type.string
|
||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||
.optional(),
|
||||
comments: type({
|
||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
||||
line: type.number.describe(
|
||||
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
|
||||
),
|
||||
side: type
|
||||
.enumerated("LEFT", "RIGHT")
|
||||
.describe(
|
||||
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
|
||||
)
|
||||
.optional(),
|
||||
body: type.string.describe(
|
||||
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
|
||||
),
|
||||
start_line: type.number
|
||||
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
||||
.optional(),
|
||||
})
|
||||
.array()
|
||||
.describe(
|
||||
// FORK PR NOTE: checkout_pr returns the diff via GitHub API - use that for line numbers
|
||||
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use the diff returned from checkout_pr to find correct line numbers (RIGHT side for new code, LEFT for old)."
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function ReviewTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "submit_pull_request_review",
|
||||
description:
|
||||
"DEPRECATED: Use start_review, add_review_comment, and submit_review instead for iterative review workflow. " +
|
||||
"Submit a review for an existing pull request. " +
|
||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
||||
parameters: Review,
|
||||
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
||||
// set PR context
|
||||
ctx.toolState.prNumber = pull_number;
|
||||
|
||||
// get the PR to determine the head commit if commit_id not provided
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
// compose the request
|
||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
event: "COMMENT",
|
||||
};
|
||||
if (body) params.body = body;
|
||||
if (commit_id) {
|
||||
params.commit_id = commit_id;
|
||||
} else {
|
||||
params.commit_id = pr.data.head.sha;
|
||||
}
|
||||
if (comments.length > 0) {
|
||||
type ReviewComment = (typeof params.comments & {})[number];
|
||||
// convert comments to the format expected by GitHub API
|
||||
params.comments = comments.map((comment) => {
|
||||
const reviewComment: ReviewComment = {
|
||||
...comment,
|
||||
};
|
||||
reviewComment.side = comment.side || "RIGHT";
|
||||
if (comment.start_line) {
|
||||
reviewComment.start_line = comment.start_line;
|
||||
reviewComment.start_side = comment.side || "RIGHT";
|
||||
}
|
||||
return reviewComment;
|
||||
});
|
||||
}
|
||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
||||
log.debug(`createReview (legacy) response: ${JSON.stringify(result.data)}`);
|
||||
if (!result.data.id) {
|
||||
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
|
||||
}
|
||||
const reviewId = result.data.id;
|
||||
|
||||
// build quick links footer and update the review body
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||
|
||||
const footer = buildPullfrogFooter({
|
||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
||||
});
|
||||
|
||||
const updatedBody = (body || "") + footer;
|
||||
|
||||
// update the review with the footer
|
||||
await ctx.octokit.rest.pulls.updateReview({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
pull_number,
|
||||
review_id: reviewId,
|
||||
body: updatedBody,
|
||||
});
|
||||
|
||||
await deleteProgressComment(ctx);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reviewId,
|
||||
html_url: result.data.html_url,
|
||||
state: result.data.state,
|
||||
user: result.data.user?.login,
|
||||
submitted_at: result.data.submitted_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
+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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ export function SelectModeTool(ctx: ToolContext) {
|
||||
};
|
||||
}
|
||||
|
||||
// store selected mode in toolState for use by other tools (e.g., report_progress)
|
||||
ctx.toolState.selectedMode = selectedMode.name;
|
||||
|
||||
return {
|
||||
modeName: selectedMode.name,
|
||||
description: selectedMode.description,
|
||||
|
||||
+8
-11
@@ -17,19 +17,19 @@ import {
|
||||
AwaitDependencyInstallationTool,
|
||||
StartDependencyInstallationTool,
|
||||
} from "./dependencies.ts";
|
||||
import { ListFilesTool } from "./files.ts";
|
||||
import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts";
|
||||
import { IssueTool } from "./issue.ts";
|
||||
import { GetIssueCommentsTool } from "./issueComments.ts";
|
||||
import { GetIssueEventsTool } from "./issueEvents.ts";
|
||||
import { IssueInfoTool } from "./issueInfo.ts";
|
||||
import { AddLabelsTool } from "./labels.ts";
|
||||
import { PullRequestTool } from "./pr.ts";
|
||||
import { CreatePullRequestTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import { AddReviewCommentTool, StartReviewTool, SubmitReviewTool } from "./review.ts";
|
||||
import { CreatePullRequestReviewTool } from "./review.ts";
|
||||
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
||||
import { SelectModeTool } from "./selectMode.ts";
|
||||
import { addTools } from "./shared.ts";
|
||||
import { BashTool } from "./bash.ts";
|
||||
|
||||
/**
|
||||
* Find an available port starting from the given port
|
||||
@@ -65,7 +65,7 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
||||
*/
|
||||
export async function startMcpHttpServer(
|
||||
ctx: ToolContext
|
||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
|
||||
const server = new FastMCP({
|
||||
name: ghPullfrogMcpName,
|
||||
version: "0.0.1",
|
||||
@@ -83,11 +83,8 @@ export async function startMcpHttpServer(
|
||||
IssueInfoTool(ctx),
|
||||
GetIssueCommentsTool(ctx),
|
||||
GetIssueEventsTool(ctx),
|
||||
PullRequestTool(ctx),
|
||||
// ReviewTool(ctx),
|
||||
StartReviewTool(ctx),
|
||||
AddReviewCommentTool(ctx),
|
||||
SubmitReviewTool(ctx),
|
||||
CreatePullRequestTool(ctx),
|
||||
CreatePullRequestReviewTool(ctx),
|
||||
PullRequestInfoTool(ctx),
|
||||
CheckoutPrTool(ctx),
|
||||
GetReviewCommentsTool(ctx),
|
||||
@@ -98,7 +95,7 @@ export async function startMcpHttpServer(
|
||||
CreateBranchTool(ctx),
|
||||
CommitFilesTool(ctx),
|
||||
PushBranchTool(ctx),
|
||||
ListFilesTool(ctx),
|
||||
BashTool(ctx),
|
||||
];
|
||||
|
||||
if (!ctx.payload.disableProgressComment) {
|
||||
@@ -124,7 +121,7 @@ export async function startMcpHttpServer(
|
||||
|
||||
return {
|
||||
url,
|
||||
close: async () => {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await server.stop();
|
||||
},
|
||||
};
|
||||
|
||||
+15
-8
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
|
||||
@@ -12,14 +14,10 @@ export interface ToolResult {
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
||||
export const handleToolSuccess = (data: Record<string, any> | string): ToolResult => {
|
||||
const text = typeof data === "string" ? data : toonEncode(data);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,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>>) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export function getModes({ disableProgressComment }: GetModesParams): Mode[] {
|
||||
name: "Build",
|
||||
description:
|
||||
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
|
||||
prompt: `Follow these steps:
|
||||
prompt: `Follow these steps. THINK HARDER.
|
||||
1. If this is a PR event, the PR branch is already checked out - skip branch creation. Otherwise, create a branch using ${ghPullfrogMcpName}/create_branch. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit directly to main, master, or production. Do NOT use git commands directly (including \`git branch\`, \`git status\`, \`git log\`) - always use ${ghPullfrogMcpName} MCP tools for git operations.
|
||||
|
||||
${dependencyInstallationGuidance}
|
||||
@@ -68,7 +68,7 @@ ${dependencyInstallationGuidance}
|
||||
name: "Address Reviews",
|
||||
description:
|
||||
"Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
|
||||
prompt: `Follow these steps:
|
||||
prompt: `Follow these steps. THINK HARDER.
|
||||
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs).
|
||||
|
||||
${dependencyInstallationGuidance}
|
||||
@@ -99,43 +99,35 @@ ${
|
||||
name: "Review",
|
||||
description:
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `Follow these steps:
|
||||
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and returns the PR diff in the \`diff\` field of the response. Use this diff for your review - it shows exactly what's in the PR (fetched via GitHub API, so it's not affected by main advancing after the branch was created).
|
||||
prompt: `Follow these steps to review the PR. Think hard. Do not nitpick.
|
||||
|
||||
2. Start review session using ${ghPullfrogMcpName}/start_review. This creates a pending review on GitHub and returns analysis guidance. Follow the guidance before adding comments.
|
||||
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This should give you all PR metadata you need, including a \`diffPath\`: a path to a temp file containing the PR diff.
|
||||
|
||||
3. **ANALYZE** - Before adding any comments, think through:
|
||||
- What does this PR change? Summarize in 1-2 sentences.
|
||||
- Is the approach sound? If not, **stop here** and comment on the approach first. Don't waste time on implementation details if the approach is wrong.
|
||||
- What bugs, edge cases, or security issues exist?
|
||||
|
||||
4. **BEFORE COMMENTING** - For each potential comment, ask yourself:
|
||||
- Is this a nitpick? Skip it unless explicitly requested.
|
||||
- Would the codebase maintainer care about this feedback, based on what you can infer about the code quality standards in this repo?
|
||||
2. **ANALYZE**
|
||||
- Read the modified files to understand the changes in context. Make sure you understand what's being changed.
|
||||
- Is it a good idea? Think about the tradeoffs.
|
||||
- Is the approach sound? If not, focus on the approach first. Don't waste time on implementation details if the approach is wrong.
|
||||
- Can you imagine a better approach? If so, explain. Make sure it's strictly better, not just different.
|
||||
- Are there bugs, edge cases, security issues, or usability issues? Use your imagination.
|
||||
|
||||
5. Add inline review comments one-by-one using ${ghPullfrogMcpName}/add_review_comment
|
||||
- Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`)
|
||||
- Use the NEW file line number from the diff (shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12)
|
||||
- Only comment on lines that appear in the diff. GitHub will reject comments on unchanged lines.
|
||||
- For issues appearing in multiple places, comment on the FIRST occurrence and reference others (e.g., "also at lines X, Y")
|
||||
3. **DRAFT** - For each inline comment, find the line in the diff. Each code line shows: \`| OLD | NEW | TYPE | CODE\`. Use the NEW line number (second column).
|
||||
|
||||
6. Submit the review using ${ghPullfrogMcpName}/submit_review
|
||||
- The "body" field is ONLY for: (1) a 1-3 sentence high-level overview, (2) urgency level (e.g., "minor suggestions" vs "blocking issues"), (3) critical security callouts (e.g., API key exposure)
|
||||
4. **FILTER COMMENTS** - Do not nitpick! Do not leave compliments that are not actionable. Do not critique the code hygiene or anything stylistic.
|
||||
|
||||
**GENERAL GUIDANCE**
|
||||
5. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review with:
|
||||
- \`comments\`: Array of all inline comments with file paths and line numbers
|
||||
- \`body\`: Everything else. Aim for a 1-3 sentence summary of the urgency level (e.g., "minor suggestions" vs "blocking issues") and any critical callouts (e.g., API key exposure). It can be longer if there are concerns that do not lend themselves to inline comments.
|
||||
- If you have no substantive feedback, submit an empty comments array with a brief approving body.
|
||||
- Again, do not nitpick.
|
||||
|
||||
- Do not leave any comments that are not potentially actionable. Do not leave complimentary comments just to be nice.
|
||||
- Do not nitpick unless instructed explicitly to do so by the user's additional instructions. This includes: requesting documentation/docstrings/JSDoc.
|
||||
- **CRITICAL: Prioritize per-line feedback over summary text.**
|
||||
- All specific feedback MUST go in inline review comments with file paths and line numbers from the diff
|
||||
- The vast majority of review content should be in inline review comments; the body should be brief and only summarize the urgency of the review and any cross-cutting concerns.
|
||||
`,
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Plan",
|
||||
description:
|
||||
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
|
||||
prompt: `Follow these steps:
|
||||
prompt: `Follow these steps. THINK HARDER.
|
||||
1. If the request requires understanding the codebase structure or conventions, gather relevant context (read AGENTS.md if it exists). Skip this step if the prompt is trivial and self-contained.
|
||||
|
||||
2. Analyze the request and break it down into clear, actionable tasks
|
||||
@@ -148,7 +140,7 @@ ${
|
||||
name: "Prompt",
|
||||
description:
|
||||
"Fallback for tasks that don't fit other workflows, e.g. direct prompts via comments, or requests requiring general assistance",
|
||||
prompt: `Follow these steps:
|
||||
prompt: `Follow these steps. THINK HARDER.
|
||||
1. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.${disableProgressComment ? "" : "\n\n2. When creating comments, always use report_progress. Do not use create_issue_comment."}
|
||||
|
||||
2. If the task involves making code changes:
|
||||
|
||||
Generated
-960
@@ -1,960 +0,0 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.96",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.96",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.26",
|
||||
"@ark/fs": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"arktype": "2.1.25",
|
||||
"convex": "^1.29.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"execa": "^9.6.0",
|
||||
"fastmcp": "^3.20.0",
|
||||
"table": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"husky": "^9.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core": {
|
||||
"version": "1.11.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/http-client": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.18.112"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.1.26_zod@3.25.76/node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.1.26",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "^0.33.5",
|
||||
"@img/sharp-darwin-x64": "^0.33.5",
|
||||
"@img/sharp-linux-arm": "^0.33.5",
|
||||
"@img/sharp-linux-arm64": "^0.33.5",
|
||||
"@img/sharp-linux-x64": "^0.33.5",
|
||||
"@img/sharp-win32-x64": "^0.33.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.1"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@ark+fs@0.53.0/node_modules/@ark/fs": {
|
||||
"version": "0.53.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"../node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util": {
|
||||
"version": "0.53.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"../node_modules/.pnpm/@octokit+rest@22.0.0/node_modules/@octokit/rest": {
|
||||
"version": "22.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/core": "^7.0.2",
|
||||
"@octokit/plugin-paginate-rest": "^13.0.1",
|
||||
"@octokit/plugin-request-log": "^6.0.0",
|
||||
"@octokit/plugin-rest-endpoint-methods": "^16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/auth-action": "^6.0.1",
|
||||
"@octokit/auth-app": "^8.0.1",
|
||||
"@octokit/fixtures-server": "^8.1.0",
|
||||
"@octokit/request": "^10.0.0",
|
||||
"@octokit/tsconfig": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"fetch-mock": "^12.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"nock": "^14.0.0-beta.8",
|
||||
"prettier": "^3.2.4",
|
||||
"semantic-release-plugin-update-version-in-files": "^2.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"undici": "^6.4.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@octokit+webhooks-types@7.6.1/node_modules/@octokit/webhooks-types": {
|
||||
"version": "7.6.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {}
|
||||
},
|
||||
"../node_modules/.pnpm/@standard-schema+spec@1.0.0/node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@types+node@24.7.2/node_modules/@types/node": {
|
||||
"version": "24.7.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/arg@5.0.2/node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"chai": "^4.1.1",
|
||||
"jest": "^27.0.6",
|
||||
"prettier": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/arktype@2.1.25/node_modules/arktype": {
|
||||
"version": "2.1.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ark/schema": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"arkregex": "0.0.2"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/dotenv@17.2.3/node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"license": "BSD-2-Clause",
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.11.3",
|
||||
"decache": "^4.6.2",
|
||||
"sinon": "^14.0.1",
|
||||
"standard": "^17.0.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"tap": "^19.2.0",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/esbuild@0.25.10/node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.10",
|
||||
"@esbuild/android-arm": "0.25.10",
|
||||
"@esbuild/android-arm64": "0.25.10",
|
||||
"@esbuild/android-x64": "0.25.10",
|
||||
"@esbuild/darwin-arm64": "0.25.10",
|
||||
"@esbuild/darwin-x64": "0.25.10",
|
||||
"@esbuild/freebsd-arm64": "0.25.10",
|
||||
"@esbuild/freebsd-x64": "0.25.10",
|
||||
"@esbuild/linux-arm": "0.25.10",
|
||||
"@esbuild/linux-arm64": "0.25.10",
|
||||
"@esbuild/linux-ia32": "0.25.10",
|
||||
"@esbuild/linux-loong64": "0.25.10",
|
||||
"@esbuild/linux-mips64el": "0.25.10",
|
||||
"@esbuild/linux-ppc64": "0.25.10",
|
||||
"@esbuild/linux-riscv64": "0.25.10",
|
||||
"@esbuild/linux-s390x": "0.25.10",
|
||||
"@esbuild/linux-x64": "0.25.10",
|
||||
"@esbuild/netbsd-arm64": "0.25.10",
|
||||
"@esbuild/netbsd-x64": "0.25.10",
|
||||
"@esbuild/openbsd-arm64": "0.25.10",
|
||||
"@esbuild/openbsd-x64": "0.25.10",
|
||||
"@esbuild/openharmony-arm64": "0.25.10",
|
||||
"@esbuild/sunos-x64": "0.25.10",
|
||||
"@esbuild/win32-arm64": "0.25.10",
|
||||
"@esbuild/win32-ia32": "0.25.10",
|
||||
"@esbuild/win32-x64": "0.25.10"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/execa@9.6.0/node_modules/execa": {
|
||||
"version": "9.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"figures": "^6.1.0",
|
||||
"get-stream": "^9.0.0",
|
||||
"human-signals": "^8.0.1",
|
||||
"is-plain-obj": "^4.1.0",
|
||||
"is-stream": "^4.0.1",
|
||||
"npm-run-path": "^6.0.0",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"strip-final-newline": "^4.0.0",
|
||||
"yoctocolors": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.21",
|
||||
"ava": "^6.3.0",
|
||||
"c8": "^10.1.3",
|
||||
"get-node": "^15.0.3",
|
||||
"is-in-ci": "^1.0.0",
|
||||
"is-running": "^2.1.0",
|
||||
"log-process-errors": "^12.0.1",
|
||||
"path-exists": "^5.0.0",
|
||||
"path-key": "^4.0.0",
|
||||
"tempfile": "^5.0.0",
|
||||
"tsd": "^0.32.0",
|
||||
"typescript": "^5.8.3",
|
||||
"which": "^5.0.0",
|
||||
"xo": "^0.60.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/fastmcp@3.20.0_arktype@2.1.25_effect@3.16.12/node_modules/fastmcp": {
|
||||
"version": "3.20.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.2",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"execa": "^9.6.0",
|
||||
"file-type": "^21.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"mcp-proxy": "^5.8.1",
|
||||
"strict-event-emitter-types": "^2.0.0",
|
||||
"undici": "^7.13.0",
|
||||
"uri-templates": "^0.2.0",
|
||||
"xsschema": "0.3.5",
|
||||
"yargs": "^18.0.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
},
|
||||
"bin": {
|
||||
"fastmcp": "dist/bin/fastmcp.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@modelcontextprotocol/inspector": "^0.16.2",
|
||||
"@sebbo2002/semantic-release-jsr": "^3.0.1",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/uri-templates": "^0.1.34",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@valibot/to-json-schema": "^1.3.0",
|
||||
"@wong2/mcp-cli": "^1.13.0",
|
||||
"arktype": "^2.1.20",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-perfectionist": "^4.15.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eventsource-client": "^1.1.4",
|
||||
"get-port-please": "^3.2.0",
|
||||
"jiti": "^2.5.1",
|
||||
"jsr": "^0.13.5",
|
||||
"prettier": "^3.6.2",
|
||||
"semantic-release": "^24.2.7",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.0",
|
||||
"valibot": "^1.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/husky@9.1.7/node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/table@6.9.0/node_modules/table": {
|
||||
"version": "6.9.0",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.1",
|
||||
"lodash.truncate": "^4.4.2",
|
||||
"slice-ansi": "^4.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.16",
|
||||
"@types/lodash.mapvalues": "^4.6.6",
|
||||
"@types/lodash.truncate": "^4.4.6",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/sinon": "^10.0.0",
|
||||
"@types/slice-ansi": "^4.0.0",
|
||||
"ajv-cli": "^5.0.0",
|
||||
"ajv-keywords": "^5.0.0",
|
||||
"chai": "^4.2.0",
|
||||
"chalk": "^4.1.0",
|
||||
"coveralls": "^3.1.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-canonical": "^25.0.0",
|
||||
"gitdown": "^3.1.4",
|
||||
"husky": "^4.3.6",
|
||||
"js-beautify": "^1.14.0",
|
||||
"lodash.mapvalues": "^4.6.0",
|
||||
"mkdirp": "^1.0.4",
|
||||
"mocha": "^8.2.1",
|
||||
"nyc": "^15.1.0",
|
||||
"semantic-release": "^17.3.1",
|
||||
"sinon": "^12.0.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "4.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dprint/formatter": "^0.4.1",
|
||||
"@dprint/typescript": "0.93.4",
|
||||
"@esfx/canceltoken": "^1.0.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@types/chai": "^4.3.20",
|
||||
"@types/diff": "^7.0.1",
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node": "latest",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/which": "^3.0.4",
|
||||
"@typescript-eslint/rule-tester": "^8.24.1",
|
||||
"@typescript-eslint/type-utils": "^8.24.1",
|
||||
"@typescript-eslint/utils": "^8.24.1",
|
||||
"azure-devops-node-api": "^14.1.0",
|
||||
"c8": "^10.1.3",
|
||||
"chai": "^4.5.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dprint": "^0.49.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-formatter-autolinkable-stylish": "^1.4.0",
|
||||
"eslint-plugin-regexp": "^2.7.0",
|
||||
"fast-xml-parser": "^4.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"globals": "^15.15.0",
|
||||
"hereby": "^1.10.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knip": "^5.44.4",
|
||||
"minimist": "^1.2.8",
|
||||
"mocha": "^10.8.2",
|
||||
"mocha-fivemat-progress-reporter": "^0.1.0",
|
||||
"monocart-coverage-reports": "^2.12.1",
|
||||
"ms": "^2.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"playwright": "^1.50.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"which": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"resolved": "../node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"resolved": "../node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.1.26_zod@3.25.76/node_modules/@anthropic-ai/claude-agent-sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ark/fs": {
|
||||
"resolved": "../node_modules/.pnpm/@ark+fs@0.53.0/node_modules/@ark/fs",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ark/util": {
|
||||
"resolved": "../node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
|
||||
"integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
|
||||
"integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
|
||||
"integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
|
||||
"integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
|
||||
"integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
|
||||
"integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
|
||||
"integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
|
||||
"integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
|
||||
"integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
|
||||
"integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
|
||||
"integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
|
||||
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest": {
|
||||
"resolved": "../node_modules/.pnpm/@octokit+rest@22.0.0/node_modules/@octokit/rest",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@octokit/webhooks-types": {
|
||||
"resolved": "../node_modules/.pnpm/@octokit+webhooks-types@7.6.1/node_modules/@octokit/webhooks-types",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"resolved": "../node_modules/.pnpm/@standard-schema+spec@1.0.0/node_modules/@standard-schema/spec",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"resolved": "../node_modules/.pnpm/@types+node@24.7.2/node_modules/@types/node",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"resolved": "../node_modules/.pnpm/arg@5.0.2/node_modules/arg",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/arktype": {
|
||||
"resolved": "../node_modules/.pnpm/arktype@2.1.25/node_modules/arktype",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/convex": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/convex/-/convex-1.29.0.tgz",
|
||||
"integrity": "sha512-uoIPXRKIp2eLCkkR9WJ2vc9NtgQtx8Pml59WPUahwbrd5EuW2WLI/cf2E7XrUzOSifdQC3kJZepisk4wJNTJaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esbuild": "0.25.4",
|
||||
"prettier": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"convex": "bin/main.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@auth0/auth0-react": "^2.0.1",
|
||||
"@clerk/clerk-react": "^4.12.8 || ^5.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@auth0/auth0-react": {
|
||||
"optional": true
|
||||
},
|
||||
"@clerk/clerk-react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/convex/node_modules/esbuild": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
||||
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.4",
|
||||
"@esbuild/android-arm": "0.25.4",
|
||||
"@esbuild/android-arm64": "0.25.4",
|
||||
"@esbuild/android-x64": "0.25.4",
|
||||
"@esbuild/darwin-arm64": "0.25.4",
|
||||
"@esbuild/darwin-x64": "0.25.4",
|
||||
"@esbuild/freebsd-arm64": "0.25.4",
|
||||
"@esbuild/freebsd-x64": "0.25.4",
|
||||
"@esbuild/linux-arm": "0.25.4",
|
||||
"@esbuild/linux-arm64": "0.25.4",
|
||||
"@esbuild/linux-ia32": "0.25.4",
|
||||
"@esbuild/linux-loong64": "0.25.4",
|
||||
"@esbuild/linux-mips64el": "0.25.4",
|
||||
"@esbuild/linux-ppc64": "0.25.4",
|
||||
"@esbuild/linux-riscv64": "0.25.4",
|
||||
"@esbuild/linux-s390x": "0.25.4",
|
||||
"@esbuild/linux-x64": "0.25.4",
|
||||
"@esbuild/netbsd-arm64": "0.25.4",
|
||||
"@esbuild/netbsd-x64": "0.25.4",
|
||||
"@esbuild/openbsd-arm64": "0.25.4",
|
||||
"@esbuild/openbsd-x64": "0.25.4",
|
||||
"@esbuild/sunos-x64": "0.25.4",
|
||||
"@esbuild/win32-arm64": "0.25.4",
|
||||
"@esbuild/win32-ia32": "0.25.4",
|
||||
"@esbuild/win32-x64": "0.25.4"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"resolved": "../node_modules/.pnpm/dotenv@17.2.3/node_modules/dotenv",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"resolved": "../node_modules/.pnpm/esbuild@0.25.10/node_modules/esbuild",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"resolved": "../node_modules/.pnpm/execa@9.6.0/node_modules/execa",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/fastmcp": {
|
||||
"resolved": "../node_modules/.pnpm/fastmcp@3.20.0_arktype@2.1.25_effect@3.16.12/node_modules/fastmcp",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"resolved": "../node_modules/.pnpm/husky@9.1.7/node_modules/husky",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/table": {
|
||||
"resolved": "../node_modules/.pnpm/table@6.9.0/node_modules/table",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"resolved": "../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript",
|
||||
"link": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.152",
|
||||
"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",
|
||||
@@ -25,19 +25,21 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.37",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.77",
|
||||
"@ark/fs": "0.53.0",
|
||||
"@ark/util": "0.53.0",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"@openai/codex-sdk": "0.58.0",
|
||||
"@opencode-ai/sdk": "^1.0.143",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@toon-format/toon": "^1.0.0",
|
||||
"arktype": "2.1.28",
|
||||
"package-manager-detector": "^1.6.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"execa": "^9.6.0",
|
||||
"fastmcp": "^3.20.0",
|
||||
"fastmcp": "^3.26.8",
|
||||
"package-manager-detector": "^1.6.0",
|
||||
"table": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -45,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",
|
||||
@@ -70,5 +73,6 @@
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
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";
|
||||
@@ -24,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);
|
||||
|
||||
@@ -67,7 +49,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = arg({
|
||||
"--help": Boolean,
|
||||
"--raw": String,
|
||||
"--local": Boolean,
|
||||
"-h": "--help",
|
||||
"-l": "--local",
|
||||
});
|
||||
|
||||
if (args["--help"]) {
|
||||
@@ -81,18 +65,85 @@ Arguments:
|
||||
|
||||
Options:
|
||||
--raw [prompt] Use raw string as prompt instead of loading from file
|
||||
--local, -l Run locally on macOS (default: runs in Docker)
|
||||
-h, --help Show this help message
|
||||
|
||||
Environment:
|
||||
PLAY_LOCAL=1 Same as --local
|
||||
|
||||
Examples:
|
||||
tsx play.ts # Use default fixture
|
||||
tsx play.ts fixtures/basic.txt # Use specific text file
|
||||
tsx play.ts custom.json # Use JSON file
|
||||
tsx play.ts fixtures/test.ts # Use TypeScript file
|
||||
tsx play.ts bash-test.ts # Run in Docker (default)
|
||||
tsx play.ts --local bash-test.ts # Run locally on macOS
|
||||
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// default: run in Docker (unless --local or PLAY_LOCAL=1 or already inside Docker)
|
||||
const isInsideDocker = existsSync("/.dockerenv");
|
||||
const useLocal = args["--local"] || process.env.PLAY_LOCAL === "1" || isInsideDocker;
|
||||
|
||||
if (!useLocal) {
|
||||
log.info("» running in Docker container...");
|
||||
|
||||
const passArgs = process.argv.slice(2);
|
||||
const nodeCmd = `node play.ts ${passArgs.join(" ")}`;
|
||||
|
||||
// pass all env vars to docker
|
||||
const envFlags = Object.entries(process.env).flatMap(([key, value]) =>
|
||||
value !== undefined ? ["-e", `${key}=${value}`] : []
|
||||
);
|
||||
|
||||
// SSH for git - mount individual SSH files to avoid permission issues
|
||||
const sshFlags: string[] = [];
|
||||
const home = process.env.HOME;
|
||||
if (home) {
|
||||
const sshDir = join(home, ".ssh");
|
||||
// mount SSH keys (try common key names)
|
||||
for (const keyName of ["id_rsa", "id_ed25519", "id_ecdsa"]) {
|
||||
const keyPath = join(sshDir, keyName);
|
||||
if (existsSync(keyPath)) {
|
||||
sshFlags.push("-v", `${keyPath}:/root/.ssh/${keyName}:ro`);
|
||||
}
|
||||
}
|
||||
// mount known_hosts
|
||||
const knownHostsPath = join(sshDir, "known_hosts");
|
||||
if (existsSync(knownHostsPath)) {
|
||||
sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`);
|
||||
}
|
||||
}
|
||||
|
||||
const ttyFlags = process.stdin.isTTY ? ["-it"] : [];
|
||||
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
...ttyFlags,
|
||||
"-v",
|
||||
`${process.cwd()}:/app/action:cached`,
|
||||
"-v",
|
||||
"pullfrog-action-node-modules:/app/action/node_modules",
|
||||
"-w",
|
||||
"/app/action",
|
||||
...envFlags,
|
||||
...sshFlags,
|
||||
"--cap-add",
|
||||
"SYS_ADMIN",
|
||||
"--security-opt",
|
||||
"seccomp:unconfined",
|
||||
"node:24",
|
||||
"bash",
|
||||
"-c",
|
||||
`corepack enable pnpm >/dev/null 2>&1 && pnpm install --frozen-lockfile && ${nodeCmd}`,
|
||||
],
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
let prompt: string;
|
||||
|
||||
if (args["--raw"]) {
|
||||
@@ -135,10 +186,35 @@ Examples:
|
||||
|
||||
if (typeof module.default === "string") {
|
||||
prompt = module.default;
|
||||
} else if (typeof module.default === "object" && module.default.prompt) {
|
||||
prompt = module.default.prompt;
|
||||
} else {
|
||||
} 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);
|
||||
} else {
|
||||
throw new Error(`Unsupported default export type: ${typeof module.default}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Generated
+1071
-81
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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { fetchWorkflowRunInfo } from "./api.ts";
|
||||
import { getGitHubInstallationToken, parseRepoContext } from "./github.ts";
|
||||
import { createOctokit, getGitHubInstallationToken, parseRepoContext } from "./github.ts";
|
||||
|
||||
/**
|
||||
* Get progress comment ID from environment variable or database.
|
||||
@@ -55,8 +54,7 @@ export async function reportErrorToComment({
|
||||
|
||||
// update comment directly using GitHub API
|
||||
const repoContext = parseRepoContext();
|
||||
const token = getGitHubInstallationToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
const octokit = createOctokit(getGitHubInstallationToken());
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
|
||||
+58
-21
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createSign } from "node:crypto";
|
||||
import * as core from "@actions/core";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { log } from "./cli.ts";
|
||||
import { retry } from "./retry.ts";
|
||||
|
||||
@@ -44,16 +47,24 @@ interface RepositoriesResponse {
|
||||
repositories: Repository[];
|
||||
}
|
||||
|
||||
function isGitHubActionsEnvironment(): boolean {
|
||||
return Boolean(process.env.GITHUB_ACTIONS);
|
||||
function isOIDCAvailable(): boolean {
|
||||
// OIDC requires both env vars to be set (only in real GitHub Actions with id-token permission)
|
||||
return Boolean(
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
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...");
|
||||
|
||||
@@ -62,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}`,
|
||||
@@ -78,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) {
|
||||
@@ -232,9 +247,9 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function acquireNewToken(): Promise<string> {
|
||||
if (isGitHubActionsEnvironment()) {
|
||||
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
||||
export async function acquireNewToken(opts?: { repos?: string[] }): Promise<string> {
|
||||
if (isOIDCAvailable()) {
|
||||
return await retry(() => acquireTokenViaOIDC(opts), { label: "token exchange" });
|
||||
} else {
|
||||
return await acquireTokenViaGitHubApp();
|
||||
}
|
||||
@@ -246,31 +261,32 @@ let githubInstallationToken: string | undefined;
|
||||
/**
|
||||
* Setup GitHub installation token for the action
|
||||
*/
|
||||
export async function setupGitHubInstallationToken(): Promise<string> {
|
||||
export async function setupGitHubInstallationToken() {
|
||||
assert(!githubInstallationToken, "GitHub installation token is already set.");
|
||||
const acquiredToken = await acquireNewToken();
|
||||
core.setSecret(acquiredToken);
|
||||
githubInstallationToken = acquiredToken;
|
||||
return acquiredToken;
|
||||
return {
|
||||
token: acquiredToken,
|
||||
[Symbol.asyncDispose]() {
|
||||
githubInstallationToken = undefined;
|
||||
return revokeGitHubInstallationToken(acquiredToken);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub installation token from memory
|
||||
*/
|
||||
export function getGitHubInstallationToken(): string {
|
||||
if (!githubInstallationToken) {
|
||||
throw new Error("GitHub installation token not set. Call setupGitHubInstallationToken first.");
|
||||
}
|
||||
assert(
|
||||
githubInstallationToken,
|
||||
"GitHub installation token not set. Call setupGitHubInstallationToken first."
|
||||
);
|
||||
return githubInstallationToken;
|
||||
}
|
||||
|
||||
export async function revokeGitHubInstallationToken(): Promise<void> {
|
||||
if (!githubInstallationToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = githubInstallationToken;
|
||||
githubInstallationToken = undefined;
|
||||
|
||||
export async function revokeGitHubInstallationToken(token: string): Promise<void> {
|
||||
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
|
||||
try {
|
||||
@@ -311,3 +327,24 @@ export function parseRepoContext(): RepoContext {
|
||||
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
export type OctokitWithPlugins = InstanceType<
|
||||
ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>
|
||||
>;
|
||||
|
||||
export function createOctokit(token: string): OctokitWithPlugins {
|
||||
// `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
|
||||
// we can't use it directly because it's stuck on `@octokit/core@v5` and we use the hottest `@octokit/core@v7`
|
||||
const OctokitWithPlugins = Octokit.plugin(throttling);
|
||||
return new OctokitWithPlugins({
|
||||
auth: token,
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||
return retryCount <= 2;
|
||||
},
|
||||
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||
return retryCount <= 2;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export type RetryOptions = {
|
||||
maxAttempts?: number;
|
||||
delayMs?: number;
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const defaultShouldRetry = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error)) return false;
|
||||
// retry on transient network errors
|
||||
return (
|
||||
error.name === "AbortError" ||
|
||||
error.message.includes("fetch failed") ||
|
||||
error.message.includes("ECONNRESET") ||
|
||||
error.message.includes("ETIMEDOUT")
|
||||
);
|
||||
};
|
||||
|
||||
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
||||
const maxAttempts = options.maxAttempts ?? 3;
|
||||
const delayMs = options.delayMs ?? 1000;
|
||||
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
|
||||
const label = options.label ?? "operation";
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxAttempts || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = delayMs * attempt;
|
||||
log.warning(
|
||||
`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { Payload } from "../external.ts";
|
||||
import type { ToolState } from "../main.ts";
|
||||
import { checkoutPrBranch } from "../mcp/checkout.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import type { OctokitWithPlugins } from "./github.ts";
|
||||
import { $ } from "./shell.ts";
|
||||
|
||||
export interface SetupOptions {
|
||||
@@ -66,7 +66,7 @@ interface SetupGitAuthParams {
|
||||
owner: string;
|
||||
name: string;
|
||||
payload: Payload;
|
||||
octokit: Octokit;
|
||||
octokit: OctokitWithPlugins;
|
||||
toolState: ToolState;
|
||||
}
|
||||
|
||||
|
||||
@@ -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