@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.
- package/CHANGELOG.md +1531 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/SECURITY.md +130 -0
- package/assets/accuracy-lab.html +2639 -0
- package/assets/api-clis-real.jpg +0 -0
- package/assets/bridge-console-hero.jpg +0 -0
- package/assets/browser-privacy.svg +151 -0
- package/assets/demo-orchestration.svg +74 -0
- package/assets/desktop-select-region.jpg +0 -0
- package/assets/in-page-chat.gif +0 -0
- package/assets/orchestration-hero.svg +126 -0
- package/assets/social-preview.png +0 -0
- package/assets/zara-accent.png +0 -0
- package/build/bootstrap.js +548 -0
- package/build/build.js +680 -0
- package/build/payload-entry.js +649 -0
- package/build/payload-signing-pub.json +7 -0
- package/docs/AGENT_GUIDE.md +259 -0
- package/docs/RELEASE.md +106 -0
- package/docs/SAFETY.md +112 -0
- package/docs/TESTING.md +181 -0
- package/installer/server.js +231 -0
- package/installer/ui/app.js +278 -0
- package/installer/ui/index.html +24 -0
- package/installer/ui/styles.css +146 -0
- package/package.json +95 -0
- package/scripts/bootstrap-e2e.mjs +650 -0
- package/scripts/certify-bridge.mjs +636 -0
- package/scripts/check-companion-surface.mjs +118 -0
- package/scripts/extract-welcome.mjs +64 -0
- package/scripts/gh-route-handler-check.mjs +57 -0
- package/scripts/gh-wire-test.mjs +107 -0
- package/scripts/publish-downloads.mjs +180 -0
- package/scripts/smoke-all-tools.mjs +509 -0
- package/scripts/smoke-live-bridge.mjs +696 -0
- package/scripts/splice-welcome.mjs +63 -0
- package/scripts/welcome-body.txt +2733 -0
- package/src/anthropic-client.ts +192 -0
- package/src/bootstrap-exe.ts +69 -0
- package/src/bridge.ts +2444 -0
- package/src/chat.ts +345 -0
- package/src/cli-runner.ts +239 -0
- package/src/cli.ts +649 -0
- package/src/config.ts +199 -0
- package/src/desktop-overlay.ps1 +121 -0
- package/src/executable-resolver.ts +330 -0
- package/src/handlers/agy-imagegen.ts +179 -0
- package/src/handlers/github-cli.ts +399 -0
- package/src/handlers/higgsfield-cli.ts +783 -0
- package/src/launch.js +337 -0
- package/src/mcp-server.ts +1265 -0
- package/src/pair-claim.ts +218 -0
- package/src/payload-daemon.ts +168 -0
- package/src/server.ts +21036 -0
- package/src/tool-defaults.ts +230 -0
- package/src/update-check.js +136 -0
- package/tray/build.py +76 -0
- package/tray/requirements.txt +2 -0
- 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
|
+
}
|