diff --git a/modules/ai/omo.nix b/modules/ai/omo.nix index c05f071..002c163 100644 --- a/modules/ai/omo.nix +++ b/modules/ai/omo.nix @@ -19,6 +19,9 @@ in pkgs.local.omo-ai ]; home.file.".omo/agent/AGENTS.md".source = ./opencode/language.md; + # Vendored pi-direnv extension (see the file header for provenance); + # senpi auto-discovers extensions in ~/.omo/agent/extensions/. + home.file.".omo/agent/extensions/pi-direnv.ts".source = ./omo/pi-direnv.ts; home.file.".omo/omo.jsonc" = { # omo's own 2026-07-opencode-config-unification migration already created # this file; take it over from nix. diff --git a/modules/ai/omo/pi-direnv.ts b/modules/ai/omo/pi-direnv.ts new file mode 100644 index 0000000..bdc02e9 --- /dev/null +++ b/modules/ai/omo/pi-direnv.ts @@ -0,0 +1,145 @@ +/** + * Direnv Extension + * + * Loads direnv environment variables on session start, then watches + * .envrc and .direnv/ for changes and reloads only when needed. + * + * The bash tool spawns a new process per command (no persistent shell), + * so the working directory never changes between bash calls. Running + * direnv after every bash command is therefore unnecessary — file + * watching is both cheaper and more correct. + * + * Requirements: + * - direnv installed and in PATH + * - .envrc must be allowed (run `direnv allow` in your shell first) + * + * Vendored from https://github.com/aldoborrero/pi-agent-kit + * (extensions/pi-direnv, not published to npm). Local change: the + * `@aldoborrero/pi-common` dependency is inlined — `createUiColors` + * was only used for two `theme.fg` calls. + */ + +import { watch, type FSWatcher } from "node:fs"; +import { join } from "node:path"; +import { exec } from "node:child_process"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; + +/** Debounce before reloading after a file-system event (ms). */ +const RELOAD_DEBOUNCE_MS = 300; + +export default function (pi: ExtensionAPI) { + let direnvStatus: "on" | "blocked" | "error" | "off" = "off"; + let watchers: FSWatcher[] = []; + let reloadTimer: ReturnType | null = null; + let latestCtx: ExtensionContext | null = null; + + function updateStatus(ctx: ExtensionContext, status: "on" | "blocked" | "error" | "off"): void { + direnvStatus = status; + if (!ctx.hasUI) return; + if (status === "off" || status === "on") { + ctx.ui.setStatus("direnv", undefined); + return; + } + const theme = ctx.ui.theme; + ctx.ui.setStatus( + "direnv", + status === "blocked" ? theme.fg("warning", "direnv:blocked") : theme.fg("error", "direnv:error"), + ); + } + + function loadDirenv(cwd: string, ctx: ExtensionContext): void { + exec("direnv export json", { cwd }, (error, stdout, stderr) => { + if (error) { + const message = (stderr || error.message).toLowerCase(); + updateStatus(ctx, /allow|blocked|denied|not allowed/.test(message) ? "blocked" : "error"); + return; + } + + if (!stdout.trim()) { + updateStatus(ctx, "off"); + return; + } + + try { + const env = JSON.parse(stdout) as Record; + let loadedCount = 0; + for (const [key, value] of Object.entries(env)) { + if (value === null) { + delete process.env[key]; + } else { + process.env[key] = value; + loadedCount++; + } + } + updateStatus(ctx, loadedCount > 0 ? "on" : "off"); + } catch { + updateStatus(ctx, "error"); + } + }); + } + + function scheduleReload(): void { + if (!latestCtx) return; + if (reloadTimer) clearTimeout(reloadTimer); + reloadTimer = setTimeout(() => { + reloadTimer = null; + if (latestCtx) loadDirenv(latestCtx.cwd, latestCtx); + }, RELOAD_DEBOUNCE_MS); + } + + function startWatchers(cwd: string): void { + stopWatchers(); + + // Watch .envrc — covers edits and direnv allow (which rewrites .envrc state) + const envrcPath = join(cwd, ".envrc"); + try { + const w = watch(envrcPath, () => scheduleReload()); + watchers.push(w); + } catch { + // .envrc may not exist — that's fine + } + + // Watch .direnv/ — covers flake rebuilds, nix develop, direnv allow state + const direnvDir = join(cwd, ".direnv"); + try { + const w = watch(direnvDir, () => scheduleReload()); + watchers.push(w); + } catch { + // .direnv/ may not exist yet — that's fine + } + } + + function stopWatchers(): void { + for (const w of watchers) { + try { w.close(); } catch { /* ignore */ } + } + watchers = []; + if (reloadTimer) { + clearTimeout(reloadTimer); + reloadTimer = null; + } + } + + pi.on("session_start", async (_event, ctx) => { + latestCtx = ctx; + loadDirenv(ctx.cwd, ctx); + startWatchers(ctx.cwd); + }); + + pi.on("session_shutdown", async () => { + stopWatchers(); + latestCtx = null; + }); + + pi.registerCommand("direnv", { + description: "Reload direnv environment variables", + handler: async (_args, ctx) => { + latestCtx = ctx; + loadDirenv(ctx.cwd, ctx); + ctx.ui.notify("direnv reloaded", "info"); + }, + }); +} diff --git a/packages/omo-ai.nix b/packages/omo-ai.nix index 7204f7d..bdae8c9 100644 --- a/packages/omo-ai.nix +++ b/packages/omo-ai.nix @@ -1,6 +1,7 @@ { lib, buildNpmPackage, + direnv, fetchurl, fd, nodejs, @@ -93,6 +94,7 @@ buildNpmPackage { nodejs fd ripgrep + direnv ] }" # Senpi needs fd/rg at runtime (same as pi); keep telemetry off unless