diff --git a/CHANGELOG.md b/CHANGELOG.md index 2074029..ab19cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.3] - 2026-10-13 + +### Fixed + +- **Bundled `rg` (ripgrep) is now on `PATH` for `Bash` commands and hooks across all OSes.** `@vscode/ripgrep` was previously in `optionalDependencies` and only resolved by the legacy `search_files` executor; shell commands spawned by `Bash` (`executeCommand`, background commands) and hooks inherited the raw `process.env.PATH`, so `rg` failed with `command not found` on machines where ripgrep was not installed globally. `@vscode/ripgrep` is now a regular dependency (installing the matching platform binary via its per-OS/arch packages on macOS, Linux, and Windows), and its `bin/` directory is appended to `PATH` for all spawned shell commands and hooks so `rg` works out of the box while still respecting any system `rg` already on the user's `PATH`. + ## [7.0.0] - 2026-10-09 ### Added diff --git a/package.json b/package.json index 6a444d7..1bfea27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "7.0.2", + "version": "7.0.3", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { @@ -67,7 +67,8 @@ "picomatch": "4.0.4", "react": "^19.2.0", "read-excel-file": "^9.2.0", - "string-width": "^7.2.0" + "string-width": "^7.2.0", + "@vscode/ripgrep": "1.18.0" }, "devDependencies": { "@types/node": "^20.17.0", @@ -77,8 +78,7 @@ "typescript": "^5.7.2" }, "optionalDependencies": { - "@ff-labs/fff-node": "0.10.1", - "@vscode/ripgrep": "1.18.0" + "@ff-labs/fff-node": "0.10.1" }, "keywords": [ "orbcode", diff --git a/src/core/hooks.ts b/src/core/hooks.ts index 5b12206..377f4b7 100644 --- a/src/core/hooks.ts +++ b/src/core/hooks.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process" -import { getShell, getShellRunArgs } from "../utils/shell.js" +import { getShell, getShellRunArgs, withBundledToolsOnPath } from "../utils/shell.js" /** * Lifecycle hooks, modeled on Claude Code's hooks feature. Users configure @@ -369,7 +369,7 @@ const HOOK_ENV_REDACT_PATTERN = /(?:^|_)(TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDEN * credential — hooks must never see the user's API token. */ function buildHookEnv(cwd: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { MATTERAI_PROJECT_DIR: cwd } - for (const [key, value] of Object.entries(process.env)) { + for (const [key, value] of Object.entries(withBundledToolsOnPath(process.env))) { if (value === undefined) continue if (HOOK_ENV_REDACT_EXACT.has(key)) continue if (HOOK_ENV_REDACT_PATTERN.test(key)) continue diff --git a/src/tools/executors/backgroundCommands.ts b/src/tools/executors/backgroundCommands.ts index 36e78a2..dc98631 100644 --- a/src/tools/executors/backgroundCommands.ts +++ b/src/tools/executors/backgroundCommands.ts @@ -3,6 +3,8 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" +import { withBundledToolsOnPath } from "../../utils/shell.js" + export interface BackgroundCommand { id: string /** Task that started the command; scopes listing, notices and cleanup. */ @@ -126,7 +128,7 @@ export function startBackgroundCommand( const child = spawn(shell, shellArgs, { cwd, - env: { ...process.env, TERM: "dumb" }, + env: { ...withBundledToolsOnPath(process.env), TERM: "dumb" }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", }) diff --git a/src/tools/executors/executeCommand.ts b/src/tools/executors/executeCommand.ts index 3e50e35..b94bbea 100644 --- a/src/tools/executors/executeCommand.ts +++ b/src/tools/executors/executeCommand.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process" import * as fs from "node:fs" -import { getShell, getShellRunArgs, isCmdShell } from "../../utils/shell.js" +import { getShell, getShellRunArgs, isCmdShell, withBundledToolsOnPath } from "../../utils/shell.js" import { type ToolContext, type ToolResult, resolveWorkspacePath } from "../types.js" import { startBackgroundCommand } from "./backgroundCommands.js" @@ -50,7 +50,7 @@ export async function executeCommand(args: Record, context: Too const isWindows = process.platform === "win32" const child = spawn(getShell(), getShellRunArgs(command), { cwd, - env: { ...process.env, TERM: "dumb" }, + env: { ...withBundledToolsOnPath(process.env), TERM: "dumb" }, stdio: ["ignore", "pipe", "pipe"], // Own process group, so a timeout/interrupt can kill grandchildren too // (`find /` started by the shell would otherwise outlive the shell and diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 7f28938..fee46c3 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs" +import { createRequire } from "node:module" import * as path from "node:path" /** @@ -9,6 +10,60 @@ import * as path from "node:path" * - Windows: Git Bash when installed, else ComSpec (cmd.exe). */ let cached: string | undefined +let cachedRgBinDir: string | null | undefined + +const require = createRequire(import.meta.url) + +/** + * Resolve the bundled `@vscode/ripgrep` binary path, if installed for this + * platform/arch (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, + * `win32-x64`, `win32-arm64`, etc.). + */ +export function getBundledRipgrepPath(): string | undefined { + try { + const mod = require("@vscode/ripgrep") as { rgPath?: unknown } + if (typeof mod.rgPath === "string" && fs.statSync(mod.rgPath, { throwIfNoEntry: false })?.isFile()) { + return mod.rgPath + } + } catch { + // Platform binary package may not be installed on an unsupported architecture. + } + return undefined +} + +/** + * Directory containing the bundled `rg` (`rg.exe` on Windows) binary, so we + * can append it to `PATH` when spawning shell commands and hooks. Appending + * (rather than prepending) respects any `rg` the user already has on `PATH` + * while guaranteeing `rg` works out of the box when not installed globally. + */ +export function getBundledRipgrepDir(): string | undefined { + if (cachedRgBinDir === undefined) { + const rgPath = getBundledRipgrepPath() + cachedRgBinDir = rgPath ? path.dirname(rgPath) : null + } + return cachedRgBinDir ?? undefined +} + +/** + * Return `baseEnv` with the bundled ripgrep directory appended to `PATH` (or + * `Path` on Windows when that casing is used) if not already present. + */ +export function withBundledToolsOnPath(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const rgDir = getBundledRipgrepDir() + if (!rgDir) return { ...baseEnv } + const env: NodeJS.ProcessEnv = { ...baseEnv } + const pathKey = + process.platform === "win32" + ? (Object.keys(env).find((k) => k.toLowerCase() === "path") ?? "PATH") + : "PATH" + const current = env[pathKey] ?? "" + const entries = current.split(path.delimiter).filter(Boolean) + if (!entries.includes(rgDir)) { + env[pathKey] = entries.length > 0 ? `${current}${path.delimiter}${rgDir}` : rgDir + } + return env +} function firstExisting(candidates: string[]): string | undefined { return candidates.find((candidate) => { diff --git a/test/read-only-command.test.ts b/test/read-only-command.test.ts index a64e372..845b0e6 100644 --- a/test/read-only-command.test.ts +++ b/test/read-only-command.test.ts @@ -53,3 +53,22 @@ test("shell selection prefers a bash-compatible shell", async () => { assert.equal(isCmdShell("C:\\Windows\\System32\\cmd.exe"), true) assert.equal(isCmdShell("/bin/bash"), false) }) + +test("bundled ripgrep is exposed on PATH for Bash commands", async () => { + const { getBundledRipgrepDir, getBundledRipgrepPath, withBundledToolsOnPath } = await import("../src/utils/shell.js") + const { executeCommand } = await import("../src/tools/executors/executeCommand.js") + const rgPath = getBundledRipgrepPath() + const rgDir = getBundledRipgrepDir() + assert.ok(rgPath, "expected @vscode/ripgrep binary to resolve on this host") + assert.ok(rgDir, "expected bundled ripgrep bin directory") + + const envWithTools = withBundledToolsOnPath({ PATH: "/usr/bin:/bin" }) + assert.ok(envWithTools.PATH?.split(process.platform === "win32" ? ";" : ":").includes(rgDir)) + + const res = await executeCommand( + { command: "rg --version" }, + { cwd: process.cwd(), token: "", getTodos: () => "", setTodos: () => {} }, + ) + assert.equal(res.isError, false, res.text) + assert.match(res.text, /^ripgrep \d+\.\d+\.\d+/m) +})