import chalk from "chalk"; import { spawnSync } from "child_process"; import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; import { join } from "path"; import { Readable } from "stream"; import { finished } from "stream/promises"; import { APP_NAME, getToolsDir } from "./config.js"; const TOOLS_DIR = getToolsDir(); interface ToolConfig { name: string; repo: string; // GitHub repo (e.g., "sharkdp/fd") binaryName: string; // Name of the binary inside the archive tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0) getAssetName: (version: string, plat: string, architecture: string) => string | null; } const TOOLS: Record = { fd: { name: "fd", repo: "sharkdp/fd", binaryName: "fd", tagPrefix: "v", getAssetName: (version, plat, architecture) => { if (plat === "darwin") { const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; return `fd-v${version}-${archStr}-apple-darwin.tar.gz`; } else if (plat === "linux") { const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`; } else if (plat === "win32") { const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; return `fd-v${version}-${archStr}-pc-windows-msvc.zip`; } return null; }, }, rg: { name: "ripgrep", repo: "BurntSushi/ripgrep", binaryName: "rg", tagPrefix: "", getAssetName: (version, plat, architecture) => { if (plat === "darwin") { const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`; } else if (plat === "linux") { if (architecture === "arm64") { return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`; } return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`; } else if (plat === "win32") { const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`; } return null; }, }, }; // Check if a command exists in PATH by trying to run it function commandExists(cmd: string): boolean { try { const result = spawnSync(cmd, ["--version"], { stdio: "pipe" }); // Check for ENOENT error (command not found) return result.error === undefined || result.error === null; } catch { return false; } } // Get the path to a tool (system-wide or in our tools dir) export function getToolPath(tool: "fd" | "rg"): string | null { const config = TOOLS[tool]; if (!config) return null; // Check our tools directory first const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); if (existsSync(localPath)) { return localPath; } // Check system PATH - if found, just return the command name (it's in PATH) if (commandExists(config.binaryName)) { return config.binaryName; } return null; } // Fetch latest release version from GitHub async function getLatestVersion(repo: string): Promise { const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers: { "User-Agent": `${APP_NAME}-coding-agent` }, }); if (!response.ok) { throw new Error(`GitHub API error: ${response.status}`); } const data = (await response.json()) as { tag_name: string }; return data.tag_name.replace(/^v/, ""); } // Download a file from URL async function downloadFile(url: string, dest: string): Promise { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to download: ${response.status}`); } if (!response.body) { throw new Error("No response body"); } const fileStream = createWriteStream(dest); await finished(Readable.fromWeb(response.body as any).pipe(fileStream)); } // Download and install a tool async function downloadTool(tool: "fd" | "rg"): Promise { const config = TOOLS[tool]; if (!config) throw new Error(`Unknown tool: ${tool}`); const plat = platform(); const architecture = arch(); // Get latest version const version = await getLatestVersion(config.repo); // Get asset name for this platform const assetName = config.getAssetName(version, plat, architecture); if (!assetName) { throw new Error(`Unsupported platform: ${plat}/${architecture}`); } // Create tools directory mkdirSync(TOOLS_DIR, { recursive: true }); const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`; const archivePath = join(TOOLS_DIR, assetName); const binaryExt = plat === "win32" ? ".exe" : ""; const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt); // Download await downloadFile(downloadUrl, archivePath); // Extract const extractDir = join(TOOLS_DIR, "extract_tmp"); mkdirSync(extractDir, { recursive: true }); try { if (assetName.endsWith(".tar.gz")) { spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); } else if (assetName.endsWith(".zip")) { spawnSync("unzip", ["-o", archivePath, "-d", extractDir], { stdio: "pipe" }); } // Find the binary in extracted files const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, "")); const extractedBinary = join(extractedDir, config.binaryName + binaryExt); if (existsSync(extractedBinary)) { renameSync(extractedBinary, binaryPath); } else { throw new Error(`Binary not found in archive: ${extractedBinary}`); } // Make executable (Unix only) if (plat !== "win32") { chmodSync(binaryPath, 0o755); } } finally { // Cleanup rmSync(archivePath, { force: true }); rmSync(extractDir, { recursive: true, force: true }); } return binaryPath; } // Ensure a tool is available, downloading if necessary // Returns the path to the tool, or null if unavailable export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { const existingPath = getToolPath(tool); if (existingPath) { return existingPath; } const config = TOOLS[tool]; if (!config) return null; // Tool not found - download it if (!silent) { console.log(chalk.dim(`${config.name} not found. Downloading...`)); } try { const path = await downloadTool(tool); if (!silent) { console.log(chalk.dim(`${config.name} installed to ${path}`)); } return path; } catch (e) { if (!silent) { console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`)); } return null; } }