From 6d0794465440098aae18b30cd6a842e4a2ac10e3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 8 Apr 2026 05:22:27 +0200 Subject: [PATCH] refactor: migrated binary detection to $which() utility across codebase - Replaced all Bun.which() calls with $which() utility from @oh-my-pi/pi-utils across 22 files. - Removed findBashOnPath() wrapper function from procmgr.ts, consolidating binary path resolution. - Updated AGENTS.md documentation to reflect new $which() API usage pattern. - Centralized binary detection logic through shared utility, reducing code duplication. --- AGENTS.md | 2 +- packages/ai/test/context-overflow.test.ts | 3 +- packages/ai/test/image-limits.test.ts | 3 +- packages/ai/test/stream.test.ts | 3 +- packages/coding-agent/src/cli/setup-cli.ts | 8 ++-- packages/coding-agent/src/cli/update-cli.ts | 6 +-- .../src/extensibility/plugins/doctor.ts | 3 +- packages/coding-agent/src/ipy/runtime.ts | 4 +- packages/coding-agent/src/lsp/lspmux.ts | 4 +- .../src/ssh/connection-manager.ts | 4 +- packages/coding-agent/src/ssh/sshfs-mount.ts | 10 ++--- packages/coding-agent/src/stt/downloader.ts | 8 ++-- packages/coding-agent/src/stt/recorder.ts | 8 ++-- packages/coding-agent/src/stt/transcriber.ts | 4 +- packages/coding-agent/src/task/worktree.ts | 8 ++-- packages/coding-agent/src/tools/browser.ts | 6 +-- packages/coding-agent/src/tools/fetch.ts | 4 +- packages/coding-agent/src/utils/git.ts | 8 ++-- .../coding-agent/src/utils/tools-manager.ts | 12 +++--- .../test/core/python-prelude.test.ts | 4 +- packages/coding-agent/test/tools.test.ts | 4 +- packages/utils/src/procmgr.ts | 17 ++------ packages/utils/src/which.ts | 41 ++++++++++++++----- 23 files changed, 93 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 742600519..4af395ac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -339,7 +339,7 @@ const wrapped = Bun.wrapAnsi(text, width, { | File read/write | `Bun.file()`, `Bun.write()` | `readFileSync`, `writeFileSync` | | Spawn process | `$\`cmd\``, `Bun.spawn()` | `child_process` | | Sleep | `Bun.sleep(ms)` | `setTimeout` promise | -| Binary lookup | `Bun.which("git")` | `spawnSync(["which", "git"])` | +| Binary lookup | `$which("git")` from `@oh-my-pi/pi-utils` | `spawnSync(["which", "git"])` | | HTTP server | `Bun.serve()` | `http.createServer()` | | SQLite | `bun:sqlite` | `better-sqlite3` | | Hashing | `Bun.hash()`, Web Crypto | `node:crypto` | diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 2ed300de3..670250b85 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -18,6 +18,7 @@ import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { AssistantMessage, Context, Model, Usage } from "@oh-my-pi/pi-ai/types"; import { isContextOverflow } from "@oh-my-pi/pi-ai/utils/overflow"; +import { $which } from "@oh-my-pi/pi-utils"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) @@ -445,7 +446,7 @@ describe("Context overflow error handling", () => { // ============================================================================= // Ollama tests require PI_LOCAL_LLM=1 and ollama installed - const ollamaInstalled = !!Bun.env.PI_LOCAL_LLM && !!Bun.which("ollama"); + const ollamaInstalled = !!Bun.env.PI_LOCAL_LLM && !!$which("ollama"); describe.skipIf(!ollamaInstalled)("Ollama (local)", () => { let ollamaProcess: ChildProcess | null = null; diff --git a/packages/ai/test/image-limits.test.ts b/packages/ai/test/image-limits.test.ts index bf5fe91d4..b61489170 100644 --- a/packages/ai/test/image-limits.test.ts +++ b/packages/ai/test/image-limits.test.ts @@ -74,6 +74,7 @@ import * as path from "node:path"; import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types"; +import { $which } from "@oh-my-pi/pi-utils"; import { e2eApiKey } from "./oauth"; const TEMP_DIR = path.join(import.meta.dir, ".temp-images"); @@ -261,7 +262,7 @@ describe("Image Limits E2E Tests", () => { let smallImage: string; // 100x100 for count tests beforeAll(async () => { - if (!Bun.which("magick")) return; + if (!$which("magick")) return; // Create temp directory fs.mkdirSync(TEMP_DIR, { recursive: true }); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 317851dd7..d0084353c 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -7,6 +7,7 @@ import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { StringEnum } from "@oh-my-pi/pi-ai/utils/schema"; +import { $which } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import { e2eApiKey, resolveApiKey } from "./oauth"; @@ -1509,7 +1510,7 @@ describe("Generate E2E Tests", () => { }); // Ollama tests require PI_LOCAL_LLM=1 and ollama installed - const ollamaInstalled = !!Bun.env.PI_LOCAL_LLM && !!Bun.which("ollama"); + const ollamaInstalled = !!Bun.env.PI_LOCAL_LLM && !!$which("ollama"); describe.skipIf(!ollamaInstalled)("Ollama Provider (gpt-oss-20b via OpenAI Completions)", () => { let llm: Model<"openai-completions"> | undefined; diff --git a/packages/coding-agent/src/cli/setup-cli.ts b/packages/coding-agent/src/cli/setup-cli.ts index 316f51334..5e8e7f289 100644 --- a/packages/coding-agent/src/cli/setup-cli.ts +++ b/packages/coding-agent/src/cli/setup-cli.ts @@ -4,7 +4,7 @@ * Handles `omp setup ` to install dependencies for optional features. */ import * as path from "node:path"; -import { APP_NAME, getPythonEnvDir } from "@oh-my-pi/pi-utils"; +import { $which, APP_NAME, getPythonEnvDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; @@ -90,12 +90,12 @@ async function checkPythonSetup(): Promise { managedEnvPath: MANAGED_PYTHON_ENV, }; - const systemPythonPath = Bun.which("python") ?? Bun.which("python3"); + const systemPythonPath = $which("python") ?? $which("python3"); const managedPath = managedPythonPath(); const hasManagedEnv = await Bun.file(managedPath).exists(); - result.uvPath = Bun.which("uv") ?? undefined; - result.pipPath = Bun.which("pip3") ?? Bun.which("pip") ?? undefined; + result.uvPath = $which("uv") ?? undefined; + result.pipPath = $which("pip3") ?? $which("pip") ?? undefined; const candidates = [systemPythonPath, hasManagedEnv ? managedPath : undefined].filter( (candidate): candidate is string => !!candidate, diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 0c895424b..d8b3caded 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { pipeline } from "node:stream/promises"; -import { APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; +import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; @@ -36,7 +36,7 @@ export function parseUpdateArgs(args: string[]): { force: boolean; check: boolea } async function getBunGlobalBinDir(): Promise { - if (!Bun.which("bun")) return undefined; + if (!$which("bun")) return undefined; try { const result = await $`bun pm bin -g`.quiet().nothrow(); if (result.exitCode !== 0) return undefined; @@ -167,7 +167,7 @@ function getBinaryName(): string { * Resolve the path that `omp` maps to in the user's PATH. */ function resolveOmpPath(): string | undefined { - return Bun.which(APP_NAME) ?? undefined; + return $which(APP_NAME) ?? undefined; } /** diff --git a/packages/coding-agent/src/extensibility/plugins/doctor.ts b/packages/coding-agent/src/extensibility/plugins/doctor.ts index 913ec4052..ff33cc562 100644 --- a/packages/coding-agent/src/extensibility/plugins/doctor.ts +++ b/packages/coding-agent/src/extensibility/plugins/doctor.ts @@ -1,3 +1,4 @@ +import { $which } from "@oh-my-pi/pi-utils"; import { theme } from "../../modes/theme/theme"; import type { DoctorCheck } from "./types"; @@ -12,7 +13,7 @@ export async function runDoctorChecks(): Promise { ]; for (const tool of tools) { - const path = Bun.which(tool.name); + const path = $which(tool.name); checks.push({ name: tool.name, status: path ? "ok" : "warning", diff --git a/packages/coding-agent/src/ipy/runtime.ts b/packages/coding-agent/src/ipy/runtime.ts index 577f39daf..b2ac5ec55 100644 --- a/packages/coding-agent/src/ipy/runtime.ts +++ b/packages/coding-agent/src/ipy/runtime.ts @@ -6,7 +6,7 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import { $env, getPythonEnvDir } from "@oh-my-pi/pi-utils"; +import { $env, $which, getPythonEnvDir } from "@oh-my-pi/pi-utils"; const DEFAULT_ENV_ALLOWLIST = new Set([ "PATH", @@ -210,7 +210,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record { return cachedState; } - const binaryPath = Bun.which("lspmux"); + const binaryPath = $which("lspmux"); if (!binaryPath) { cachedState = { available: false, running: false, binaryPath: null, config: null }; cacheTimestamp = now; diff --git a/packages/coding-agent/src/ssh/connection-manager.ts b/packages/coding-agent/src/ssh/connection-manager.ts index 780547426..6942ead92 100644 --- a/packages/coding-agent/src/ssh/connection-manager.ts +++ b/packages/coding-agent/src/ssh/connection-manager.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getRemoteHostDir, getSshControlDir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils"; +import { $which, getRemoteHostDir, getSshControlDir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { buildSshTarget, sanitizeHostName } from "./utils"; @@ -106,7 +106,7 @@ async function runSshCaptureSync(args: string[]): Promise<{ exitCode: number | n } function ensureSshBinary(): void { - if (!Bun.which("ssh")) { + if (!$which("ssh")) { throw new Error("ssh binary not found on PATH"); } } diff --git a/packages/coding-agent/src/ssh/sshfs-mount.ts b/packages/coding-agent/src/ssh/sshfs-mount.ts index 9be8900e6..402c287e7 100644 --- a/packages/coding-agent/src/ssh/sshfs-mount.ts +++ b/packages/coding-agent/src/ssh/sshfs-mount.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getRemoteDir, postmortem } from "@oh-my-pi/pi-utils"; +import { $which, getRemoteDir, postmortem } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager"; import { buildSshTarget, sanitizeHostName } from "./utils"; @@ -60,24 +60,24 @@ function buildSshfsArgs(host: SSHConnectionTarget): string[] { } async function unmountPath(path: string): Promise { - const fusermount = Bun.which("fusermount") ?? Bun.which("fusermount3"); + const fusermount = $which("fusermount") ?? $which("fusermount3"); if (fusermount) { const result = await $`${fusermount} -u ${path}`.quiet().nothrow(); if (result.exitCode === 0) return true; } - const umount = Bun.which("umount"); + const umount = $which("umount"); if (!umount) return false; const result = await $`${umount} ${path}`.quiet().nothrow(); return result.exitCode === 0; } export function hasSshfs(): boolean { - return Bun.which("sshfs") !== null; + return $which("sshfs") !== null; } export async function isMounted(path: string): Promise { - const mountpoint = Bun.which("mountpoint"); + const mountpoint = $which("mountpoint"); if (!mountpoint) return false; const result = await $`${mountpoint} -q ${path}`.quiet().nothrow(); return result.exitCode === 0; diff --git a/packages/coding-agent/src/stt/downloader.ts b/packages/coding-agent/src/stt/downloader.ts index 5c106da09..4586b1d11 100644 --- a/packages/coding-agent/src/stt/downloader.ts +++ b/packages/coding-agent/src/stt/downloader.ts @@ -1,4 +1,4 @@ -import { logger } from "@oh-my-pi/pi-utils"; +import { $which, logger } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { resolvePython } from "./transcriber"; @@ -15,9 +15,9 @@ export interface EnsureOptions { // ── Recording tool ───────────────────────────────────────────────── async function ensureRecordingTool(options?: EnsureOptions): Promise { - if (Bun.which("sox")) return; - if (Bun.which("ffmpeg")) return; - if (process.platform === "linux" && Bun.which("arecord")) return; + if ($which("sox")) return; + if ($which("ffmpeg")) return; + if (process.platform === "linux" && $which("arecord")) return; // Windows: PowerShell mciSendString is always available as fallback if (process.platform === "win32") { diff --git a/packages/coding-agent/src/stt/recorder.ts b/packages/coding-agent/src/stt/recorder.ts index 993688c9a..3110e324c 100644 --- a/packages/coding-agent/src/stt/recorder.ts +++ b/packages/coding-agent/src/stt/recorder.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { logger, Snowflake } from "@oh-my-pi/pi-utils"; +import { $which, logger, Snowflake } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; export interface RecordingHandle { @@ -15,9 +15,9 @@ const isWindows = process.platform === "win32"; */ export function detectRecordingTools(): string[] { const tools: string[] = []; - if (Bun.which("sox")) tools.push("sox"); - if (Bun.which("ffmpeg")) tools.push("ffmpeg"); - if (!isWindows && Bun.which("arecord")) tools.push("arecord"); + if ($which("sox")) tools.push("sox"); + if ($which("ffmpeg")) tools.push("ffmpeg"); + if (!isWindows && $which("arecord")) tools.push("arecord"); if (isWindows) tools.push("powershell"); return tools; } diff --git a/packages/coding-agent/src/stt/transcriber.ts b/packages/coding-agent/src/stt/transcriber.ts index c7a909dd1..9ef607c23 100644 --- a/packages/coding-agent/src/stt/transcriber.ts +++ b/packages/coding-agent/src/stt/transcriber.ts @@ -1,4 +1,4 @@ -import { logger } from "@oh-my-pi/pi-utils"; +import { $which, logger } from "@oh-my-pi/pi-utils"; import transcribeScript from "./transcribe.py" with { type: "text" }; export interface TranscribeOptions { @@ -14,7 +14,7 @@ const TRANSCRIBE_TIMEOUT_MS = 120_000; */ export function resolvePython(): string | null { for (const cmd of ["python", "py", "python3"]) { - if (Bun.which(cmd)) return cmd; + if ($which(cmd)) return cmd; } return null; } diff --git a/packages/coding-agent/src/task/worktree.ts b/packages/coding-agent/src/task/worktree.ts index 8ebb33dcd..ca49543c2 100644 --- a/packages/coding-agent/src/task/worktree.ts +++ b/packages/coding-agent/src/task/worktree.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives"; -import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils"; +import { $which, getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import * as git from "../utils/git"; @@ -348,7 +348,7 @@ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise { try { - const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount"); + const fusermount = $which("fusermount3") ?? $which("fusermount"); if (fusermount) { await $`${fusermount} -u ${mergedDir}`.quiet().nothrow(); } diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index a6c22b351..47f896904 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { Readability } from "@mozilla/readability"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; -import { getPuppeteerDir, logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; +import { $which, getPuppeteerDir, logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { type HTMLElement, parseHTML } from "linkedom"; import type { @@ -88,8 +88,8 @@ function resolveSystemChromium(): string | undefined { return undefined; } const candidates = [ - Bun.which("chromium"), - Bun.which("chromium-browser"), + $which("chromium"), + $which("chromium-browser"), path.join(os.homedir(), ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium", ]; diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index 1c2ad5df1..72c37997a 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -4,7 +4,7 @@ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { htmlToMarkdown } from "@oh-my-pi/pi-natives"; import { type Component, Text } from "@oh-my-pi/pi-tui"; -import { ptree, truncate } from "@oh-my-pi/pi-utils"; +import { $which, ptree, truncate } from "@oh-my-pi/pi-utils"; import { parseHTML } from "linkedom"; import type { Settings } from "../config/settings"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -94,7 +94,7 @@ const MAX_INLINE_IMAGE_OUTPUT_BYTES = 0.75 * 1024 * 1024; * Check if a command exists (cross-platform) */ function hasCommand(cmd: string): boolean { - return Boolean(Bun.which(cmd)); + return Boolean($which(cmd)); } /** diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 44c0a863d..9d825a34a 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { isEnoent, Snowflake } from "@oh-my-pi/pi-utils"; +import { $which, isEnoent, Snowflake } from "@oh-my-pi/pi-utils"; import { parseDiffHunks as parseCommitDiffHunks, parseFileDiffs, @@ -162,7 +162,7 @@ function normalizeStdin(input: CommandOptions["stdin"]): "ignore" | Uint8Array { } function ensureAvailable(): void { - if (!Bun.which("git")) { + if (!$which("git")) { throw new Error("git is not installed."); } } @@ -1334,13 +1334,13 @@ function formatGhFailure(args: readonly string[], stdout: string, stderr: string export const github = { /** Check if `gh` CLI is installed. */ available(): boolean { - return Boolean(Bun.which("gh")); + return Boolean($which("gh")); }, /** Run a raw `gh` CLI command. Does not throw on non-zero exit. */ async run(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise { throwIfAborted(signal); - if (!Bun.which("gh")) { + if (!$which("gh")) { throw new ToolError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/."); } try { diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 4b8885472..e91708e1a 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils"; +import { $which, APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils"; const TOOLS_DIR = getToolsDir(); const TOOL_DOWNLOAD_TIMEOUT_MS = 120_000; @@ -96,7 +96,7 @@ export function getToolPath(tool: ToolName): string | null { // Check Python tools first const pythonConfig = PYTHON_TOOLS[tool]; if (pythonConfig) { - return Bun.which(pythonConfig.binaryName); + return $which(pythonConfig.binaryName); } const config = TOOLS[tool]; @@ -109,7 +109,7 @@ export function getToolPath(tool: ToolName): string | null { } // Check system PATH - return Bun.which(config.binaryName); + return $which(config.binaryName); } // Fetch latest release version from GitHub @@ -249,7 +249,7 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise { // Try uv first (faster, better isolation) - const uv = Bun.which("uv"); + const uv = $which("uv"); if (uv) { const result = await ptree.exec(["uv", "tool", "install", pkg], { signal, @@ -261,7 +261,7 @@ async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise< } // Fall back to pip - const pip = Bun.which("pip3") || Bun.which("pip"); + const pip = $which("pip3") || $which("pip"); if (pip) { const result = await ptree.exec(["pip", "install", "--user", pkg], { signal, @@ -316,7 +316,7 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt const success = await installPythonPackage(pythonConfig.package, signal); if (success) { // Re-check for the command after installation - const path = Bun.which(pythonConfig.binaryName); + const path = $which(pythonConfig.binaryName); if (path) { if (!silent) { logger.debug(`${pythonConfig.name} installed successfully`); diff --git a/packages/coding-agent/test/core/python-prelude.test.ts b/packages/coding-agent/test/core/python-prelude.test.ts index 318d6cf3e..6bb8a4ff4 100644 --- a/packages/coding-agent/test/core/python-prelude.test.ts +++ b/packages/coding-agent/test/core/python-prelude.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetPreludeDocsCache, warmPythonEnvironment } from "@oh-my-pi/pi-coding-agent/ipy/executor"; import { getPythonToolDescription, PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python"; -import { getProjectDir } from "@oh-my-pi/pi-utils"; +import { $which, getProjectDir } from "@oh-my-pi/pi-utils"; const resolvePythonPath = (): string | null => { const venvPath = Bun.env.VIRTUAL_ENV; @@ -19,7 +19,7 @@ const resolvePythonPath = (): string | null => { return pythonCandidate; } } - return Bun.which("python") ?? Bun.which("python3"); + return $which("python") ?? $which("python3"); }; const pythonPath = resolvePythonPath(); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index c59a70db5..f1e117f0c 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -16,7 +16,7 @@ import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-m import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; import * as markitUtils from "@oh-my-pi/pi-coding-agent/utils/markit"; -import { Snowflake } from "@oh-my-pi/pi-utils"; +import { $which, Snowflake } from "@oh-my-pi/pi-utils"; import { unzipSync } from "fflate"; // Helper to extract text from content blocks @@ -34,7 +34,7 @@ function createFifoOrSkip(fifoPath: string): boolean { return false; } - const mkfifoPath = Bun.which("mkfifo"); + const mkfifoPath = $which("mkfifo"); if (!mkfifoPath) { return false; } diff --git a/packages/utils/src/procmgr.ts b/packages/utils/src/procmgr.ts index e1068c952..e73efb898 100644 --- a/packages/utils/src/procmgr.ts +++ b/packages/utils/src/procmgr.ts @@ -3,6 +3,7 @@ import path from "node:path"; import * as timers from "node:timers"; import type { Subprocess } from "bun"; import { $env } from "./env"; +import { $which } from "./which"; export interface ShellConfig { shell: string; @@ -60,18 +61,6 @@ function getShellPrefix(): string | undefined { return $env.PI_SHELL_PREFIX || $env.CLAUDE_CODE_SHELL_PREFIX; } -/** - * Find bash executable on PATH (Windows) - */ -function findBashOnPath(): string | null { - try { - return Bun.which("bash.exe"); - } catch { - // Ignore errors - } - return null; -} - /** * Build full shell config from a shell path. */ @@ -89,7 +78,7 @@ function buildConfig(shell: string): ShellConfig { */ export function resolveBasicShell(): string | undefined { for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) { - const resolved = Bun.which(name); + const resolved = $which(name); if (resolved) return resolved; } @@ -152,7 +141,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) - const bashOnPath = findBashOnPath(); + const bashOnPath = $which("bash.exe"); if (bashOnPath) { cachedShellConfig = buildConfig(bashOnPath); return cachedShellConfig; diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index f6c139081..c779cecfb 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -19,16 +19,35 @@ const MACOS_TOOL_PATHS = [ // Map: cache key -> resolved binary path or null (not found) const toolCache = new Map(); +/** + * Cache policy for which lookups. + */ +export const enum WhichCachePolicy { + /** + * Use cached result if available. + */ + Cached = 0, + /** + * Bypass cache and perform a new lookup. + */ + Bypass, + /** + * Always update cache. + */ + Fresh, + /** + * Read-only, serves from cache if present, but doesn't write. + */ + ReadOnly, +} + // Extension: additional cache policy for tool path lookup export interface WhichOptions extends Bun.WhichOptions { - /** Controls cache usage. - * - * - "none": disables cache, always perform a new lookup - * - "fresh": always update cache (default) - * - "ro": read-only, serves from cache if present, but doesn't write - * - "cached": prefers cache if present, otherwise lookup and populate + /** + * Cache policy for the lookup. + * Defaults to `WhichCachePolicy.Fresh`. */ - cache?: "none" | "fresh" | "ro" | "cached"; + cache?: WhichCachePolicy; } // Darwin-specific "which" shim: consult extra Xcode locations, then fallback to xcrun @@ -73,19 +92,19 @@ function cacheKey(command: string, options?: Bun.WhichOptions): string | bigint * @returns Filesystem path if found, else null */ export function $which(command: string, options?: WhichOptions): string | null { - const cachePolicy = options?.cache ?? "fresh"; + const cachePolicy = options?.cache ?? WhichCachePolicy.Cached; let key: string | bigint | undefined; - if (cachePolicy !== "none") { + if (cachePolicy !== WhichCachePolicy.Bypass) { key = cacheKey(command, options); - if (cachePolicy !== "fresh") { + if (cachePolicy !== WhichCachePolicy.Fresh) { const cached = toolCache.get(key); if (cached !== undefined) return cached; } } const result = whichFresh(command, options); - if (key != null && cachePolicy !== "ro") { + if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) { toolCache.set(key, result); } return result;