Files
codex/prs/bolinfest/PR-2237.md
2025-09-02 15:17:45 -07:00

16 KiB

PR #2237: [codex-cli] Add ripgrep as a dependency for node environment

Description

Summary

Ripgrep is our preferred tool for file search. When users install via brew install codex, it's automatically installed as a dependency. We want to ensure that users running via an npm install also have this tool! Microsoft has already solved this problem for VS Code - let's not reinvent the wheel.

This approach of appending to the PATH directly might be a bit heavy-handed, but feels reasonably robust to a variety of environment concerns. Open to thoughts on better approaches here!

Testing

  • confirmed this import approach works with node -e "const { rgPath } = require('@vscode/ripgrep'); require('child_process').spawn(rgPath, ['--version'], { stdio: 'inherit' })"
  • Ran codex.js locally with rg uninstalled, asked it to run which rg. Output below:
⚡ Ran command which rg; echo $?
  ⎿ /Users/dylan.hurd/code/dh--npm-rg/node_modules/@vscode/ripgrep/bin/rg
    0

codex
Re-running to confirm the path and exit code.

- Path: `/Users/dylan.hurd/code/dh--npm-rg/node_modules/@vscode/ripgrep/bin/rg`
- Exit code: `0`

Full Diff

diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js
index d92d8f2f4f..b22c5180c0 100755
--- a/codex-cli/bin/codex.js
+++ b/codex-cli/bin/codex.js
@@ -43,7 +43,7 @@ switch (platform) {
         targetTriple = "x86_64-pc-windows-msvc.exe";
         break;
       case "arm64":
-        // We do not build this today, fall through...
+      // We do not build this today, fall through...
       default:
         break;
     }
@@ -65,9 +65,43 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch (err) {
+    return null;
+  }
+}
+
+async function resolveRgDir() {
+  const ripgrep = await tryImport("@vscode/ripgrep");
+  if (!ripgrep?.rgPath) {
+    return null;
+  }
+  return path.dirname(ripgrep.rgPath);
+}
+
+function getUpdatedPath(newDirs) {
+  const pathSep = process.platform === "win32" ? ";" : ":";
+  const existingPath = process.env.PATH || "";
+  const updatedPath = [
+    ...newDirs,
+    ...existingPath.split(pathSep).filter(Boolean),
+  ].join(pathSep);
+  return updatedPath;
+}
+
+const additionalDirs = [];
+const rgDir = await resolveRgDir();
+if (rgDir) {
+  additionalDirs.push(rgDir);
+}
+const updatedPath = getUpdatedPath(additionalDirs);
+
 const child = spawn(binaryPath, process.argv.slice(2), {
   stdio: "inherit",
-  env: { ...process.env, CODEX_MANAGED_BY_NPM: "1" },
+  env: { ...process.env, PATH: updatedPath, CODEX_MANAGED_BY_NPM: "1" },
 });
 
 child.on("error", (err) => {
@@ -120,4 +154,3 @@ if (childResult.type === "signal") {
 } else {
   process.exit(childResult.exitCode);
 }
-
diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json
new file mode 100644
index 0000000000..a1c840ade0
--- /dev/null
+++ b/codex-cli/package-lock.json
@@ -0,0 +1,119 @@
+{
+  "name": "@openai/codex",
+  "version": "0.0.0-dev",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@openai/codex",
+      "version": "0.0.0-dev",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@vscode/ripgrep": "^1.15.14"
+      },
+      "bin": {
+        "codex": "bin/codex.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@vscode/ripgrep": {
+      "version": "1.15.14",
+      "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz",
+      "integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "https-proxy-agent": "^7.0.2",
+        "proxy-from-env": "^1.1.0",
+        "yauzl": "^2.9.2"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+      "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fd-slicer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+      "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+      "license": "MIT",
+      "dependencies": {
+        "pend": "~1.2.0"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/pend": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+      "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/yauzl": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+      "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-crc32": "~0.2.3",
+        "fd-slicer": "~1.1.0"
+      }
+    }
+  }
+}
diff --git a/codex-cli/package.json b/codex-cli/package.json
index c5464beae5..614ca1a832 100644
--- a/codex-cli/package.json
+++ b/codex-cli/package.json
@@ -16,5 +16,11 @@
   "repository": {
     "type": "git",
     "url": "git+https://github.com/openai/codex.git"
+  },
+  "dependencies": {
+    "@vscode/ripgrep": "^1.15.14"
+  },
+  "devDependencies": {
+    "prettier": "^3.3.3"
   }
 }
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000..bb67c75258
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,33 @@
+{
+  "name": "codex-monorepo",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "codex-monorepo",
+      "devDependencies": {
+        "prettier": "^3.5.3"
+      },
+      "engines": {
+        "node": ">=22",
+        "pnpm": ">=9.0.0"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+      "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "prettier": "bin/prettier.cjs"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
index c13e6cb314..606c323ff4 100644
--- a/package.json
+++ b/package.json
@@ -3,8 +3,8 @@
   "private": true,
   "description": "Tools for repo-wide maintenance.",
   "scripts": {
-    "format": "prettier --check *.json *.md .github/workflows/*.yml",
-    "format:fix": "prettier --write *.json *.md .github/workflows/*.yml"
+    "format": "prettier --check *.json *.md .github/workflows/*.yml **/*.js",
+    "format:fix": "prettier --write *.json *.md .github/workflows/*.yml **/*.js"
   },
   "devDependencies": {
     "prettier": "^3.5.3"

Review Comments

codex-cli/bin/codex.js

@@ -65,9 +65,40 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch {
+    return null;
+  }
+}
+
+async function resolveRgPath() {

In deleting most of codex-cli, I guess we lost Prettier coverage for this file...

@@ -65,9 +65,40 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch {
+    return null;
+  }
+}
+
+async function resolveRgPath() {
+    try {
+        const { rgPath } = await tryImport("@vscode/ripgrep");
+        return path.dirname(rgPath);
+    } catch(err) {
+        console.error('unable to import ripgrep', err);
+    }
+}
+
+function getUpdatedPath(newDirs) {
+    const pathSep = process.platform === "win32" ? ";" : ":";
+    const existingPath = process.env.PATH || process.env.Path || "";

I thought env vars are case-insensitive and that process.env does some internal get voodoo to enforce that, so I don't believe you need process.env.Path, do you?

@@ -65,9 +65,40 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch {
+    return null;
+  }
+}
+
+async function resolveRgPath() {
+    try {
+        const { rgPath } = await tryImport("@vscode/ripgrep");
+        return path.dirname(rgPath);

To confirm: rg is definitely the only file in that directory?

@@ -65,9 +65,40 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch {
+    return null;
+  }
+}

Can you verify this works on Node 20? I recently lowered the minimum version:

eaa3969e68/codex-cli/package.json (L10)

@@ -65,9 +65,40 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch {
+    return null;
+  }
+}
+
+async function resolveRgPath() {
+    try {
+        const { rgPath } = await tryImport("@vscode/ripgrep");

You already have try/catch in tryImport(), so I don't think we should do it again here. Just check whether the result is null or not.

I worry we might swallow logical errors by accident.

@@ -65,9 +65,40 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch {
+    return null;
+  }
+}
+
+async function resolveRgPath() {
+    try {
+        const { rgPath } = await tryImport("@vscode/ripgrep");
+        return path.dirname(rgPath);
+    } catch(err) {
+        console.error('unable to import ripgrep', err);
+    }
+}
+
+function getUpdatedPath(newDirs) {
+    const pathSep = process.platform === "win32" ? ";" : ":";
+    const existingPath = process.env.PATH || process.env.Path || "";

I just tested on my Windows machine and process.env.Path and process.env.PATH are the same value.

@@ -65,9 +65,40 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
 // receives a fatal signal, both processes exit in a predictable manner.
 const { spawn } = await import("child_process");
 
+async function tryImport(moduleName) {
+  try {
+    // eslint-disable-next-line node/no-unsupported-features/es-syntax
+    return await import(moduleName);
+  } catch (err) {
+    console.error(`unable to import ${moduleName}`, err);
+    return null;
+  }
+}
+
+async function resolveRgPath() {
+  const { rgPath } = await tryImport("@vscode/ripgrep");
+  if (!rgPath) {
+    throw new Error("ripgrep not found");
+  }
+  return path.dirname(rgPath);
+}
+
+function getUpdatedPath(newDirs) {
+  const pathSep = process.platform === "win32" ? ";" : ":";
+  const existingPath = process.env.PATH || "";
+  const updatedPath = [
+    ...newDirs,
+    ...existingPath.split(pathSep).filter(Boolean),
+  ].join(pathSep);
+  return updatedPath;
+}
+
+const rgPath = await resolveRgPath();
+const updatedPath = getUpdatedPath([rgPath]);

Should we throw in this case? I feel like, if the user has installed with --ignore-scripts or the network request failed to fetch rg for some reason, we should still let them run the CLI?

package.json

@@ -21,5 +21,8 @@
     "node": ">=22",
     "pnpm": ">=9.0.0"
   },
-  "packageManager": "pnpm@10.8.1"
+  "packageManager": "pnpm@10.8.1",
+  "dependencies": {
+    "@vscode/ripgrep": "^1.15.14"

Note that this does not guarantee that rg gets installed because the npm package does not bundle the binaries itself, but downloads them via postinstall:

https://www.npmjs.com/package/@vscode/ripgrep

Because we expect our users to install via npm -g and have network access at that time, this should be fine, but if they used --ignore-scripts for some reason, then it wouldn't end up getting fetched.