import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { execSync } from "node:child_process"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createBashTool, createLocalBashOperations } from "@earendil-works/pi-coding-agent"; export default function (pi: ExtensionAPI) { // Find rbenv root and shims let rbenvRoot = process.env.RBENV_ROOT; if (!rbenvRoot) { try { rbenvRoot = execSync("rbenv root", { encoding: "utf8" }).trim(); } catch { rbenvRoot = path.join(os.homedir(), ".rbenv"); } } const rbenvShims = path.join(rbenvRoot, "shims"); // Get rbenv's global/default version let globalVersion: string | null = null; try { globalVersion = execSync("rbenv global", { encoding: "utf8" }).trim(); } catch { // Fallback if rbenv is not fully initialized or global command fails } const projectRoot = process.cwd(); const pathSeparator = process.platform === "win32" ? ";" : ":"; // Prepend rbenv shims to PATH globally in the parent process const currentPath = process.env.PATH || ""; const paths = currentPath.split(pathSeparator); if (!paths.includes(rbenvShims)) { process.env.PATH = [rbenvShims, ...paths].join(pathSeparator); } // Helper to dynamically read .ruby-version from project root function getProjectRubyVersion(): string | null { const rubyVersionPath = path.join(projectRoot, ".ruby-version"); if (fs.existsSync(rubyVersionPath)) { try { const content = fs.readFileSync(rubyVersionPath, "utf8"); const line = content .split("\n") .map((l) => l.trim()) .find((l) => l && !l.startsWith("#")); if (line) { return line.replace(/^ruby-/, ""); } } catch { // Ignore read errors } } return null; } // Helper to check if a specific version is installed under rbenv function isVersionInstalled(version: string): boolean { const versionDir = path.join(rbenvRoot, "versions", version); return fs.existsSync(versionDir); } // Helper to determine the target ruby version for execution function determineTargetVersion(): { version: string | null; fallback: boolean } { const projectVersion = getProjectRubyVersion(); if (projectVersion) { if (isVersionInstalled(projectVersion)) { return { version: projectVersion, fallback: false }; } else { return { version: globalVersion, fallback: true }; } } return { version: null, fallback: false }; } // Update global process.env.RBENV_VERSION on startup const { version: initialVersion } = determineTargetVersion(); if (initialVersion) { process.env.RBENV_VERSION = initialVersion; } else { delete process.env.RBENV_VERSION; } // Notify user on session startup pi.on("session_start", async (_event, ctx) => { const projectVersion = getProjectRubyVersion(); if (projectVersion) { if (isVersionInstalled(projectVersion)) { ctx.ui.notify(`rbenv: Using Ruby version ${projectVersion} (from .ruby-version)`, "info"); } else if (globalVersion) { ctx.ui.notify( `rbenv: Ruby version ${projectVersion} specified in .ruby-version is not installed. Falling back to default (${globalVersion}).`, "warning" ); } else { ctx.ui.notify( `rbenv: Ruby version ${projectVersion} specified in .ruby-version is not installed.`, "warning" ); } } else if (globalVersion) { ctx.ui.notify(`rbenv: Using default Ruby version (${globalVersion})`, "info"); } else { ctx.ui.notify(`rbenv: Using default Ruby version`, "info"); } }); // Helper to configure the environment for a command execution context function configureEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const modifiedEnv = { ...env }; // Prepend shims to PATH const currentPath = modifiedEnv.PATH || ""; const paths = currentPath.split(pathSeparator); if (!paths.includes(rbenvShims)) { modifiedEnv.PATH = [rbenvShims, ...paths].join(pathSeparator); } // Determine ruby version and configure RBENV_VERSION const { version: targetVersion } = determineTargetVersion(); if (targetVersion) { modifiedEnv.RBENV_VERSION = targetVersion; } else { delete modifiedEnv.RBENV_VERSION; } return modifiedEnv; } // Override built-in 'bash' tool to customize env for agent and subagent actions const originalBash = createBashTool(projectRoot, { spawnHook: (spawnContext) => { return { command: spawnContext.command, cwd: spawnContext.cwd, env: configureEnv(spawnContext.env), }; }, }); pi.registerTool({ name: "bash", label: originalBash.label, description: originalBash.description, parameters: originalBash.parameters, execute: async (toolCallId, params, signal, onUpdate) => { return originalBash.execute(toolCallId, params, signal, onUpdate); }, }); // Intercept the 'shell' tool (used by ctx_shell) to inject rbenv env setup. // Unlike the 'bash' tool, there is no spawnHook available for 'shell', so we // can't set the subprocess env directly. Instead, prefix every command with // inline exports that run after any login-shell init (overriding ~/.zprofile // etc.), ensuring PATH has rbenv shims first and RBENV_VERSION is correct. // This covers every gem-installed executable (rails, rubocop, pry, …) without // needing a hardcoded whitelist. pi.on("tool_call", (event, _ctx) => { if ( (event.toolName === "shell" || event.toolName === "ctx_shell") && event.input && typeof (event.input as { command?: string }).command === "string" ) { const input = event.input as { command: string }; const { version: targetVersion } = determineTargetVersion(); const envSetup = [ `export PATH="${rbenvShims}:$PATH"`, targetVersion ? `export RBENV_VERSION="${targetVersion}"` : "", ].filter(Boolean).join("; "); input.command = `${envSetup}; ${input.command}`; } }); // Intercept 'user_bash' to customize env for interactive CLI commands (! and !!) pi.on("user_bash", (_event, _ctx) => { const local = createLocalBashOperations(); return { operations: { exec(command, cwd, options) { const modifiedEnv = configureEnv(options.env || process.env); return local.exec(command, cwd, { ...options, env: modifiedEnv }); }, }, }; }); }