diff --git a/agents/cursor.ts b/agents/cursor.ts index 14533a3..24127dc 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -21,21 +21,16 @@ export const cursor = agent({ configureCursorMcpServers({ mcpServers, cliPath }); try { - // Run cursor-agent in non-interactive mode with the prompt - // Using -p flag for prompt, --output-format text for plain text output - // and --approve-mcps to automatically approve all MCP servers const fullPrompt = addInstructions(payload); log.info("Running Cursor CLI..."); - // Use spawn to handle streaming output - // Use --print flag explicitly for non-interactive mode return new Promise((resolve) => { const child = spawn( cliPath, ["--print", fullPrompt, "--output-format", "text", "--approve-mcps", "--force"], { - cwd: process.cwd(), // Run in current working directory + cwd: process.cwd(), env: { ...process.env, CURSOR_API_KEY: apiKey, @@ -50,7 +45,6 @@ export const cursor = agent({ let stdout = ""; let stderr = ""; - // Log when process starts child.on("spawn", () => { log.debug("Cursor CLI process spawned"); }); @@ -65,12 +59,10 @@ export const cursor = agent({ child.stderr?.on("data", (data) => { const text = data.toString(); stderr += text; - // Log errors as they come - but also write to stdout so we can see it process.stderr.write(text); log.warning(text); }); - // Handle process exit child.on("close", (code, signal) => { if (signal) { log.warning(`Cursor CLI terminated by signal: ${signal}`); diff --git a/agents/gemini.ts b/agents/gemini.ts index 49d8f3e..ded54d9 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -2,16 +2,16 @@ import { spawnSync } from "node:child_process"; import { log } from "../utils/cli.ts"; import { spawn } from "../utils/subprocess.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, type ConfigureMcpServersParams, installFromNpmTarball } from "./shared.ts"; +import { agent, type ConfigureMcpServersParams, installFromGithub } from "./shared.ts"; export const gemini = agent({ name: "gemini", install: async () => { - return await installFromNpmTarball({ - packageName: "@google/gemini-cli", - version: "latest", - executablePath: "dist/index.js", - installDependencies: true, + return await installFromGithub({ + owner: "google-gemini", + repo: "gemini-cli", + tag: "v0.16.0", + assetName: "gemini.js", }); }, run: async ({ payload, apiKey, mcpServers, githubInstallationToken, cliPath }) => { @@ -31,7 +31,7 @@ export const gemini = agent({ try { const result = await spawn({ cmd: "node", - args: [cliPath, "--yolo", "--output-format", "text", sessionPrompt], + args: [cliPath, "--yolo", "--output-format=text", "-p", sessionPrompt], env: { GEMINI_API_KEY: apiKey, GITHUB_INSTALLATION_TOKEN: githubInstallationToken, @@ -93,7 +93,6 @@ function configureGeminiMcpServers({ mcpServers, cliPath }: ConfigureMcpServersP const addArgs = ["mcp", "add", serverName, command, ...args]; - // Add environment variables as --env flags for (const [key, value] of Object.entries(envVars)) { addArgs.push("--env", `${key}=${value}`); } diff --git a/agents/shared.ts b/agents/shared.ts index 381d052..683dc33 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -1,5 +1,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, createWriteStream, existsSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { pipeline } from "node:stream/promises"; import type { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk"; @@ -54,6 +56,17 @@ export interface InstallFromCurlParams { executableName: string; } +/** + * Parameters for installing from GitHub releases + */ +export interface InstallFromGithubParams { + owner: string; + repo: string; + tag?: string; + assetName?: string; + executablePath?: string; +} + /** * NPM registry response data structure */ @@ -167,6 +180,91 @@ export async function installFromNpmTarball({ return cliPath; } +/** + * Install a CLI tool from GitHub releases + * Downloads the latest release asset from GitHub and returns the path to the executable + * The temp directory will be cleaned up by the OS automatically + */ +export async function installFromGithub({ + owner, + repo, + tag, + assetName, + executablePath, +}: InstallFromGithubParams): Promise { + log.info(`📦 Installing ${owner}/${repo} from GitHub releases...`); + + // fetch release from GitHub API (specific tag or latest) + const releaseUrl = tag + ? `https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}` + : `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + log.info(`Fetching release from ${releaseUrl}...`); + const releaseResponse = await fetch(releaseUrl); + if (!releaseResponse.ok) { + throw new Error( + `Failed to fetch release: ${releaseResponse.status} ${releaseResponse.statusText}` + ); + } + + const releaseData = (await releaseResponse.json()) as { + tag_name: string; + assets: Array<{ + name: string; + browser_download_url: string; + }>; + }; + + log.info(`Found release: ${releaseData.tag_name}`); + + const asset = releaseData.assets.find((a) => a.name === assetName); + if (!asset) { + throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`); + } + const assetUrl = asset.browser_download_url; + + log.info(`Downloading asset from ${assetUrl}...`); + + // create temp directory + const tempDirPrefix = `${owner}-${repo}-github-`; + const tempDir = await mkdtemp(join(tmpdir(), tempDirPrefix)); + + // determine file extension and download path + const urlPath = new URL(assetUrl).pathname; + const fileName = urlPath.split("/").pop() || "asset"; + const downloadPath = join(tempDir, fileName); + + // download the asset + const assetResponse = await fetch(assetUrl); + if (!assetResponse.ok) { + throw new Error( + `Failed to download asset: ${assetResponse.status} ${assetResponse.statusText}` + ); + } + + if (!assetResponse.body) throw new Error("Response body is null"); + const fileStream = createWriteStream(downloadPath); + await pipeline(assetResponse.body, fileStream); + log.info(`Downloaded asset to ${downloadPath}`); + + // determine the executable path + let cliPath: string; + if (executablePath) { + cliPath = join(tempDir, executablePath); + } else { + // no executablePath, assume the downloaded file is the executable + cliPath = downloadPath; + } + + if (!existsSync(cliPath)) { + throw new Error(`Executable not found at ${cliPath}`); + } + + chmodSync(cliPath, 0o755); + log.info(`✓ Installed from GitHub release at ${cliPath}`); + + return cliPath; +} + /** * Install a CLI tool from a curl-based install script * Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable diff --git a/fixtures/basic.txt b/fixtures/basic.txt index 117d20a..482eeb8 100644 --- a/fixtures/basic.txt +++ b/fixtures/basic.txt @@ -1,3 +1 @@ -Print the MCP tools available to you. -Try to call select_mode with the name "Plan". -Then tell me a joke \ No newline at end of file +write a comment to https://github.com/pullfrogai/scratch/pull/29 that tells a joke \ No newline at end of file diff --git a/main.ts b/main.ts index 9127a99..13dac65 100644 --- a/main.ts +++ b/main.ts @@ -135,7 +135,6 @@ To fix this, add the required secret to your GitHub repository: interface MainContext { inputs: Inputs; githubInstallationToken: string; - tokenToRevoke: string | null; repoContext: RepoContext; agentName: AgentNameType; agent: (typeof agents)[AgentNameType]; @@ -155,16 +154,14 @@ async function initializeContext( Inputs.assert(inputs); setupGitConfig(); - const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken(); - const tokenToRevoke = wasAcquired ? githubInstallationToken : null; + const githubInstallationToken = await setupGitHubInstallationToken(); const repoContext = parseRepoContext(); return { inputs, githubInstallationToken, - tokenToRevoke, repoContext, - agentName: "claude" as AgentNameType, + agentName: "claude", agent: agents.claude, sharedTempDir: "", mcpLogPath: "", @@ -292,7 +289,5 @@ async function cleanup(ctx: Omit { // we don't need to extract it here since main() will parse the payload const inputs: Required = { prompt, - defaultAgent: "cursor", + defaultAgent: "gemini", ...flatMorph(agents, (_, agent) => agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]) ), diff --git a/utils/github.ts b/utils/github.ts index 98fb24f..f1649a5 100644 --- a/utils/github.ts +++ b/utils/github.ts @@ -43,12 +43,6 @@ interface RepositoriesResponse { repositories: Repository[]; } -function checkExistingToken(): string | null { - const inputToken = core.getInput("github_installation_token"); - const envToken = process.env.GITHUB_INSTALLATION_TOKEN; - return inputToken || envToken || null; -} - function isGitHubActionsEnvironment(): boolean { return Boolean(process.env.GITHUB_ACTIONS); } @@ -249,24 +243,13 @@ async function acquireNewToken(): Promise { /** * Setup GitHub installation token for the action - * Returns the token and whether it was acquired (needs revocation) */ -export async function setupGitHubInstallationToken(): Promise<{ - githubInstallationToken: string; - wasAcquired: boolean; -}> { - const existingToken = checkExistingToken(); - if (existingToken) { - core.setSecret(existingToken); - log.info("Using provided GitHub installation token"); - return { githubInstallationToken: existingToken, wasAcquired: false }; - } - +export async function setupGitHubInstallationToken(): Promise { const acquiredToken = await acquireNewToken(); core.setSecret(acquiredToken); process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken; - return { githubInstallationToken: acquiredToken, wasAcquired: true }; + return acquiredToken; } /**