diff --git a/agents/gemini.ts b/agents/gemini.ts index 05ae52f..01fbfbe 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -2,17 +2,17 @@ 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", inputKeys: ["google_api_key", "gemini_api_key"], 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 }) => { diff --git a/agents/shared.ts b/agents/shared.ts index 5a36399..8e4285e 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -55,6 +55,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 */ @@ -172,6 +183,140 @@ 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}`); + + // find the asset to download + let assetUrl: string; + if (assetName) { + const asset = releaseData.assets.find((a) => a.name === assetName); + if (!asset) { + throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`); + } + assetUrl = asset.browser_download_url; + } else { + // if no asset name specified, try to find a .js file or use the first asset + const jsAsset = releaseData.assets.find((a) => a.name.endsWith(".js")); + if (!jsAsset) { + if (releaseData.assets.length === 0) { + throw new Error(`No assets found in release ${releaseData.tag_name}`); + } + assetUrl = releaseData.assets[0].browser_download_url; + } else { + assetUrl = jsAsset.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) { + // if executablePath is provided, assume the downloaded file needs to be extracted + // and the executable is inside + if (fileName.endsWith(".tar.gz") || fileName.endsWith(".tgz")) { + // extract tarball + log.info(`Extracting tarball...`); + const extractResult = spawnSync("tar", ["-xzf", downloadPath, "-C", tempDir], { + stdio: "pipe", + encoding: "utf-8", + }); + if (extractResult.status !== 0) { + throw new Error( + `Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}` + ); + } + // find the extracted directory (usually named after the repo) + const extractedDir = join(tempDir, repo); + cliPath = join(extractedDir, executablePath); + } else if (fileName.endsWith(".zip")) { + // extract zip + log.info(`Extracting zip...`); + const extractResult = spawnSync("unzip", ["-q", downloadPath, "-d", tempDir], { + stdio: "pipe", + encoding: "utf-8", + }); + if (extractResult.status !== 0) { + throw new Error( + `Failed to extract zip: ${extractResult.stderr || extractResult.stdout || "Unknown error"}` + ); + } + const extractedDir = join(tempDir, repo); + cliPath = join(extractedDir, executablePath); + } else { + // assume it's a single file, executablePath is relative to temp dir + 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 789260c..48607c8 100644 --- a/fixtures/basic.txt +++ b/fixtures/basic.txt @@ -1 +1 @@ -create a comment on https://github.com/pullfrogai/scratch/pull/29 that says ribbit \ No newline at end of file +create a comment on https://github.com/pullfrogai/scratch/pull/29 that says Things just got out of hand \ No newline at end of file diff --git a/play.ts b/play.ts index 276111c..5e972a6 100644 --- a/play.ts +++ b/play.ts @@ -24,7 +24,7 @@ export async function run( const inputs: Required = { prompt, - agent: "claude", + agent: "gemini", ...flatMorph(agents, (_, agent) => agent.inputKeys.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]) ),