Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions compile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { getMacroDefines } from "./scripts/defines.ts";
import { exit } from "process";
import { join, resolve } from "path";
import { readFile, writeFile } from "fs/promises";

const outfile = process.platform === "win32" ? "claude.exe" : "claude";

// Use the currently running bun executable
const bunExe = process.execPath;

// Collect FEATURE_* env vars from environment
const features = Object.keys(process.env)
.filter(k => k.startsWith("FEATURE_"))
.map(k => k.replace("FEATURE_", ""));

// Auto-enable CHICAGO_MCP so @ant packages (computer-use-mcp, etc.)
// are bundled into the standalone exe. Without this flag, the feature-gated
// dynamic imports are tree-shaken and the native .node files are not embedded.
if (!features.includes("CHICAGO_MCP")) {
features.push("CHICAGO_MCP");
}

const defines = getMacroDefines();

// Build --define flags
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
"--define",
`${k}:${v}`,
]);

// Pass BUNDLED_MODE flag so ripgrepAsset.ts knows we're in compiled mode
const defineArgsWithBundled = [
...defineArgs,
"--define",
`BUNDLED_MODE:"true"`,
];

// Build --feature flags
const featureArgs = features.flatMap(f => ["--feature", f]);

// ─── Native module embedding ──────────────────────────────────────────────────
// bun build --compile embeds .node files as assets. When the bundler sees
// process.env.XXX_NODE_PATH with the var set to an absolute .node path,
// it rewrites the string to the bunfs asset path. This lets the runtime
// require() the embedded .node from within the compiled exe.
//
// Paths must use forward slashes and be absolute at compile time.
const repoRoot = resolve(__dirname);

const nativeNodePaths: Record<string, string> = {
// @ant packages — macOS only. Path is used at compile time for Bun asset embedding.
// Runtime: TS files check process.platform !== "darwin" and skip native load.
COMPUTER_USE_INPUT_NODE_PATH: join(repoRoot,
"packages/@ant/computer-use-input/prebuilds/arm64-darwin/computer-use-input.node"),
COMPUTER_USE_SWIFT_NODE_PATH: join(repoRoot,
"packages/@ant/computer-use-swift/prebuilds/arm64-darwin/computer_use.node"),

// vendor modules — cross-platform (win32/linux/darwin)
AUDIO_CAPTURE_NODE_PATH: join(repoRoot,
`vendor/audio-capture/${process.arch}-${process.platform}/audio-capture.node`),
IMAGE_PROCESSOR_NODE_PATH: join(repoRoot,
`vendor/image-processor/${process.arch}-${process.platform}/image-processor.node`),
// modifiers and url-handler are macOS only — paths point to darwin builds
MODIFIERS_NODE_PATH: join(repoRoot,
`vendor/modifiers-napi/${process.arch}-darwin/modifiers.node`),
URL_HANDLER_NODE_PATH: join(repoRoot,
`vendor/url-handler/${process.arch}-darwin/url-handler.node`),
};
Comment on lines +50 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cross-platform build will fail: hardcoded arm64-darwin paths.

The native module paths for COMPUTER_USE_INPUT_NODE_PATH, COMPUTER_USE_SWIFT_NODE_PATH, MODIFIERS_NODE_PATH, and URL_HANDLER_NODE_PATH are hardcoded to arm64-darwin. Building on Windows or Linux will reference non-existent paths, causing Bun to fail when trying to embed these assets.

♻️ Proposed fix for platform-aware paths
 const nativeNodePaths: Record<string, string> = {
     // `@ant` packages — macOS only. Path is used at compile time for Bun asset embedding.
     // Runtime: TS files check process.platform !== "darwin" and skip native load.
-    COMPUTER_USE_INPUT_NODE_PATH: join(repoRoot,
-        "packages/@ant/computer-use-input/prebuilds/arm64-darwin/computer-use-input.node"),
-    COMPUTER_USE_SWIFT_NODE_PATH: join(repoRoot,
-        "packages/@ant/computer-use-swift/prebuilds/arm64-darwin/computer_use.node"),
+    ...(process.platform === 'darwin' ? {
+        COMPUTER_USE_INPUT_NODE_PATH: join(repoRoot,
+            `packages/@ant/computer-use-input/prebuilds/${process.arch}-darwin/computer-use-input.node`),
+        COMPUTER_USE_SWIFT_NODE_PATH: join(repoRoot,
+            `packages/@ant/computer-use-swift/prebuilds/${process.arch}-darwin/computer_use.node`),
+    } : {}),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const nativeNodePaths: Record<string, string> = {
// @ant packages — macOS only. Path is used at compile time for Bun asset embedding.
// Runtime: TS files check process.platform !== "darwin" and skip native load.
COMPUTER_USE_INPUT_NODE_PATH: join(repoRoot,
"packages/@ant/computer-use-input/prebuilds/arm64-darwin/computer-use-input.node"),
COMPUTER_USE_SWIFT_NODE_PATH: join(repoRoot,
"packages/@ant/computer-use-swift/prebuilds/arm64-darwin/computer_use.node"),
// vendor modules — cross-platform (win32/linux/darwin)
AUDIO_CAPTURE_NODE_PATH: join(repoRoot,
`vendor/audio-capture/${process.arch}-${process.platform}/audio-capture.node`),
IMAGE_PROCESSOR_NODE_PATH: join(repoRoot,
`vendor/image-processor/${process.arch}-${process.platform}/image-processor.node`),
// modifiers and url-handler are macOS only — paths point to darwin builds
MODIFIERS_NODE_PATH: join(repoRoot,
`vendor/modifiers-napi/${process.arch}-darwin/modifiers.node`),
URL_HANDLER_NODE_PATH: join(repoRoot,
`vendor/url-handler/${process.arch}-darwin/url-handler.node`),
};
const nativeNodePaths: Record<string, string> = {
// `@ant` packages — macOS only. Path is used at compile time for Bun asset embedding.
// Runtime: TS files check process.platform !== "darwin" and skip native load.
...(process.platform === 'darwin' ? {
COMPUTER_USE_INPUT_NODE_PATH: join(repoRoot,
`packages/@ant/computer-use-input/prebuilds/${process.arch}-darwin/computer-use-input.node`),
COMPUTER_USE_SWIFT_NODE_PATH: join(repoRoot,
`packages/@ant/computer-use-swift/prebuilds/${process.arch}-darwin/computer_use.node`),
} : {}),
// vendor modules — cross-platform (win32/linux/darwin)
AUDIO_CAPTURE_NODE_PATH: join(repoRoot,
`vendor/audio-capture/${process.arch}-${process.platform}/audio-capture.node`),
IMAGE_PROCESSOR_NODE_PATH: join(repoRoot,
`vendor/image-processor/${process.arch}-${process.platform}/image-processor.node`),
// modifiers and url-handler are macOS only — paths point to darwin builds
MODIFIERS_NODE_PATH: join(repoRoot,
`vendor/modifiers-napi/${process.arch}-darwin/modifiers.node`),
URL_HANDLER_NODE_PATH: join(repoRoot,
`vendor/url-handler/${process.arch}-darwin/url-handler.node`),
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@compile.ts` around lines 50 - 68, nativeNodePaths contains hardcoded
arm64-darwin values causing cross-platform build failures; update the path
construction for COMPUTER_USE_INPUT_NODE_PATH, COMPUTER_USE_SWIFT_NODE_PATH,
MODIFIERS_NODE_PATH, and URL_HANDLER_NODE_PATH inside the nativeNodePaths object
to choose architecture/platform dynamically (use process.arch and
process.platform like the AUDIO_CAPTURE_NODE_PATH/IMAGE_PROCESSOR_NODE_PATH
entries) so the join(repoRoot, ...) paths point to the correct prebuild/vendor
subfolders for the current platform; ensure any macOS-only modules still guard
runtime loads with process.platform checks and that the join and repoRoot
symbols remain used for path assembly.


// Build env with native paths (forward slashes for Bun compatibility)
const compileEnv: Record<string, string> = {
...process.env,
...Object.fromEntries(
Object.entries(nativeNodePaths).map(([k, v]) => [k, v.replace(/\\/g, "/")]),
),
};

// ─── Step 0: Generate ripgrep base64 asset ───────────────────────────────────
// Bun's bundler does not support ?url imports or arbitrary file embedding
// for non-.node files. The only reliable way to embed a binary into the
// compiled exe is to base64-encode it and store it as a JS string constant.
// At runtime, we decode to a temp file and execute.
async function generateRipgrepAsset() {
const rgCache = join(repoRoot,
`node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep`);

Comment on lines +83 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hardcoded SDK version will break on upgrades.

The SDK path includes a pinned version string @0.2.87+3c5d820c62823f0b that will become invalid when the dependency is upgraded. This pattern appears in multiple files:

  • compile.ts:85
  • compile.ts:136-137
  • src/utils/ripgrepAsset.ts:27-29
♻️ Proposed fix using dynamic resolution
+import { resolve as resolvePath } from 'path'
+
+// Dynamically resolve SDK path using require.resolve
+function getSdkPath(): string {
+  try {
+    // This resolves through node_modules correctly
+    const sdkMain = require.resolve('@anthropic-ai/claude-agent-sdk')
+    return resolvePath(sdkMain, '..')
+  } catch {
+    throw new Error('Could not resolve `@anthropic-ai/claude-agent-sdk`')
+  }
+}
+
 async function generateRipgrepAsset() {
-    const rgCache = join(repoRoot,
-        `node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep`);
+    const sdkRoot = getSdkPath()
+    const rgCache = join(sdkRoot, 'vendor/ripgrep')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@compile.ts` around lines 83 - 86, The hardcoded vendor path in
generateRipgrepAsset (and the other occurrences) will break on upgrades; replace
the literal join(...) that embeds `@anthropic-ai+claude-agent-sdk@0.2.87+...`
with dynamic module resolution (e.g., use require.resolve or import.meta.resolve
to locate the package root and then append `/vendor/ripgrep`), so the code
discovers the installed `@anthropic-ai/claude-agent-sdk` location at runtime;
apply the same change to the other occurrences (the generateRipgrepAsset
function and the references in src/utils/ripgrepAsset.ts) so they all derive the
SDK path dynamically instead of using a pinned version string.

const ripgrepBinaries: Record<string, string> = {}

// Map platform+arch to filename
const allPlatforms: Array<{ key: string; subdir: string; file: string }> = [
{ key: 'windows_x64', subdir: 'x64-win32', file: 'rg.exe' },
{ key: 'darwin_x64', subdir: 'x64-darwin', file: 'rg' },
{ key: 'darwin_arm64', subdir: 'arm64-darwin', file: 'rg' },
{ key: 'linux_x64', subdir: 'x64-linux', file: 'rg' },
{ key: 'linux_arm64', subdir: 'arm64-linux', file: 'rg' },
];

// Only embed the current platform's binary to minimize exe size.
// The other platforms are available in the SDK for dev-mode fallback.
const currentPlatformKey = (() => {
if (process.platform === 'win32') return 'windows_x64'
if (process.platform === 'darwin') return process.arch === 'arm64' ? 'darwin_arm64' : 'darwin_x64'
return process.arch === 'arm64' ? 'linux_arm64' : 'linux_x64'
})()

for (const { key, subdir, file } of allPlatforms) {
if (key !== currentPlatformKey) continue // Skip other platforms
const binPath = join(rgCache, subdir, file);
try {
const data = await readFile(binPath);
ripgrepBinaries[key] = data.toString('base64');
console.log(`Encoded ${key}: ${data.length} bytes -> ${Math.round(data.length * 1.37)} chars`);
} catch (e) {
console.warn(`Warning: could not read ${binPath}: ${e}`);
}
}

// Generate TypeScript asset file
const assetFile = join(repoRoot, "src", "utils", "ripgrepAssetBase64.ts");
const content = `/**
* AUTO-GENERATED by compile.ts — do not edit manually.
* Ripgrep binaries encoded as base64 strings.
* Decoded at runtime to temp files for execution.
*/
export const RIPGREP_BINARIES: Record<string, string> = ${JSON.stringify(ripgrepBinaries, null, 2)};
`;
await writeFile(assetFile, content);
console.log(`Generated ${assetFile}`);
}

// ─── Step 1: Patch SDK ripgrep path ───────────────────────────────────────────
// The SDK's cli.js computes dy_ from import.meta.url which points to B:\~BUN\root\...
// in --compile mode. Patch it to use path.dirname(process.execPath) instead.
async function patchRipgrepPaths() {
// --- Patch bun cache SDK cli.js ---
const sdkCachePath = join(repoRoot,
"node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/cli.js");
const sdkContent = await readFile(sdkCachePath, "utf-8");
const patchedSdk = sdkContent
.replace(
/import\{fileURLToPath as Uy_\}from"url";/,
";",
)
.replace(
/dy_=Uy_\(import\.meta\.url\),dy_=Z16\.join\(dy_,"\.\/"\)/,
"dy_=Z16.dirname(process.execPath)",
);
if (patchedSdk === sdkContent) {
console.warn("Warning: SDK patch did not match");
} else {
await writeFile(sdkCachePath, patchedSdk);
console.log("Patched SDK cli.js (bun cache)");
}
Comment on lines +139 to +153
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fragile regex patching of minified SDK code.

Patching minified JavaScript using regex patterns like Uy_ and dy_=Uy_ is extremely brittle. Any SDK update that changes minification output, variable naming, or code structure will silently break this patch. The warning at line 148-149 only logs; the build continues with potentially broken ripgrep resolution.

Consider:

  1. Failing the build if patch doesn't match instead of just warning
  2. Using AST-based patching for robustness
  3. Documenting the exact SDK version this targets and adding version checks
🛡️ Proposed fix to fail on patch mismatch
     if (patchedSdk === sdkContent) {
-        console.warn("Warning: SDK patch did not match");
+        console.error("ERROR: SDK patch did not match. The SDK may have been updated.");
+        console.error("Expected patterns: 'import{fileURLToPath as Uy_}from\"url\"' and 'dy_=Uy_(import.meta.url)'");
+        exit(1);
     } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const patchedSdk = sdkContent
.replace(
/import\{fileURLToPath as Uy_\}from"url";/,
";",
)
.replace(
/dy_=Uy_\(import\.meta\.url\),dy_=Z16\.join\(dy_,"\.\/"\)/,
"dy_=Z16.dirname(process.execPath)",
);
if (patchedSdk === sdkContent) {
console.warn("Warning: SDK patch did not match");
} else {
await writeFile(sdkCachePath, patchedSdk);
console.log("Patched SDK cli.js (bun cache)");
}
if (patchedSdk === sdkContent) {
console.error("ERROR: SDK patch did not match. The SDK may have been updated.");
console.error("Expected patterns: 'import{fileURLToPath as Uy_}from\"url\"' and 'dy_=Uy_(import.meta.url)'");
process.exit(1);
} else {
await writeFile(sdkCachePath, patchedSdk);
console.log("Patched SDK cli.js (bun cache)");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@compile.ts` around lines 139 - 153, The regex-based patching of the minified
SDK (operating on sdkContent to produce patchedSdk using patterns matching Uy_
and dy_=Uy_) is brittle and currently only emits a console.warn if no
replacement occurred; change this to fail the build by throwing or exiting with
a non-zero status when patchedSdk === sdkContent so the CI stops on mismatch,
and add a clear error message including sdkCachePath and the original fragment
names (Uy_, dy_=Uy_) to aid debugging; additionally, replace this fragile
approach in the long term by switching the patching logic from regex to an
AST-based transform (or at minimum add an SDK version check and a documented
targeted version comment near the patch code and function writeFile usage) so
future minifier/name changes are detected and handled safely.

}

// ─── Step 2: Run the compile ───────────────────────────────────────────────────
async function run() {
await generateRipgrepAsset();
await patchRipgrepPaths();

console.log("\nCompiling standalone executable with native modules...");
console.log(`Outfile: ${outfile}`);
console.log(`Defines: ${Object.keys(defines).join(", ")}`);
console.log(`Native modules:`);
for (const [k, v] of Object.entries(nativeNodePaths)) {
console.log(` ${k}=${v}`);
}

// Use Bun.spawn with CLI because Bun.build({ outfile, compile: true })
// does not reliably place the output file on Windows.
const result = Bun.spawnSync(
[
bunExe,
"build",
"--compile",
"--outfile=" + outfile,
...defineArgsWithBundled,
...featureArgs,
"src/entrypoints/cli.tsx",
],
{
stdio: ["inherit", "inherit", "inherit"],
env: compileEnv,
},
);

if (result.exitCode !== 0) {
console.error("Compile failed with exit code:", result.exitCode);
exit(1);
}

console.log(`\nCompiled standalone executable: ${outfile}`);
if (features.length > 0) {
console.log(`Features enabled: ${features.join(", ")}`);
}
}

run();
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
],
"scripts": {
"build": "bun run build.ts",
"compile": "bun run compile.ts",
"dev": "bun run scripts/dev.ts",
"dev:inspect": "bun run scripts/dev-debug.ts",
"prepublishOnly": "bun run build",
Expand Down
2 changes: 1 addition & 1 deletion scripts/download-ripgrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const RG_VERSION = '15.0.1'
const BASE_URL = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
const BASE_URL = `https://gh-proxy.com/github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Supply chain risk: using third-party GitHub proxy.

Using gh-proxy.com introduces a man-in-the-middle vector. If this proxy is compromised, malicious binaries could be served. Consider:

  1. Using the official GitHub URL (https://github.com/microsoft/ripgrep-prebuilt/releases/download/...)
  2. If proxy is needed for regional access, add checksum verification after download
🔒 Proposed fix using official GitHub URL
-const BASE_URL = `https://gh-proxy.com/github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
+const BASE_URL = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`

If regional access is needed, consider adding SHA256 verification:

const EXPECTED_CHECKSUMS: Record<string, string> = {
  'ripgrep-v15.0.1-aarch64-apple-darwin.tar.gz': '<sha256>',
  // ... other platforms
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/download-ripgrep.ts` at line 21, Replace the third‑party proxy
BASE_URL (`BASE_URL = ...gh-proxy.com...`) to use the official GitHub release
URL constructed with RG_VERSION, and add SHA256 checksum verification after the
file is downloaded: create an EXPECTED_CHECKSUMS mapping keyed by the release
asset filename, compute the downloaded file's SHA256, compare against
EXPECTED_CHECKSUMS entry, and abort (throw/log and exit) if the checksum does
not match before any extraction or execution; update the download/verify flow
where the asset is fetched to perform this check.


// --- Platform mapping ---

Expand Down
7 changes: 6 additions & 1 deletion src/utils/bundledMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ export function isRunningWithBun(): boolean {

/**
* Detects if running as a Bun-compiled standalone executable.
* This checks for embedded files which are present in compiled binaries.
*
* Primary check: Bun.embeddedFiles (present in compiled binaries).
* Fallback: BUNDLED_MODE compile-time constant injected by compile.ts.
*/
// BUNDLED_MODE is injected at compile time by compile.ts --define flag.
declare const BUNDLED_MODE: string | undefined
export function isInBundledMode(): boolean {
if (typeof BUNDLED_MODE !== 'undefined') return true
Comment on lines 12 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring is inconsistent with implementation.

The comment says "Primary check: Bun.embeddedFiles" and "Fallback: BUNDLED_MODE", but the implementation does the opposite—BUNDLED_MODE is checked first (line 21) and Bun.embeddedFiles is the fallback.

📝 Proposed fix for docstring accuracy
 /**
  * Detects if running as a Bun-compiled standalone executable.
  *
- * Primary check: Bun.embeddedFiles (present in compiled binaries).
- * Fallback: BUNDLED_MODE compile-time constant injected by compile.ts.
+ * Primary check: BUNDLED_MODE compile-time constant injected by compile.ts.
+ * Fallback: Bun.embeddedFiles (present in compiled binaries with assets).
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Detects if running as a Bun-compiled standalone executable.
* This checks for embedded files which are present in compiled binaries.
*
* Primary check: Bun.embeddedFiles (present in compiled binaries).
* Fallback: BUNDLED_MODE compile-time constant injected by compile.ts.
*/
// BUNDLED_MODE is injected at compile time by compile.ts --define flag.
declare const BUNDLED_MODE: string | undefined
export function isInBundledMode(): boolean {
if (typeof BUNDLED_MODE !== 'undefined') return true
/**
* Detects if running as a Bun-compiled standalone executable.
*
* Primary check: BUNDLED_MODE compile-time constant injected by compile.ts.
* Fallback: Bun.embeddedFiles (present in compiled binaries with assets).
*/
// BUNDLED_MODE is injected at compile time by compile.ts --define flag.
declare const BUNDLED_MODE: string | undefined
export function isInBundledMode(): boolean {
if (typeof BUNDLED_MODE !== 'undefined') return true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/bundledMode.ts` around lines 12 - 21, The docstring for
isInBundledMode is inconsistent with the implementation: the code checks the
compile-time BUNDLED_MODE first and Bun.embeddedFiles second, so update the
comment to state "Primary check: BUNDLED_MODE (compile-time constant) and
Fallback: Bun.embeddedFiles" and adjust any wording to reference the
BUNDLED_MODE declaration and the isInBundledMode function so the comment
accurately reflects the code flow.

return (
typeof Bun !== 'undefined' &&
Array.isArray(Bun.embeddedFiles) &&
Expand Down
13 changes: 5 additions & 8 deletions src/utils/ripgrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as path from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { fileURLToPath } from 'url'
import { isInBundledMode } from './bundledMode.js'
import { getRipgrepBinaryPath } from './ripgrepAsset.js'
import { logForDebugging } from './debug.js'
import { isEnvDefinedFalsy } from './envUtils.js'
import { execFileNoThrow } from './execFileNoThrow.js'
Expand Down Expand Up @@ -44,15 +45,11 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
}
}

// In bundled (native) mode, ripgrep is statically compiled into bun-internal
// and dispatches based on argv[0]. We spawn ourselves with argv0='rg'.
// In bundled mode (compiled exe), ripgrep is embedded via base64.
// Extract to temp and execute from there.
if (isInBundledMode()) {
return {
mode: 'embedded',
command: process.execPath,
args: ['--no-config'],
argv0: 'rg',
}
const rgPath = getRipgrepBinaryPath()
return { mode: 'builtin', command: rgPath, args: [] }
Comment on lines +48 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Bundled mode now displays as "vendor" in Doctor UI.

Per context snippet 2 (src/screens/Doctor.tsx:313-320), the Doctor diagnostic maps modes:

  • "embedded""bundled" (old behavior)
  • "builtin""vendor" (new behavior)

With this change, compiled executables will show as "vendor" rather than "bundled", which may confuse users expecting to see bundled mode indicated. Consider whether the Doctor UI mapping should be updated or if a distinct mode value is needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/ripgrep.ts` around lines 48 - 52, The current bundled extraction
branch in isInBundledMode() returns mode: 'builtin' (see getRipgrepBinaryPath()
usage), which now maps to "vendor" in the Doctor UI and misrepresents compiled
executables; change the returned mode value from 'builtin' to a distinct value
that preserves the previous UI label (e.g., 'embedded' or 'bundled') or add a
new mode like 'bundled' so Doctor.tsx's mapping (which currently translates
'embedded'→'bundled' and 'builtin'→'vendor') can correctly display
compiled/embedded ripgrep; update the return in the bundled branch to use that
chosen mode and adjust any consumers expecting 'builtin' as needed.

}

const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
Expand Down
122 changes: 122 additions & 0 deletions src/utils/ripgrepAsset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Gets the ripgrep binary path for the current platform/arch.
*
* In compiled mode: decodes base64 from ripgrepAssetBase64.ts, writes to temp,
* and caches on disk so subsequent starts skip the decode.
*
* In dev mode: falls back to SDK's bundled ripgrep path.
*
* BUNDLED_MODE is a compile-time constant injected by compile.ts --define flag.
*/
import { writeFileSync, readFileSync } from 'fs'
import { mkdirSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { getPlatform } from './platform.js'

// In-memory cache: platform+arch -> absolute path to extracted temp file
const extractedPaths: Record<string, string> = {}

// Global base64 data — loaded once on first access
let globalBase64: Record<string, string> | null = null

// SDK's bundled ripgrep path (used as fallback in dev mode)
function getSdkRipgrepPath(): string {
const p = getPlatform()
const arch = process.arch
if (p === 'windows') return join(process.cwd(), 'node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep/x64-win32/rg.exe')
if (p === 'macos') return join(process.cwd(), 'node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep', arch === 'arm64' ? 'arm64-darwin/rg' : 'x64-darwin/rg')
return join(process.cwd(), 'node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep', arch === 'arm64' ? 'arm64-linux/rg' : 'x64-linux/rg')
}

function getPlatformKey(): string {
const platform = getPlatform()
const arch = process.arch
if (platform === 'windows') return 'windows_x64'
if (platform === 'macos') return arch === 'arm64' ? 'darwin_arm64' : 'darwin_x64'
return arch === 'arm64' ? 'linux_arm64' : 'linux_x64'
}
Comment on lines +32 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Windows ARM64 architecture not supported.

getPlatformKey() always returns 'windows_x64' for Windows regardless of process.arch. ARM64 Windows devices (e.g., Surface Pro X, newer Qualcomm-based laptops) would attempt to run an x64 binary, which may fail or run under emulation with degraded performance.

♻️ Proposed fix to support Windows ARM64
 function getPlatformKey(): string {
   const platform = getPlatform()
   const arch = process.arch
-  if (platform === 'windows') return 'windows_x64'
+  if (platform === 'windows') return arch === 'arm64' ? 'windows_arm64' : 'windows_x64'
   if (platform === 'macos') return arch === 'arm64' ? 'darwin_arm64' : 'darwin_x64'
   return arch === 'arm64' ? 'linux_arm64' : 'linux_x64'
 }

Note: This also requires adding 'windows_arm64' to allPlatforms in compile.ts and sourcing the ARM64 ripgrep binary.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getPlatformKey(): string {
const platform = getPlatform()
const arch = process.arch
if (platform === 'windows') return 'windows_x64'
if (platform === 'macos') return arch === 'arm64' ? 'darwin_arm64' : 'darwin_x64'
return arch === 'arm64' ? 'linux_arm64' : 'linux_x64'
}
function getPlatformKey(): string {
const platform = getPlatform()
const arch = process.arch
if (platform === 'windows') return arch === 'arm64' ? 'windows_arm64' : 'windows_x64'
if (platform === 'macos') return arch === 'arm64' ? 'darwin_arm64' : 'darwin_x64'
return arch === 'arm64' ? 'linux_arm64' : 'linux_x64'
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/ripgrepAsset.ts` around lines 32 - 38, getPlatformKey currently
always returns 'windows_x64' for Windows and ignores process.arch, breaking
Windows ARM64 support; update getPlatformKey (which calls getPlatform and uses
process.arch) to return 'windows_arm64' when getPlatform() === 'windows' and
process.arch === 'arm64', otherwise keep existing mappings for darwin and linux;
after this change also add 'windows_arm64' to the allPlatforms list in
compile.ts and ensure the ARM64 ripgrep binary is sourced for packaging.


// BUNDLED_MODE is injected at compile time by compile.ts --define flag.
// In dev mode, this variable is undefined.
declare const BUNDLED_MODE: string | undefined

/**
* Load base64 data asynchronously (first call only).
* Subsequent calls use the cached global.
*/
async function ensureBase64Loaded(): Promise<Record<string, string>> {
if (globalBase64 !== null) return globalBase64
// Dynamic import so the 6.9MB base64 string isn't loaded in dev mode
const mod = await import('./ripgrepAssetBase64.js')
globalBase64 = mod.RIPGREP_BINARIES ?? {}
return globalBase64
}

/**
* Get the ripgrep binary path for the current platform/arch.
* In compiled mode: decodes base64, extracts to temp, caches by version fingerprint.
* In dev mode: returns SDK path directly.
*/
export function getRipgrepBinaryPath(): string {
const key = getPlatformKey()
if (extractedPaths[key]) return extractedPaths[key]

const tmpDir = join(tmpdir(), 'claude-code-ripgrep')
const filename = key === 'windows_x64' ? 'rg.exe' : 'rg'
const filePath = join(tmpDir, filename)
const versionPath = join(tmpDir, `${key}.version`)

// Dev mode: use SDK path directly
if (typeof BUNDLED_MODE === 'undefined') {
const sdkPath = getSdkRipgrepPath()
extractedPaths[key] = sdkPath
return sdkPath
}

// Compiled mode: must use base64 decode (synchronous path — loaded eagerly from embedded module)
// In the compiled exe, require() resolves to the embedded ripgrepAssetBase64.js
let base64Data: string | undefined
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const RIPGREP_BINARIES: Record<string, string> = require('./ripgrepAssetBase64.js').RIPGREP_BINARIES
base64Data = RIPGREP_BINARIES[key]
} catch {
// require failed — fall back to SDK path
}

if (!base64Data) {
const sdkPath = getSdkRipgrepPath()
extractedPaths[key] = sdkPath
return sdkPath
}

const versionTag = `b64:${base64Data.length}:${base64Data.slice(0, 16)}:${base64Data.slice(-16)}`

// Fast cache check: read only the version tag (~50 bytes)
try {
const storedTag = readFileSync(versionPath, 'utf8')
if (storedTag === versionTag && readFileSync(filePath)) {
extractedPaths[key] = filePath
return filePath
}
} catch {
// Cache miss or stale
}
Comment on lines +96 to +105
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cache validation doesn't verify binary integrity.

The cache check at line 99 calls readFileSync(filePath) but doesn't verify the returned buffer is non-empty or valid. An empty or corrupted file would pass the existence check, and the function would return a path to a broken binary.

🛡️ Proposed fix for stricter cache validation
   try {
     const storedTag = readFileSync(versionPath, 'utf8')
-    if (storedTag === versionTag && readFileSync(filePath)) {
+    const fileContent = readFileSync(filePath)
+    // Verify version match and non-empty binary
+    if (storedTag === versionTag && fileContent.length > 0) {
       extractedPaths[key] = filePath
       return filePath
     }
   } catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/ripgrepAsset.ts` around lines 96 - 105, The cache fast-path returns
filePath after calling readFileSync(filePath) without verifying the file
contents; change the check so that after confirming storedTag === versionTag you
read the file into a variable (using readFileSync) and validate it is non-empty
(size/length > 0) and ideally stat the file (fs.statSync(filePath)) to ensure
size > 0 and executable permission before setting extractedPaths[key] = filePath
and returning; on any validation failure fall through to the extraction path and
keep the existing try/catch behavior.


// Decode and extract
mkdirSync(tmpDir, { recursive: true })
const buffer = Buffer.from(base64Data, 'base64')
writeFileSync(filePath, buffer, { mode: 0o755 })
writeFileSync(versionPath, versionTag, 'utf8')
extractedPaths[key] = filePath
return filePath
}

/**
* Async version — preloads base64 data before extracting.
* Call this early (e.g., during startup) to avoid decode delay on first grep.
*/
export async function preloadRipgrepBinary(): Promise<void> {
getRipgrepBinaryPath()
}
8 changes: 8 additions & 0 deletions src/utils/ripgrepAssetBase64.ts

Large diffs are not rendered by default.