@empir3/empir3-bridge 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
package/src/config.ts ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Bridge config — BYO-key (Anthropic API) or BYO-CLI (claude binary).
3
+ *
4
+ * Persisted at ~/.empir3-bridge/config.json. Created on first read with
5
+ * sensible defaults so a fresh user never sees a "config missing" error.
6
+ *
7
+ * Default mode = 'cli' if `claude` is on PATH, else 'api'. Either mode is
8
+ * fine; the chat loop branches on `mode` and the unused field stays empty.
9
+ *
10
+ * Secrets (anthropicApiKey) live in this file. chmod 600 on write best-effort
11
+ * — Windows ignores POSIX modes, but per-user data dir already gates access.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
15
+ import { join } from 'path';
16
+ import { homedir } from 'os';
17
+ import { execSync } from 'child_process';
18
+ import { defaultEnabledTools, ALL_TOOL_NAMES } from './tool-defaults.js';
19
+
20
+ export type ChatMode = 'api' | 'cli';
21
+
22
+ export interface BridgeConfig {
23
+ mode: ChatMode;
24
+ anthropicApiKey: string; // empty when not set — legacy field, mirrored into apiKeys.anthropic
25
+ claudeCliPath: string; // empty = let cli-runner auto-detect
26
+ model: string; // claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5
27
+ maxTokens: number;
28
+ systemPrompt: string; // empty = use built-in
29
+ enabledTools: Record<string, boolean>;
30
+ maxLoopIterations: number; // safety cap on tool-use loop
31
+ // Provider API keys. Each empty when unset. The legacy `anthropicApiKey`
32
+ // field stays for backward-compat with older configs + the API-mode chat
33
+ // path; new providers (openai, google, xai) only flow through here.
34
+ apiKeys: {
35
+ anthropic: string;
36
+ openai: string;
37
+ google: string;
38
+ xai: string;
39
+ };
40
+ }
41
+
42
+ const CONFIG_DIR = join(homedir(), '.empir3-bridge');
43
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
44
+ const LEGACY_CONFIG_FILE = join(homedir(), '.claude-bridge', 'config.json');
45
+
46
+ // Sonnet by default — it's the right cost/quality tradeoff for browser
47
+ // automation chat. Users pick Opus in /settings when they want it.
48
+ const DEFAULT_MODEL = 'claude-sonnet-4-6';
49
+ const DEFAULT_MAX_TOKENS = 8192;
50
+ const DEFAULT_LOOP_CAP = 20;
51
+
52
+ function detectClaudeOnPath(): string | null {
53
+ try {
54
+ const cmd = process.platform === 'win32' ? 'where claude' : 'which claude';
55
+ const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
56
+ const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
57
+ if (lines.length === 0) return null;
58
+ // On Windows, `where` returns both the unix shim (`claude`) and the
59
+ // batch shim (`claude.cmd`). Node's spawn can only run `.cmd` directly
60
+ // — the bare unix file is a shell script and ENOENTs. Prefer `.cmd`.
61
+ if (process.platform === 'win32') {
62
+ const cmdShim = lines.find(l => l.toLowerCase().endsWith('.cmd'));
63
+ if (cmdShim) return cmdShim;
64
+ }
65
+ return lines[0];
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function defaultConfig(): BridgeConfig {
72
+ const cliPath = detectClaudeOnPath();
73
+ return {
74
+ mode: cliPath ? 'cli' : 'api',
75
+ anthropicApiKey: '',
76
+ claudeCliPath: cliPath || '',
77
+ model: DEFAULT_MODEL,
78
+ maxTokens: DEFAULT_MAX_TOKENS,
79
+ systemPrompt: '',
80
+ enabledTools: defaultEnabledTools(),
81
+ maxLoopIterations: DEFAULT_LOOP_CAP,
82
+ apiKeys: { anthropic: '', openai: '', google: '', xai: '' },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Merge persisted config over defaults so fields added in newer versions
88
+ * always have a value, and per-tool toggles for tools the user has never
89
+ * seen pick up the default (rather than being silently disabled because
90
+ * the JSON is missing the key).
91
+ */
92
+ function hydrate(raw: Partial<BridgeConfig>): BridgeConfig {
93
+ const base = defaultConfig();
94
+ const enabled = { ...base.enabledTools };
95
+ if (raw.enabledTools && typeof raw.enabledTools === 'object') {
96
+ // Migration: openai_chat → custom_llm (renamed pre-public-launch to
97
+ // avoid collision with OpenAI Codex CLI, which is unrelated). If the
98
+ // old key is present and the new key has not been explicitly set,
99
+ // carry the user's prior choice forward. The old key falls out
100
+ // automatically — it's not in ALL_TOOL_NAMES anymore.
101
+ const legacy: any = raw.enabledTools;
102
+ if (typeof legacy.openai_chat === 'boolean' && typeof legacy.custom_llm !== 'boolean') {
103
+ legacy.custom_llm = legacy.openai_chat;
104
+ }
105
+ for (const k of ALL_TOOL_NAMES) {
106
+ if (typeof raw.enabledTools[k] === 'boolean') enabled[k] = raw.enabledTools[k];
107
+ }
108
+ }
109
+ const rawKeys: any = (raw as any).apiKeys || {};
110
+ const legacyAnthropic = typeof raw.anthropicApiKey === 'string' ? raw.anthropicApiKey : '';
111
+ const apiKeys = {
112
+ // Legacy field wins if the new field is empty — keeps old configs working.
113
+ anthropic: typeof rawKeys.anthropic === 'string' && rawKeys.anthropic ? rawKeys.anthropic : legacyAnthropic,
114
+ openai: typeof rawKeys.openai === 'string' ? rawKeys.openai : '',
115
+ google: typeof rawKeys.google === 'string' ? rawKeys.google : '',
116
+ xai: typeof rawKeys.xai === 'string' ? rawKeys.xai : '',
117
+ };
118
+ return {
119
+ mode: raw.mode === 'api' || raw.mode === 'cli' ? raw.mode : base.mode,
120
+ anthropicApiKey: apiKeys.anthropic,
121
+ claudeCliPath: typeof raw.claudeCliPath === 'string' && raw.claudeCliPath ? raw.claudeCliPath : base.claudeCliPath,
122
+ model: typeof raw.model === 'string' && raw.model ? raw.model : base.model,
123
+ maxTokens: typeof raw.maxTokens === 'number' && raw.maxTokens > 0 ? raw.maxTokens : base.maxTokens,
124
+ systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '',
125
+ enabledTools: enabled,
126
+ maxLoopIterations: typeof raw.maxLoopIterations === 'number' && raw.maxLoopIterations > 0
127
+ ? raw.maxLoopIterations : base.maxLoopIterations,
128
+ apiKeys,
129
+ };
130
+ }
131
+
132
+ export function loadConfig(): BridgeConfig {
133
+ try {
134
+ const file = existsSync(CONFIG_FILE)
135
+ ? CONFIG_FILE
136
+ : existsSync(LEGACY_CONFIG_FILE)
137
+ ? LEGACY_CONFIG_FILE
138
+ : '';
139
+ if (!file) return defaultConfig();
140
+ const raw = JSON.parse(readFileSync(file, 'utf-8'));
141
+ return hydrate(raw);
142
+ } catch {
143
+ return defaultConfig();
144
+ }
145
+ }
146
+
147
+ export function saveConfig(patch: Partial<BridgeConfig>): BridgeConfig {
148
+ const current = loadConfig();
149
+ const mergedKeys = { ...current.apiKeys, ...((patch as any).apiKeys || {}) };
150
+ const next: BridgeConfig = {
151
+ ...current,
152
+ ...patch,
153
+ enabledTools: { ...current.enabledTools, ...(patch.enabledTools || {}) },
154
+ apiKeys: mergedKeys,
155
+ // Keep the legacy field in sync with the new one so any consumer still
156
+ // reading anthropicApiKey directly continues to work.
157
+ anthropicApiKey: mergedKeys.anthropic,
158
+ };
159
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
160
+ writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2));
161
+ try { chmodSync(CONFIG_FILE, 0o600); } catch { /* Windows; best-effort */ }
162
+ return next;
163
+ }
164
+
165
+ /**
166
+ * Sanitized config for the /api/config GET response — NEVER returns the raw
167
+ * API key, just whether one is set. Settings UI uses this to render a
168
+ * masked field that re-asks for the key only when the user wants to change it.
169
+ */
170
+ export function publicConfig(c: BridgeConfig = loadConfig()) {
171
+ return {
172
+ mode: c.mode,
173
+ anthropicApiKeySet: c.apiKeys.anthropic.length > 0,
174
+ claudeCliPath: c.claudeCliPath,
175
+ model: c.model,
176
+ maxTokens: c.maxTokens,
177
+ systemPrompt: c.systemPrompt,
178
+ enabledTools: c.enabledTools,
179
+ maxLoopIterations: c.maxLoopIterations,
180
+ apiKeysSet: {
181
+ anthropic: c.apiKeys.anthropic.length > 0,
182
+ openai: c.apiKeys.openai.length > 0,
183
+ google: c.apiKeys.google.length > 0,
184
+ xai: c.apiKeys.xai.length > 0,
185
+ },
186
+ };
187
+ }
188
+
189
+ export function configReady(c: BridgeConfig = loadConfig()): { ready: boolean; reason?: string } {
190
+ if (c.mode === 'api') {
191
+ if (!c.anthropicApiKey) return { ready: false, reason: 'Anthropic API key not set. Open localhost:3006/settings to configure.' };
192
+ return { ready: true };
193
+ }
194
+ if (c.mode === 'cli') {
195
+ if (!c.claudeCliPath) return { ready: false, reason: '`claude` CLI not found on PATH. Install Claude Code or switch to API mode at localhost:3006/settings.' };
196
+ return { ready: true };
197
+ }
198
+ return { ready: false, reason: `Unknown mode: ${c.mode}` };
199
+ }
@@ -0,0 +1,121 @@
1
+ param(
2
+ [Parameter(Mandatory=$true)] [string]$SnapshotPath
3
+ )
4
+
5
+ $ErrorActionPreference = 'Continue'
6
+
7
+ Add-Type @"
8
+ using System;
9
+ using System.Runtime.InteropServices;
10
+ public static class Empir3OverlayDpi {
11
+ [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
12
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
13
+ [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
14
+ [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
15
+ }
16
+ "@
17
+ try { [Empir3OverlayDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {
18
+ try { [Empir3OverlayDpi]::SetProcessDPIAware() | Out-Null } catch {}
19
+ }
20
+
21
+ Add-Type -AssemblyName System.Windows.Forms
22
+ Add-Type -AssemblyName System.Drawing
23
+
24
+ # Calculate union of all screens for fullscreen overlay across multi-monitor
25
+ $screens = [System.Windows.Forms.Screen]::AllScreens
26
+ $minX = ($screens | ForEach-Object { $_.Bounds.Left } | Measure-Object -Minimum).Minimum
27
+ $minY = ($screens | ForEach-Object { $_.Bounds.Top } | Measure-Object -Minimum).Minimum
28
+ $maxX = ($screens | ForEach-Object { $_.Bounds.Right } | Measure-Object -Maximum).Maximum
29
+ $maxY = ($screens | ForEach-Object { $_.Bounds.Bottom } | Measure-Object -Maximum).Maximum
30
+ $vWidth = $maxX - $minX
31
+ $vHeight = $maxY - $minY
32
+
33
+ $script:elements = @()
34
+ $script:lastMTime = $null
35
+
36
+ function Load-Snapshot {
37
+ if (-not (Test-Path $SnapshotPath)) { return $false }
38
+ try {
39
+ $mtime = (Get-Item $SnapshotPath).LastWriteTime
40
+ if ($script:lastMTime -and $mtime -eq $script:lastMTime) { return $false }
41
+ $script:lastMTime = $mtime
42
+ $raw = Get-Content $SnapshotPath -Raw
43
+ $data = $raw | ConvertFrom-Json
44
+ $script:elements = $data.elements
45
+ return $true
46
+ } catch { return $false }
47
+ }
48
+
49
+ Load-Snapshot | Out-Null
50
+
51
+ $form = New-Object System.Windows.Forms.Form
52
+ $form.FormBorderStyle = 'None'
53
+ $form.StartPosition = 'Manual'
54
+ $form.Location = New-Object System.Drawing.Point($minX, $minY)
55
+ $form.Size = New-Object System.Drawing.Size($vWidth, $vHeight)
56
+ $form.TopMost = $true
57
+ $form.ShowInTaskbar = $false
58
+ $form.BackColor = [System.Drawing.Color]::Magenta
59
+ $form.TransparencyKey = [System.Drawing.Color]::Magenta
60
+ # Opacity 1.0: rely purely on the TransparencyKey for see-through. Box interiors
61
+ # are left transparent (no fill) so the user can read the UI behind them; only
62
+ # the borders + number labels are painted opaque.
63
+ $form.Opacity = 1.0
64
+
65
+ $form.Add_Shown({
66
+ $hwnd = $form.Handle
67
+ $GWL_EXSTYLE = -20
68
+ $WS_EX_TRANSPARENT = 0x20
69
+ $WS_EX_LAYERED = 0x80000
70
+ $WS_EX_NOACTIVATE = 0x08000000
71
+ $WS_EX_TOOLWINDOW = 0x80
72
+ $current = [Empir3OverlayDpi]::GetWindowLong($hwnd, $GWL_EXSTYLE)
73
+ $new = $current -bor $WS_EX_TRANSPARENT -bor $WS_EX_LAYERED -bor $WS_EX_NOACTIVATE -bor $WS_EX_TOOLWINDOW
74
+ [Empir3OverlayDpi]::SetWindowLong($hwnd, $GWL_EXSTYLE, $new) | Out-Null
75
+ })
76
+
77
+ $form.Add_Paint({
78
+ param($sender, $e)
79
+ $g = $e.Graphics
80
+ $g.SmoothingMode = 'AntiAlias'
81
+ $g.TextRenderingHint = 'ClearTypeGridFit'
82
+
83
+ $boxColor = [System.Drawing.Color]::FromArgb(255, 32, 220, 120)
84
+ $labelBg = [System.Drawing.Color]::FromArgb(255, 18, 18, 28)
85
+ $labelFg = [System.Drawing.Color]::FromArgb(255, 240, 255, 240)
86
+ $boxPen = New-Object System.Drawing.Pen($boxColor, 3)
87
+ $labelBgBrush = New-Object System.Drawing.SolidBrush($labelBg)
88
+ $labelFgBrush = New-Object System.Drawing.SolidBrush($labelFg)
89
+ $font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold)
90
+
91
+ foreach ($el in $script:elements) {
92
+ if (-not $el.bounds) { continue }
93
+ $x = [int]$el.bounds.x - $minX
94
+ $y = [int]$el.bounds.y - $minY
95
+ $w = [int]$el.bounds.width
96
+ $h = [int]$el.bounds.height
97
+ if ($w -le 0 -or $h -le 0) { continue }
98
+
99
+ $rect = New-Object System.Drawing.Rectangle($x, $y, $w, $h)
100
+ # No interior fill — keep it see-through. Border + label only.
101
+ $g.DrawRectangle($boxPen, $rect)
102
+
103
+ # Label "d0" in a chip at top-left of the box
104
+ $label = $el.ref
105
+ $textSize = $g.MeasureString($label, $font)
106
+ $labelRect = New-Object System.Drawing.Rectangle($x, ($y - [int]$textSize.Height - 2), ([int]$textSize.Width + 10), ([int]$textSize.Height + 2))
107
+ if ($labelRect.Y -lt 0) { $labelRect.Y = $y + 1 }
108
+ $g.FillRectangle($labelBgBrush, $labelRect)
109
+ $g.DrawString($label, $font, $labelFgBrush, ($labelRect.X + 5), $labelRect.Y)
110
+ }
111
+ })
112
+
113
+ # Watcher: poll snapshot file every 750ms; re-paint if changed
114
+ $timer = New-Object System.Windows.Forms.Timer
115
+ $timer.Interval = 750
116
+ $timer.Add_Tick({
117
+ if (Load-Snapshot) { $form.Invalidate() }
118
+ })
119
+ $timer.Start()
120
+
121
+ [System.Windows.Forms.Application]::Run($form)
@@ -0,0 +1,330 @@
1
+ /**
2
+ * In-process executable resolution — the single source of truth for "where is
3
+ * CLI <x> installed".
4
+ *
5
+ * Why not just `where.exe` / `which`? Those run with the *daemon's* PATH, which
6
+ * a tray / GUI-launched process inherits in a stripped, stale form: winget edits
7
+ * the user PATH after the daemon already started, node-version-managers expose
8
+ * their bins only in the interactive shell's PATH, etc. The symptom is a CLI
9
+ * that resolves fine from the user's terminal but reads NOT INSTALLED in the
10
+ * bridge (real incident: a winget-installed `gh`).
11
+ *
12
+ * The fix (mirrors open-design's runtimes/executables.ts): never shell out.
13
+ * Split `process.env.PATH` in-process, walk `PATHEXT`, and *augment* the search
14
+ * with a list of well-known user-toolchain dirs where global CLIs actually land
15
+ * — plus a per-CLI env override (`CLAUDE_BIN`, `GH_BIN`, …) as an escape hatch.
16
+ * One resolver, used by every CLI, so no detection path can drift again.
17
+ */
18
+ import { existsSync, readdirSync, statSync } from 'fs';
19
+ import { delimiter, join, extname } from 'path';
20
+ import { homedir } from 'os';
21
+
22
+ // Per-CLI explicit override: point detection at an exact binary when the
23
+ // conventional locations miss. Keyed by the bare CLI name we probe for.
24
+ const BIN_ENV_OVERRIDE: Record<string, string> = {
25
+ claude: 'CLAUDE_BIN',
26
+ codex: 'CODEX_BIN',
27
+ gemini: 'GEMINI_BIN',
28
+ grok: 'GROK_BIN',
29
+ gh: 'GH_BIN',
30
+ higgsfield: 'HIGGSFIELD_BIN',
31
+ agy: 'AGY_BIN',
32
+ };
33
+
34
+ function pathExts(): string[] {
35
+ if (process.platform !== 'win32') return [''];
36
+ // Append '' so an extensionless native-installer binary (e.g. a unix-style
37
+ // `claude` shim) is still found on Windows.
38
+ const fromEnv = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map(s => s.trim()).filter(Boolean);
39
+ return [...fromEnv, ''];
40
+ }
41
+
42
+ function pathDirs(): string[] {
43
+ return (process.env.PATH || '').split(delimiter).map(s => s.trim()).filter(Boolean);
44
+ }
45
+
46
+ // ── Well-known user-toolchain bin dirs ──────────────────────────────
47
+ // Cross-platform list of places a user-installed global CLI lands beyond the
48
+ // daemon's PATH. Cached briefly — the node-version-manager enumeration touches
49
+ // the filesystem, and detection runs on every settings-state load.
50
+ const TOOLCHAIN_CACHE_TTL_MS = 5000;
51
+ let cachedDirs: string[] | null = null;
52
+ let cachedHome: string | null = null;
53
+ let cachedAt = 0;
54
+
55
+ export function wellKnownToolchainDirs(): string[] {
56
+ const home = homedir();
57
+ const now = Date.now();
58
+ if (cachedDirs && cachedHome === home && now - cachedAt < TOOLCHAIN_CACHE_TTL_MS) {
59
+ return cachedDirs;
60
+ }
61
+ const env = process.env;
62
+ const localAppData = env.LOCALAPPDATA || join(home, 'AppData', 'Local');
63
+ const roamingAppData = env.APPDATA || join(home, 'AppData', 'Roaming');
64
+ const dirs: string[] = [];
65
+
66
+ // Explicit npm prefix wins — matches npm's own resolution (env > .npmrc >
67
+ // default). On Windows the global shims sit directly in <prefix>; on POSIX
68
+ // they live in <prefix>/bin, so push both.
69
+ const npmPrefix = (env.NPM_CONFIG_PREFIX || env.npm_config_prefix || '').trim();
70
+ if (npmPrefix) dirs.push(npmPrefix, join(npmPrefix, 'bin'));
71
+
72
+ // Windows global package-manager + shim locations.
73
+ dirs.push(
74
+ join(roamingAppData, 'npm'), // npm global (default)
75
+ join(localAppData, 'Microsoft', 'WinGet', 'Links'), // winget shims
76
+ join(localAppData, 'Volta', 'bin'), // Volta (Windows)
77
+ join(localAppData, 'Yarn', 'bin'), // Yarn classic (Windows)
78
+ join(home, 'scoop', 'shims'), // Scoop
79
+ );
80
+ if (env.PNPM_HOME) dirs.push(env.PNPM_HOME); // pnpm
81
+
82
+ // Cross-platform home-relative toolchains.
83
+ dirs.push(
84
+ join(home, '.local', 'bin'), // native installers (e.g. Claude)
85
+ // Claude Code "local installer" target (`claude migrate-installer`, also
86
+ // what the VS Code extension can set up). NOT on PATH — `where claude`
87
+ // misses it, but the extension spawns it by absolute path, so it works in
88
+ // VS Code yet read NOT INSTALLED in the bridge until we scan here.
89
+ join(home, '.claude', 'local'),
90
+ join(home, '.claude', 'local', 'node_modules', '.bin'),
91
+ join(home, '.bun', 'bin'), // bun
92
+ join(home, '.deno', 'bin'), // deno
93
+ join(home, '.cargo', 'bin'), // cargo
94
+ join(home, '.volta', 'bin'), // Volta (POSIX)
95
+ join(home, '.yarn', 'bin'), // Yarn (POSIX)
96
+ join(home, '.npm-global', 'bin'), // common sudo-free npm prefix
97
+ join(home, '.npm-packages', 'bin'), // ditto, second variant
98
+ );
99
+
100
+ // POSIX system bins (a GUI app's PATH often lacks these).
101
+ if (process.platform !== 'win32') {
102
+ dirs.push('/opt/homebrew/bin', '/usr/local/bin');
103
+ }
104
+
105
+ // Per-version Node toolchains — npm-global CLIs hide inside the *active*
106
+ // node version's bin dir, which never makes it into a GUI app's PATH.
107
+ for (const { root, segments } of [
108
+ { root: join(home, '.nvm', 'versions', 'node'), segments: ['bin'] },
109
+ { root: join(home, '.fnm', 'node-versions'), segments: ['installation', 'bin'] },
110
+ { root: join(home, '.local', 'share', 'fnm', 'node-versions'), segments: ['installation', 'bin'] },
111
+ { root: join(localAppData, 'fnm_multishells'), segments: [] },
112
+ ]) {
113
+ try {
114
+ for (const entry of readdirSync(root)) {
115
+ dirs.push(join(root, entry, ...segments));
116
+ }
117
+ } catch { /* root absent — contributes nothing */ }
118
+ }
119
+
120
+ cachedDirs = dirs;
121
+ cachedHome = home;
122
+ cachedAt = now;
123
+ return dirs;
124
+ }
125
+
126
+ // ── Name-specific install locations ─────────────────────────────────
127
+ // A few CLIs land somewhere no generic toolchain-dir scan would reach. Kept
128
+ // name-keyed so the generic resolver stays clean.
129
+
130
+ // Scan the winget Packages tree (%LOCALAPPDATA%\Microsoft\WinGet\Packages\
131
+ // <id>\(bin\)?<exe>). The package-id dir rarely matches the binary name, so
132
+ // probe each package's root + bin. The daemon's PATH usually lacks this dir.
133
+ function wingetPackageCandidates(baseName: string): string[] {
134
+ if (process.platform !== 'win32') return [];
135
+ const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
136
+ const packages = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
137
+ const out: string[] = [];
138
+ try {
139
+ for (const dir of readdirSync(packages)) {
140
+ for (const exe of [`${baseName}.exe`, `${baseName}.cmd`]) {
141
+ out.push(join(packages, dir, 'bin', exe), join(packages, dir, exe));
142
+ }
143
+ }
144
+ } catch { /* no winget Packages dir */ }
145
+ return out;
146
+ }
147
+
148
+ // Candidates that must be tried BEFORE PATH (the Microsoft Store Codex app
149
+ // exposes a WindowsApps PATH alias that throws EPERM when spawned by Node, so
150
+ // the real per-user binary has to win).
151
+ function priorityCandidates(baseName: string): string[] {
152
+ if (process.platform !== 'win32') return [];
153
+ const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
154
+ if (baseName === 'codex') {
155
+ const out = [
156
+ join(localAppData, 'OpenAI', 'Codex', 'bin', 'codex.cmd'),
157
+ join(localAppData, 'OpenAI', 'Codex', 'bin', 'codex.exe'),
158
+ ];
159
+ const packagesDir = join(localAppData, 'Packages');
160
+ try {
161
+ for (const dir of readdirSync(packagesDir)) {
162
+ if (/^OpenAI\.Codex_/i.test(dir)) {
163
+ out.push(
164
+ join(packagesDir, dir, 'LocalCache', 'Local', 'OpenAI', 'Codex', 'bin', 'codex.cmd'),
165
+ join(packagesDir, dir, 'LocalCache', 'Local', 'OpenAI', 'Codex', 'bin', 'codex.exe'),
166
+ );
167
+ }
168
+ }
169
+ } catch {}
170
+ const windowsApps = join(process.env.ProgramFiles || 'C:\\Program Files', 'WindowsApps');
171
+ try {
172
+ for (const dir of readdirSync(windowsApps)) {
173
+ if (/^OpenAI\.Codex_/i.test(dir)) {
174
+ out.push(
175
+ join(windowsApps, dir, 'app', 'resources', 'codex.cmd'),
176
+ join(windowsApps, dir, 'app', 'resources', 'codex.exe'),
177
+ join(windowsApps, dir, 'app', 'resources', 'codex'),
178
+ );
179
+ }
180
+ }
181
+ } catch {}
182
+ return out;
183
+ }
184
+ return [];
185
+ }
186
+
187
+ // Candidates tried AFTER PATH + toolchain dirs — installer-specific homes and
188
+ // winget Packages.
189
+ function fallbackCandidates(baseName: string): string[] {
190
+ const home = homedir();
191
+ const out: string[] = [];
192
+ if (baseName === 'grok') {
193
+ // xAI installer drops grok at ~/.grok/bin/grok[.exe] (not an npm package).
194
+ for (const f of ['grok.exe', 'grok.cmd', 'grok']) out.push(join(home, '.grok', 'bin', f));
195
+ }
196
+ if (baseName === 'gh') {
197
+ out.push(
198
+ join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe'),
199
+ join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'GitHub CLI', 'gh.exe'),
200
+ join(process.env['LOCALAPPDATA'] || '', 'GitHub CLI', 'gh.exe'),
201
+ );
202
+ }
203
+ if (baseName === 'agy') {
204
+ // Antigravity's headless CLI installs to %LOCALAPPDATA%\agy\bin (Windows)
205
+ // or ~/.local/bin (POSIX) and is not added to PATH.
206
+ const lad = process.env.LOCALAPPDATA || join(home, 'AppData', 'Local');
207
+ out.push(join(lad, 'agy', 'bin', 'agy.exe'), join(home, '.local', 'bin', 'agy'));
208
+ }
209
+ if (baseName === 'claude') {
210
+ out.push(...editorBundledClaudeCandidates(home));
211
+ }
212
+ out.push(...wingetPackageCandidates(baseName));
213
+ return out;
214
+ }
215
+
216
+ // The Claude Code VS Code / Cursor extension bundles a full, headless-capable
217
+ // native `claude` (verified: `--version` → "2.1.x (Claude Code)") at
218
+ // <ext>/resources/native-binary/claude[.exe], but never adds it to PATH — so
219
+ // `where claude` misses it even though Claude works in the editor. Detected as
220
+ // a last resort (a real standalone install ranks ahead via PATH / npm). Newest
221
+ // extension version wins.
222
+ function editorBundledClaudeCandidates(home: string): string[] {
223
+ const exe = process.platform === 'win32' ? 'claude.exe' : 'claude';
224
+ const roots = [
225
+ join(home, '.vscode', 'extensions'),
226
+ join(home, '.vscode-insiders', 'extensions'),
227
+ join(home, '.cursor', 'extensions'),
228
+ join(home, '.windsurf', 'extensions'),
229
+ ];
230
+ const extDirs: string[] = [];
231
+ for (const root of roots) {
232
+ try {
233
+ for (const d of readdirSync(root)) {
234
+ if (/^anthropic\.claude-code-/i.test(d)) extDirs.push(join(root, d));
235
+ }
236
+ } catch { /* no such editor / extensions dir */ }
237
+ }
238
+ extDirs.sort(compareExtDirVersionDesc);
239
+ return extDirs.map(d => join(d, 'resources', 'native-binary', exe));
240
+ }
241
+
242
+ // Sort `anthropic.claude-code-2.1.161-win32-x64` dirs newest-first by their
243
+ // x.y.z version; fall back to reverse string order when no version parses.
244
+ function compareExtDirVersionDesc(a: string, b: string): number {
245
+ const parse = (s: string): number[] => {
246
+ const m = /claude-code-(\d+)\.(\d+)\.(\d+)/i.exec(s);
247
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [];
248
+ };
249
+ const va = parse(a), vb = parse(b);
250
+ for (let i = 0; i < 3; i++) {
251
+ const d = (vb[i] ?? -1) - (va[i] ?? -1);
252
+ if (d !== 0) return d;
253
+ }
254
+ return b.localeCompare(a);
255
+ }
256
+
257
+ function looksExecutableOnWindows(filePath: string): boolean {
258
+ const ext = extname(filePath).trim().toUpperCase();
259
+ if (!ext) return false;
260
+ const exts = (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').map(s => s.trim().toUpperCase()).filter(Boolean);
261
+ return exts.includes(ext);
262
+ }
263
+
264
+ // Resolve a per-CLI env override (CLAUDE_BIN, GH_BIN, …) to an absolute,
265
+ // existing, executable file. Returns null when unset/invalid.
266
+ function envOverridePath(baseName: string): string | null {
267
+ const key = BIN_ENV_OVERRIDE[baseName];
268
+ if (!key) return null;
269
+ const raw = (process.env[key] || '').trim();
270
+ if (!raw) return null;
271
+ try {
272
+ if (!statSync(raw).isFile()) return null;
273
+ if (process.platform === 'win32' && !looksExecutableOnWindows(raw)) return null;
274
+ } catch {
275
+ return null;
276
+ }
277
+ return raw;
278
+ }
279
+
280
+ /**
281
+ * Ordered, de-duplicated list of existing executable paths for `name`. Order:
282
+ * 1. env override (e.g. CLAUDE_BIN)
283
+ * 2. name-specific priority dirs (codex real binary before its PATH alias)
284
+ * 3. PATH × PATHEXT (in-process — no `where.exe` / `which`)
285
+ * 4. well-known user-toolchain dirs × PATHEXT
286
+ * 5. name-specific fallbacks (installer homes, winget Packages)
287
+ */
288
+ export function resolveExecutableCandidates(name: string): string[] {
289
+ const baseName = name.replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
290
+ const exts = pathExts();
291
+ const candidates: string[] = [];
292
+
293
+ const override = envOverridePath(baseName);
294
+ if (override) candidates.push(override);
295
+
296
+ candidates.push(...priorityCandidates(baseName));
297
+
298
+ for (const dir of pathDirs()) {
299
+ for (const ext of exts) candidates.push(join(dir, baseName + ext));
300
+ }
301
+ for (const dir of wellKnownToolchainDirs()) {
302
+ for (const ext of exts) candidates.push(join(dir, baseName + ext));
303
+ }
304
+
305
+ candidates.push(...fallbackCandidates(baseName));
306
+
307
+ const seen = new Set<string>();
308
+ return candidates.filter((c) => {
309
+ const key = c.toLowerCase();
310
+ if (seen.has(key) || !existsSync(c)) return false;
311
+ seen.add(key);
312
+ return true;
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Single best executable path for `name`, preferring a `.cmd` shim over `.exe`
318
+ * on Windows (the .cmd wrapper sets up the right interpreter), else the first
319
+ * resolved candidate. Null when nothing is found.
320
+ */
321
+ export function resolveExecutable(name: string): string | null {
322
+ const candidates = resolveExecutableCandidates(name);
323
+ if (!candidates.length) return null;
324
+ if (process.platform === 'win32') {
325
+ return candidates.find(p => p.toLowerCase().endsWith('.cmd'))
326
+ || candidates.find(p => p.toLowerCase().endsWith('.exe'))
327
+ || candidates[0];
328
+ }
329
+ return candidates[0];
330
+ }