squeezr-ai 1.23.0 → 1.46.2

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/bin/squeezr.js CHANGED
@@ -22,13 +22,26 @@ const UPDATE_CHECK_FILE = path.join(os.homedir(), '.squeezr', 'update-check.json
22
22
  const UPDATE_CHECK_INTERVAL = 4 * 60 * 60 * 1000 // 4 hours
23
23
 
24
24
  // Fire and forget — runs in background, never blocks CLI
25
+ // Convert semver string to comparable number (major*1M + minor*1k + patch)
26
+ function semverToNum(v) {
27
+ if (!v || typeof v !== 'string') return 0
28
+ const parts = v.split('.').map(p => parseInt(p) || 0)
29
+ return (parts[0] || 0) * 1000000 + (parts[1] || 0) * 1000 + (parts[2] || 0)
30
+ }
31
+
32
+ // Returns the npm version IF it's strictly newer than the local version, else null
33
+ function newerVersionOrNull(latest) {
34
+ if (!latest || latest === pkg.version) return null
35
+ return semverToNum(latest) > semverToNum(pkg.version) ? latest : null
36
+ }
37
+
25
38
  const updateCheckPromise = (async () => {
26
39
  try {
27
40
  // Read cached check
28
41
  let cached = null
29
42
  try { cached = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf-8')) } catch {}
30
43
  if (cached && Date.now() - cached.checkedAt < UPDATE_CHECK_INTERVAL) {
31
- return cached.latest !== pkg.version ? cached.latest : null
44
+ return newerVersionOrNull(cached.latest)
32
45
  }
33
46
  // Fetch latest from npm (with timeout)
34
47
  const { get } = await import('https')
@@ -48,7 +61,7 @@ const updateCheckPromise = (async () => {
48
61
  const dir = path.dirname(UPDATE_CHECK_FILE)
49
62
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
50
63
  fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ latest, checkedAt: Date.now() }))
51
- return latest !== pkg.version ? latest : null
64
+ return newerVersionOrNull(latest)
52
65
  } catch { return null }
53
66
  })()
54
67
 
@@ -242,6 +255,11 @@ Usage:
242
255
  squeezr mcp uninstall Remove Squeezr MCP registration
243
256
  squeezr ports Change HTTP and MITM proxy ports
244
257
  squeezr tunnel Expose proxy via Cloudflare Tunnel for Cursor IDE
258
+ squeezr enable-claude-desktop Enable hosts-file redirect for Claude Desktop (admin once)
259
+ squeezr disable-claude-desktop Disable hosts-file redirect for Claude Desktop
260
+ squeezr desktop start Start SEPARATE proxy for Claude/Codex Desktop (ports 8443+8088)
261
+ squeezr desktop stop Stop the desktop proxy (does NOT affect main proxy)
262
+ squeezr desktop status Show desktop proxy status
245
263
  squeezr bypass Toggle bypass mode (skip compression, keep logging)
246
264
  squeezr bypass --on Enable bypass (disable compression)
247
265
  squeezr bypass --off Disable bypass (resume compression)
@@ -314,6 +332,17 @@ async function startDaemon() {
314
332
  console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
315
333
  console.log(` Logs: ${logFile}`)
316
334
 
335
+ // ── Also start the SEPARATE Desktop proxy (independent process) ────────────
336
+ // This is the proxy that serves Claude Desktop and Codex Desktop. It runs in
337
+ // its own Node process on ports 8443 + 8088. If it crashes, the main proxy
338
+ // (just started above) keeps running. Failures here are non-fatal — we just
339
+ // warn and continue.
340
+ try {
341
+ await desktopProxyStart()
342
+ } catch (e) {
343
+ console.warn(`Desktop proxy did not start: ${e?.message ?? e}`)
344
+ console.warn(`(The main proxy is fine. Try \`squeezr desktop start\` separately.)`)
345
+ }
317
346
  }
318
347
 
319
348
  function showLogs() {
@@ -350,8 +379,55 @@ function killMcpProcesses() {
350
379
  function stopProxy() {
351
380
  const port = getPort()
352
381
  const mitmPort = getMitmPort(port)
382
+
383
+ // ── Step 0: Stop the SEPARATE Desktop proxy first (independent process) ────
384
+ // It has its own PID file. Failure here doesn't block main-proxy shutdown.
385
+ try {
386
+ const desktopPid = readDesktopPid()
387
+ if (desktopPid) {
388
+ try { process.kill(desktopPid, 'SIGTERM') } catch {}
389
+ // Give it a moment to exit gracefully
390
+ for (let i = 0; i < 20; i++) {
391
+ try { process.kill(desktopPid, 0) } catch { break }
392
+ execSync(process.platform === 'win32' ? `ping -n 1 127.0.0.1 > nul` : `sleep 0.1`, { stdio: 'pipe' })
393
+ }
394
+ try { process.kill(desktopPid, 'SIGKILL') } catch {}
395
+ try { fs.unlinkSync(DESKTOP_PID_FILE) } catch {}
396
+ // Also clean up any orphan listeners on the desktop ports
397
+ for (const dp of [DESKTOP_HTTPS_PORT, DESKTOP_HTTP_PORT]) {
398
+ try {
399
+ if (process.platform === 'win32') {
400
+ const out = execSync(`netstat -ano | findstr ":${dp} "`, { encoding: 'utf-8', stdio: 'pipe' })
401
+ const matches = [...out.matchAll(/LISTENING\s+(\d+)/g)]
402
+ for (const m of matches) {
403
+ try { execSync(`taskkill /F /PID ${m[1]}`, { stdio: 'pipe' }) } catch {}
404
+ }
405
+ } else {
406
+ try { execSync(`lsof -ti :${dp} -sTCP:LISTEN | xargs -r kill -9`, { stdio: 'pipe' }) } catch {}
407
+ }
408
+ } catch {}
409
+ }
410
+ }
411
+ } catch {}
412
+
413
+ // ── Step 1: Graceful shutdown via HTTP — persists history/cache before exit ──
414
+ // /squeezr/control/stop emits SIGTERM which calls persistAndExit().
415
+ // This prevents losing the current session's savings history on stop.
416
+ let gracefulOk = false
417
+ try {
418
+ const req = http.request({ hostname: 'localhost', port, path: '/squeezr/control/stop', method: 'POST', timeout: 2000 })
419
+ req.on('error', () => {})
420
+ req.end()
421
+ gracefulOk = true
422
+ // Give the process time to persist and exit cleanly
423
+ execSync(process.platform === 'win32'
424
+ ? `ping -n 2 127.0.0.1 > nul` // ~1s sleep on Windows
425
+ : `sleep 1`, { stdio: 'pipe' })
426
+ } catch {}
427
+
428
+ // ── Step 2: Force-kill anything still listening (fallback) ──────────────────
353
429
  const ports = [port, mitmPort]
354
- let killed = false
430
+ let killed = gracefulOk // count graceful as "killed"
355
431
 
356
432
  for (const p of ports) {
357
433
  try {
@@ -430,9 +506,9 @@ async function checkStatus() {
430
506
  return false
431
507
  }
432
508
  console.log(`Squeezr is running (v${json.version})`)
433
- console.log(` HTTP proxy (Claude/Aider/Gemini): http://localhost:${port}`)
434
- console.log(` MITM proxy (Codex): http://localhost:${mitmPort}`)
435
- console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
509
+ console.log(` HTTP proxy (Claude Code, Claude Desktop, Codex Desktop, Aider, Gemini): http://localhost:${port}`)
510
+ console.log(` MITM proxy (Codex CLI TLS): http://localhost:${mitmPort}`)
511
+ console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
436
512
  if (json.mode) console.log(` Mode: ${json.mode}`)
437
513
  if (json.uptime_seconds != null) {
438
514
  const s = json.uptime_seconds
@@ -470,12 +546,24 @@ async function mcpInstall() {
470
546
  args: [mcpServerPath],
471
547
  }
472
548
 
549
+ // Claude Desktop config path varies by platform
550
+ const claudeDesktopConfig = process.platform === 'win32'
551
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
552
+ : process.platform === 'darwin'
553
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
554
+ : path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json')
555
+
473
556
  const targets = [
474
557
  {
475
558
  name: 'Claude Code',
476
559
  file: path.join(os.homedir(), '.claude.json'),
477
560
  key: 'mcpServers',
478
561
  },
562
+ {
563
+ name: 'Claude Desktop',
564
+ file: claudeDesktopConfig,
565
+ key: 'mcpServers',
566
+ },
479
567
  {
480
568
  name: 'Cursor',
481
569
  file: path.join(os.homedir(), '.cursor', 'mcp.json'),
@@ -523,17 +611,24 @@ async function mcpInstall() {
523
611
  console.log('MCP server registered in ' + installed + ' client(s).')
524
612
  console.log('Server binary: ' + mcpServerPath)
525
613
  console.log('')
526
- console.log('Available tools in Claude/Codex/Cursor:')
527
- console.log(' squeezr_status — Check if Squeezr is running')
528
- console.log(' squeezr_stats — Real-time token savings')
529
- console.log(' squeezr_set_mode — Change compression aggressiveness')
530
- console.log(' squeezr_config — Current configuration')
531
- console.log(' squeezr_habits — Wasteful pattern report')
614
+ console.log('Available tools in Claude Desktop, Claude Code, Codex Desktop, Cursor…:')
615
+ console.log(' squeezr_status — Check if Squeezr is running')
616
+ console.log(' squeezr_stats — Real-time token savings')
617
+ console.log(' squeezr_set_mode — Change compression aggressiveness')
618
+ console.log(' squeezr_config — Current configuration')
619
+ console.log(' squeezr_habits — Wasteful pattern report')
620
+ console.log(' squeezr_open_dashboard — Open the Squeezr dashboard in your browser')
532
621
  }
533
622
 
534
623
  async function mcpUninstall() {
624
+ const claudeDesktopConfig = process.platform === 'win32'
625
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
626
+ : process.platform === 'darwin'
627
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
628
+ : path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json')
535
629
  const files = [
536
630
  path.join(os.homedir(), '.claude.json'),
631
+ claudeDesktopConfig,
537
632
  path.join(os.homedir(), '.cursor', 'mcp.json'),
538
633
  path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
539
634
  path.join(os.homedir(), '.vscode', 'extensions', 'mcp_settings.json'),
@@ -812,9 +907,389 @@ async function uninstall() {
812
907
  console.log('\nDone! Squeezr has been completely removed.\n')
813
908
  }
814
909
 
910
+ // ── Claude Desktop hosts file + TLS intercept ────────────────────────────────
911
+ // Claude Desktop ignores ANTHROPIC_BASE_URL (it's an Electron GUI app), so the
912
+ // only way to intercept is at DNS level: hosts file redirects api.anthropic.com
913
+ // → 127.0.0.1, and Squeezr listens on :443 with TLS using its local CA cert.
914
+ //
915
+ // This function adds/removes the hosts file entry + firewall rule. Requires
916
+ // admin. On Windows, re-launches itself elevated via PowerShell Start-Process -Verb RunAs.
917
+
918
+ const HOSTS_FILE_WIN = 'C:\\Windows\\System32\\drivers\\etc\\hosts'
919
+ const HOSTS_FILE_UNIX = '/etc/hosts'
920
+ const HOSTS_MARKER_BEGIN = '# squeezr-claude-desktop BEGIN'
921
+ const HOSTS_MARKER_END = '# squeezr-claude-desktop END'
922
+ const INTERCEPTED_DOMAINS = ['api.anthropic.com']
923
+ const CLAUDE_DESKTOP_FLAG_FILE = path.join(os.homedir(), '.squeezr', 'claude-desktop-enabled')
924
+ const MITM_INTERNAL_PORT = 8443
925
+
926
+ async function toggleClaudeDesktopIntercept(enable) {
927
+ const isWin = process.platform === 'win32'
928
+ const hostsPath = isWin ? HOSTS_FILE_WIN : HOSTS_FILE_UNIX
929
+
930
+ // Check if running as admin
931
+ const isAdmin = await checkIsAdmin()
932
+ if (!isAdmin) {
933
+ if (isWin) {
934
+ console.log('Admin rights required to modify hosts file and bind to port 443.')
935
+ console.log('Relaunching elevated...\n')
936
+ const verb = enable ? 'enable-claude-desktop' : 'disable-claude-desktop'
937
+ try {
938
+ execSync(
939
+ `powershell -NoProfile -Command "Start-Process -FilePath '${process.execPath}' -ArgumentList '${process.argv[1]}','${verb}' -Verb RunAs -Wait"`,
940
+ { stdio: 'inherit' }
941
+ )
942
+ console.log('\nDone. Restart Squeezr to apply: squeezr stop && squeezr start')
943
+ } catch (e) {
944
+ console.error('Failed to launch elevated process: ' + e.message)
945
+ }
946
+ return
947
+ } else {
948
+ console.error('Run with sudo: sudo squeezr ' + (enable ? 'enable' : 'disable') + '-claude-desktop')
949
+ process.exit(1)
950
+ }
951
+ }
952
+
953
+ // Read hosts file
954
+ let content = ''
955
+ try { content = fs.readFileSync(hostsPath, 'utf-8') } catch (e) {
956
+ console.error('Could not read hosts file: ' + e.message)
957
+ process.exit(1)
958
+ }
959
+
960
+ // Strip any existing squeezr block
961
+ const blockRegex = new RegExp(
962
+ `\\r?\\n?${HOSTS_MARKER_BEGIN}[\\s\\S]*?${HOSTS_MARKER_END}\\r?\\n?`,
963
+ 'g'
964
+ )
965
+ content = content.replace(blockRegex, '')
966
+
967
+ if (enable) {
968
+ const block = [
969
+ '',
970
+ HOSTS_MARKER_BEGIN,
971
+ '# Redirects Claude Desktop to Squeezr for token compression.',
972
+ '# Remove this block (or run `squeezr disable-claude-desktop`) to undo.',
973
+ ...INTERCEPTED_DOMAINS.map(d => `127.0.0.1 ${d}`),
974
+ HOSTS_MARKER_END,
975
+ '',
976
+ ].join('\n')
977
+ content += block
978
+
979
+ fs.writeFileSync(hostsPath, content)
980
+ console.log('[1/4] Hosts file updated: api.anthropic.com → 127.0.0.1')
981
+
982
+ if (isWin) {
983
+ // 2. netsh portproxy 443 → 8443 (so Squeezr listens on 8443 without admin)
984
+ try {
985
+ execSync(`netsh interface portproxy delete v4tov4 listenport=443 listenaddress=127.0.0.1`, { stdio: 'pipe' })
986
+ } catch {}
987
+ try {
988
+ execSync(`netsh interface portproxy add v4tov4 listenport=443 listenaddress=127.0.0.1 connectport=${MITM_INTERNAL_PORT} connectaddress=127.0.0.1`, { stdio: 'pipe' })
989
+ console.log(`[2/4] Port forwarding 127.0.0.1:443 → 127.0.0.1:${MITM_INTERNAL_PORT} (netsh portproxy)`)
990
+ } catch (e) {
991
+ console.warn('Could not add netsh portproxy: ' + e.message)
992
+ }
993
+
994
+ // 3. Firewall: open inbound 443
995
+ try {
996
+ execSync('netsh advfirewall firewall delete rule name="Squeezr-Claude-Desktop-443"', { stdio: 'pipe' })
997
+ } catch {}
998
+ try {
999
+ execSync('netsh advfirewall firewall add rule name="Squeezr-Claude-Desktop-443" dir=in action=allow protocol=TCP localport=443', { stdio: 'pipe' })
1000
+ console.log('[3/4] Firewall rule added for port 443.')
1001
+ } catch (e) {
1002
+ console.warn('Could not add firewall rule: ' + e.message)
1003
+ }
1004
+ // Flush DNS cache so the change takes effect immediately
1005
+ try { execSync('ipconfig /flushdns', { stdio: 'pipe' }) } catch {}
1006
+ }
1007
+
1008
+ // Note: no persistent flag file needed — Squeezr auto-detects the hosts file
1009
+ // entry on startup and activates the MITM listener accordingly.
1010
+
1011
+ console.log('\n[NEXT] Restart Squeezr: squeezr stop && squeezr start')
1012
+ console.log(' Close Claude Desktop completely (including system tray).')
1013
+ console.log(' Reopen Claude Desktop and try any query.')
1014
+ console.log(' Verify in dashboard "By client" section → should show "claude_desktop".')
1015
+ } else {
1016
+ fs.writeFileSync(hostsPath, content)
1017
+ console.log('[1/4] Hosts file cleaned: api.anthropic.com restored to normal DNS.')
1018
+ if (isWin) {
1019
+ try {
1020
+ execSync(`netsh interface portproxy delete v4tov4 listenport=443 listenaddress=127.0.0.1`, { stdio: 'pipe' })
1021
+ console.log('[2/4] Port forwarding rule removed.')
1022
+ } catch {}
1023
+ try {
1024
+ execSync('netsh advfirewall firewall delete rule name="Squeezr-Claude-Desktop-443"', { stdio: 'pipe' })
1025
+ console.log('[3/4] Firewall rule removed.')
1026
+ } catch {}
1027
+ try { execSync('ipconfig /flushdns', { stdio: 'pipe' }) } catch {}
1028
+ }
1029
+ // Legacy flag cleanup (if exists from older version)
1030
+ try {
1031
+ const legacyFlag = path.join(os.homedir(), '.squeezr', 'claude-desktop-enabled')
1032
+ if (fs.existsSync(legacyFlag)) fs.unlinkSync(legacyFlag)
1033
+ } catch {}
1034
+ console.log('\n[NEXT] Restart Squeezr: squeezr stop && squeezr start')
1035
+ }
1036
+ }
1037
+
1038
+ async function checkIsAdmin() {
1039
+ if (process.platform === 'win32') {
1040
+ try {
1041
+ execSync('net session', { stdio: 'pipe' })
1042
+ return true
1043
+ } catch {
1044
+ return false
1045
+ }
1046
+ } else {
1047
+ return process.getuid && process.getuid() === 0
1048
+ }
1049
+ }
1050
+
1051
+ // ── Desktop proxy lifecycle (TOTALLY SEPARATE from main proxy on 8080) ───────
1052
+ // `squeezr desktop start` → spawns dist/desktopProxy.js detached
1053
+ // `squeezr desktop stop` → kills via PID file (does NOT touch port 8080)
1054
+ // `squeezr desktop status` → prints state
1055
+ //
1056
+ // Critical: this proxy lives or dies independently. Crashing it cannot affect
1057
+ // the main proxy on 8080 (Claude Code, Codex CLI, Aider, Gemini CLI).
1058
+
1059
+ const DESKTOP_PID_FILE = path.join(os.homedir(), '.squeezr', 'desktop-proxy.pid')
1060
+ const DESKTOP_LOG_FILE = path.join(os.homedir(), '.squeezr', 'desktop-proxy.log')
1061
+ const DESKTOP_HTTPS_PORT = 8443
1062
+ const DESKTOP_HTTP_PORT = 8088
1063
+
1064
+ function readDesktopPid() {
1065
+ try {
1066
+ const raw = fs.readFileSync(DESKTOP_PID_FILE, 'utf-8').trim()
1067
+ const pid = Number(raw)
1068
+ if (!pid || Number.isNaN(pid)) return null
1069
+ // Check process actually exists
1070
+ try { process.kill(pid, 0); return pid } catch { return null }
1071
+ } catch { return null }
1072
+ }
1073
+
1074
+ // Probe whether the desktop proxy listener is actually answering. Used to
1075
+ // detect orphan processes — situations where the PID file is stale but a
1076
+ // previous desktop proxy is still bound to the ports.
1077
+ async function isDesktopPortBound(port) {
1078
+ return new Promise(resolve => {
1079
+ const net = require('node:net')
1080
+ const sock = net.connect({ host: '127.0.0.1', port, timeout: 500 }, () => {
1081
+ sock.end()
1082
+ resolve(true)
1083
+ })
1084
+ sock.once('error', () => resolve(false))
1085
+ sock.once('timeout', () => { sock.destroy(); resolve(false) })
1086
+ })
1087
+ }
1088
+
1089
+ // Find the PID owning a local TCP listener on Windows via `netstat -ano`. We
1090
+ // use this only to clean up orphan desktop-proxy processes; it is a best
1091
+ // effort and returns null if it can't parse the output.
1092
+ function findPidByPort(port) {
1093
+ if (process.platform !== 'win32') return null
1094
+ try {
1095
+ const out = require('node:child_process').execSync(`netstat -ano -p TCP`, { encoding: 'utf-8' })
1096
+ for (const line of out.split(/\r?\n/)) {
1097
+ const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/)
1098
+ if (m && Number(m[1]) === port) return Number(m[2])
1099
+ }
1100
+ } catch { /* ignore */ }
1101
+ return null
1102
+ }
1103
+
1104
+ async function desktopProxyStart() {
1105
+ const existing = readDesktopPid()
1106
+ if (existing) {
1107
+ console.log(`Desktop proxy already running (pid=${existing}).`)
1108
+ console.log(` HTTPS: https://127.0.0.1:${DESKTOP_HTTPS_PORT} (Claude Desktop)`)
1109
+ console.log(` HTTP: http://127.0.0.1:${DESKTOP_HTTP_PORT} (Codex Desktop)`)
1110
+ return
1111
+ }
1112
+ // Detect orphan listener (PID file dead but listener still up) — common
1113
+ // after an ungraceful restart. Reclaim it by adopting the running PID,
1114
+ // otherwise the spawn below would crash with EADDRINUSE.
1115
+ if (await isDesktopPortBound(DESKTOP_HTTPS_PORT) || await isDesktopPortBound(DESKTOP_HTTP_PORT)) {
1116
+ const orphanPid = findPidByPort(DESKTOP_HTTPS_PORT) ?? findPidByPort(DESKTOP_HTTP_PORT)
1117
+ if (orphanPid) {
1118
+ try { fs.writeFileSync(DESKTOP_PID_FILE, String(orphanPid), 'utf-8') } catch {}
1119
+ console.log(`Desktop proxy is already bound to ports ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} (pid=${orphanPid}, adopted).`)
1120
+ } else {
1121
+ console.log(`Desktop proxy ports ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} are already in use by an unknown process. Use 'squeezr desktop stop' first.`)
1122
+ }
1123
+ return
1124
+ }
1125
+ const distPath = path.join(ROOT, 'dist', 'desktopProxy.js')
1126
+ if (!fs.existsSync(distPath)) {
1127
+ console.error(`Error: ${distPath} not found. Run 'npm run build' first.`)
1128
+ process.exit(1)
1129
+ }
1130
+ try { fs.mkdirSync(path.dirname(DESKTOP_LOG_FILE), { recursive: true }) } catch {}
1131
+ const out = fs.openSync(DESKTOP_LOG_FILE, 'a')
1132
+ const err = fs.openSync(DESKTOP_LOG_FILE, 'a')
1133
+ const child = spawn(process.execPath, [distPath], {
1134
+ detached: true,
1135
+ stdio: ['ignore', out, err],
1136
+ env: {
1137
+ ...process.env,
1138
+ SQUEEZR_DESKTOP_HTTPS_PORT: String(DESKTOP_HTTPS_PORT),
1139
+ SQUEEZR_DESKTOP_HTTP_PORT: String(DESKTOP_HTTP_PORT),
1140
+ },
1141
+ })
1142
+ child.unref()
1143
+ // Wait briefly to confirm it didn't immediately exit
1144
+ await new Promise(r => setTimeout(r, 800))
1145
+ if (child.exitCode !== null) {
1146
+ console.error(`Desktop proxy failed to start (exit ${child.exitCode}). Check log: ${DESKTOP_LOG_FILE}`)
1147
+ process.exit(1)
1148
+ }
1149
+ console.log(`Desktop proxy started (pid=${child.pid}).`)
1150
+ console.log(` HTTPS: https://127.0.0.1:${DESKTOP_HTTPS_PORT} (Claude Desktop)`)
1151
+ console.log(` HTTP: http://127.0.0.1:${DESKTOP_HTTP_PORT} (Codex Desktop)`)
1152
+ console.log(` Log: ${DESKTOP_LOG_FILE}`)
1153
+ console.log(``)
1154
+ console.log(`Note: the main Squeezr proxy on 8080 is unaffected by this command.`)
1155
+ }
1156
+
1157
+ async function desktopProxyStop() {
1158
+ let pid = readDesktopPid()
1159
+ if (!pid) {
1160
+ // PID file is missing or its pid is dead — but a listener may still be
1161
+ // bound by an orphan. Try to locate it by port and kill that instead.
1162
+ if (await isDesktopPortBound(DESKTOP_HTTPS_PORT) || await isDesktopPortBound(DESKTOP_HTTP_PORT)) {
1163
+ pid = findPidByPort(DESKTOP_HTTPS_PORT) ?? findPidByPort(DESKTOP_HTTP_PORT)
1164
+ if (!pid) {
1165
+ console.log(`Desktop proxy ports ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} are in use but PID cannot be resolved. Stop the owning process manually.`)
1166
+ return
1167
+ }
1168
+ console.log(`Recovered orphan desktop proxy pid=${pid} from listener.`)
1169
+ } else {
1170
+ console.log('Desktop proxy is not running.')
1171
+ return
1172
+ }
1173
+ }
1174
+ try {
1175
+ process.kill(pid, 'SIGTERM')
1176
+ // Wait up to 3s for graceful exit
1177
+ for (let i = 0; i < 30; i++) {
1178
+ try { process.kill(pid, 0) } catch { break }
1179
+ await new Promise(r => setTimeout(r, 100))
1180
+ }
1181
+ // Force kill if still alive
1182
+ try { process.kill(pid, 'SIGKILL') } catch {}
1183
+ try { fs.unlinkSync(DESKTOP_PID_FILE) } catch {}
1184
+ console.log(`Desktop proxy stopped (was pid=${pid}).`)
1185
+ } catch (e) {
1186
+ console.error(`Failed to stop desktop proxy: ${e.message}`)
1187
+ process.exit(1)
1188
+ }
1189
+ }
1190
+
1191
+ async function desktopProxyStatus() {
1192
+ let pid = readDesktopPid()
1193
+ // Fallback when PID file is stale: probe the desktop ports. If the listener
1194
+ // answers, the proxy IS running — just under a different PID than the one
1195
+ // recorded. This is the common shape after an ungraceful restart.
1196
+ if (!pid) {
1197
+ const bound = (await isDesktopPortBound(DESKTOP_HTTPS_PORT))
1198
+ || (await isDesktopPortBound(DESKTOP_HTTP_PORT))
1199
+ if (!bound) {
1200
+ console.log('Desktop proxy is NOT running.')
1201
+ console.log('Start it with: squeezr desktop start')
1202
+ return
1203
+ }
1204
+ const orphanPid = findPidByPort(DESKTOP_HTTPS_PORT) ?? findPidByPort(DESKTOP_HTTP_PORT)
1205
+ if (orphanPid) {
1206
+ try { fs.writeFileSync(DESKTOP_PID_FILE, String(orphanPid), 'utf-8') } catch {}
1207
+ pid = orphanPid
1208
+ console.log(`Desktop proxy is running (pid=${pid}, recovered from orphan listener).`)
1209
+ } else {
1210
+ console.log(`Desktop proxy listener is bound on ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} but its PID could not be resolved.`)
1211
+ }
1212
+ } else {
1213
+ console.log(`Desktop proxy is running (pid=${pid}).`)
1214
+ }
1215
+ console.log(` HTTPS: https://127.0.0.1:${DESKTOP_HTTPS_PORT} (Claude Desktop)`)
1216
+ console.log(` HTTP: http://127.0.0.1:${DESKTOP_HTTP_PORT} (Codex Desktop)`)
1217
+ console.log(` Log: ${DESKTOP_LOG_FILE}`)
1218
+ // Surface the activation state of the Claude Desktop hosts redirect — the
1219
+ // most common reason "nothing appears in the logs" even when this listener
1220
+ // is bound is that the user never ran `enable-claude-desktop`, so Claude
1221
+ // Desktop's traffic goes directly to api.anthropic.com.
1222
+ const hostsActive = await isClaudeDesktopHostsActive()
1223
+ if (hostsActive) {
1224
+ console.log(``)
1225
+ console.log(` Claude Desktop interception: ACTIVE (hosts redirect set)`)
1226
+ } else {
1227
+ console.log(``)
1228
+ console.log(` Claude Desktop interception: NOT SET`)
1229
+ console.log(` → Run as admin: squeezr enable-claude-desktop`)
1230
+ console.log(` Without this, Claude Desktop talks to api.anthropic.com directly`)
1231
+ console.log(` and no traffic will reach this proxy.`)
1232
+ }
1233
+ }
1234
+
1235
+ // Best-effort detection of whether the hosts file currently redirects
1236
+ // api.anthropic.com to 127.0.0.1 (the prerequisite for Claude Desktop
1237
+ // traffic to reach the desktop proxy).
1238
+ async function isClaudeDesktopHostsActive() {
1239
+ try {
1240
+ const hostsPath = process.platform === 'win32'
1241
+ ? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
1242
+ : '/etc/hosts'
1243
+ const txt = fs.readFileSync(hostsPath, 'utf-8')
1244
+ return /^\s*127\.0\.0\.1\s+api\.anthropic\.com\b/m.test(txt)
1245
+ || txt.includes('# squeezr-claude-desktop BEGIN')
1246
+ } catch { return false }
1247
+ }
1248
+
1249
+ // ── Codex Desktop config helper ──────────────────────────────────────────────
1250
+ // Writes openai_base_url to ~/.codex/config.toml so Codex Desktop routes
1251
+ // through Squeezr's HTTP proxy (no MITM needed — uses standard base URL).
1252
+
1253
+ function configureCodexDesktop(port) {
1254
+ const codexDir = path.join(os.homedir(), '.codex')
1255
+ const codexToml = path.join(codexDir, 'config.toml')
1256
+ const url = `http://localhost:${port}/v1`
1257
+ const marker = 'openai_base_url'
1258
+ const line = `openai_base_url = "${url}"`
1259
+
1260
+ try {
1261
+ fs.mkdirSync(codexDir, { recursive: true })
1262
+ if (fs.existsSync(codexToml)) {
1263
+ let content = fs.readFileSync(codexToml, 'utf-8')
1264
+ // Match openai_base_url = "anything" — including empty "" which Codex Desktop
1265
+ // sometimes writes back to its config (bug we observed in the wild).
1266
+ // Force-overwrite any existing value, even if empty.
1267
+ const re = /openai_base_url\s*=\s*"[^"]*"/
1268
+ if (re.test(content)) {
1269
+ const before = content.match(re)?.[0] ?? ''
1270
+ content = content.replace(re, line)
1271
+ fs.writeFileSync(codexToml, content)
1272
+ if (before === `openai_base_url = ""`) {
1273
+ console.log(` [ok] Codex Desktop: FIXED empty openai_base_url in ${codexToml}`)
1274
+ } else {
1275
+ console.log(` [ok] Codex Desktop: updated ${codexToml}`)
1276
+ }
1277
+ } else {
1278
+ fs.appendFileSync(codexToml, `\n# Squeezr: route Codex Desktop through the compression proxy\n${line}\n`)
1279
+ console.log(` [ok] Codex Desktop: configured ${codexToml}`)
1280
+ }
1281
+ } else {
1282
+ fs.writeFileSync(codexToml, `# Squeezr: route Codex Desktop through the compression proxy\n${line}\n`)
1283
+ console.log(` [ok] Codex Desktop: created ${codexToml}`)
1284
+ }
1285
+ } catch (err) {
1286
+ console.log(` [warn] Codex Desktop config: ${err.message}`)
1287
+ }
1288
+ }
1289
+
815
1290
  // ── squeezr setup ─────────────────────────────────────────────────────────────
816
1291
 
817
- function setupWindows() {
1292
+ async function setupWindows() {
818
1293
  const squeezrBin = process.argv[1]
819
1294
  const nodeExe = process.execPath
820
1295
  const distIndex = path.join(ROOT, 'dist', 'index.js')
@@ -846,7 +1321,15 @@ function setupWindows() {
846
1321
  }
847
1322
  }
848
1323
 
849
- // 1b. Install PowerShell wrapper so env vars auto-refresh after start/setup/update
1324
+ // 1b. Configure Codex Desktop (~/.codex/config.toml openai_base_url)
1325
+ // On Windows, ANTHROPIC_BASE_URL from setx is already visible to all GUI apps
1326
+ // (including Claude Desktop) since user-level env vars propagate to new processes.
1327
+ configureCodexDesktop(port)
1328
+
1329
+ // 1c. Register MCP server in Claude Desktop + Codex Desktop automatically
1330
+ await mcpInstall()
1331
+
1332
+ // 1c. Install PowerShell wrapper so env vars auto-refresh after start/setup/update
850
1333
  installShellWrapper()
851
1334
 
852
1335
  // 2. Auto-start: try NSSM (Windows service, survives crashes) → fallback to Task Scheduler
@@ -984,8 +1467,15 @@ function setupWindows() {
984
1467
  Done!
985
1468
 
986
1469
  Squeezr is running on http://localhost:${port}
987
- MITM proxy on http://localhost:${mitmPort} (Codex TLS interception)
988
- All CLIs (Claude Code, Codex, Aider, Gemini, Ollama) are configured.
1470
+ MITM proxy on http://localhost:${mitmPort} (Codex CLI TLS interception)
1471
+
1472
+ Configured:
1473
+ Claude Code ANTHROPIC_BASE_URL=http://localhost:${port}
1474
+ Claude Desktop same — setx env var is visible to all GUI apps
1475
+ Codex Desktop ~/.codex/config.toml openai_base_url set
1476
+ Codex CLI HTTPS_PROXY=http://localhost:${mitmPort} codex (per-session)
1477
+ Aider / OpenCode ANTHROPIC_BASE_URL + openai_base_url set
1478
+ Gemini CLI GEMINI_API_BASE_URL=http://localhost:${port}
989
1479
 
990
1480
  squeezr status — check it's running
991
1481
  squeezr gain — see token savings
@@ -993,7 +1483,7 @@ Done!
993
1483
  }
994
1484
  }
995
1485
 
996
- function setupUnix() {
1486
+ async function setupUnix() {
997
1487
  const squeezrBin = process.argv[1]
998
1488
  const nodeExe = process.execPath
999
1489
  const platform = process.platform
@@ -1077,7 +1567,57 @@ function setupUnix() {
1077
1567
  console.log(` [ok] Env vars + auto-heal updated in ${profile}`)
1078
1568
  }
1079
1569
 
1080
- // 2a. macOS launchd
1570
+ // 2. Configure Codex Desktop + Claude Desktop
1571
+ // Codex Desktop reads ~/.codex/config.toml (openai_base_url key).
1572
+ configureCodexDesktop(port)
1573
+
1574
+ // Register MCP server in Claude Desktop and Codex Desktop automatically
1575
+ await mcpInstall()
1576
+
1577
+ // Claude Desktop (GUI app) does not read shell env vars.
1578
+ // macOS: inject via a launchd env-setter plist (persists across reboots).
1579
+ // Linux: write to ~/.config/environment.d/ (systemd user env, read by GUI apps).
1580
+ if (platform === 'darwin') {
1581
+ const envPlistDir = path.join(os.homedir(), 'Library', 'LaunchAgents')
1582
+ const envPlistPath = path.join(envPlistDir, 'com.squeezr.env.plist')
1583
+ fs.mkdirSync(envPlistDir, { recursive: true })
1584
+ const envVars = [
1585
+ ['ANTHROPIC_BASE_URL', `http://localhost:${port}`],
1586
+ ['GEMINI_API_BASE_URL', `http://localhost:${port}`],
1587
+ ['NODE_EXTRA_CA_CERTS', bundlePath],
1588
+ ]
1589
+ const envSetCmds = envVars.map(([k, v]) => `launchctl setenv ${k} "${v}"`).join(' && ')
1590
+ fs.writeFileSync(envPlistPath, `<?xml version="1.0" encoding="UTF-8"?>
1591
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1592
+ <plist version="1.0">
1593
+ <dict>
1594
+ <key>Label</key><string>com.squeezr.env</string>
1595
+ <key>ProgramArguments</key>
1596
+ <array><string>/bin/sh</string><string>-c</string><string>${envSetCmds}</string></array>
1597
+ <key>RunAtLoad</key><true/>
1598
+ </dict>
1599
+ </plist>`)
1600
+ try {
1601
+ execSync(`launchctl unload "${envPlistPath}" 2>/dev/null; launchctl load -w "${envPlistPath}"`, { stdio: 'pipe' })
1602
+ console.log(` [ok] Claude Desktop: env vars set via launchctl (visible to all GUI apps)`)
1603
+ } catch {
1604
+ console.log(` [warn] Claude Desktop: launchctl env plist failed — restart Claude Desktop manually after setup`)
1605
+ }
1606
+ } else {
1607
+ // Linux: systemd user environment.d — read by all user processes incl. GUI apps
1608
+ const envDDir = path.join(os.homedir(), '.config', 'environment.d')
1609
+ const envDPath = path.join(envDDir, 'squeezr.conf')
1610
+ fs.mkdirSync(envDDir, { recursive: true })
1611
+ fs.writeFileSync(envDPath, [
1612
+ `# Squeezr — visible to all GUI apps (Claude Desktop, etc.)`,
1613
+ `ANTHROPIC_BASE_URL=http://localhost:${port}`,
1614
+ `GEMINI_API_BASE_URL=http://localhost:${port}`,
1615
+ `NODE_EXTRA_CA_CERTS=${bundlePath}`,
1616
+ ].join('\n') + '\n')
1617
+ console.log(` [ok] Claude Desktop: env vars written to ${envDPath} (effective after next login)`)
1618
+ }
1619
+
1620
+ // 3a. macOS — launchd
1081
1621
  if (platform === 'darwin') {
1082
1622
  const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents')
1083
1623
  const plistPath = path.join(plistDir, 'com.squeezr.plist')
@@ -1126,7 +1666,7 @@ function setupUnix() {
1126
1666
  }
1127
1667
  })
1128
1668
 
1129
- // 2b. Linux — systemd
1669
+ // 3b. Linux — systemd
1130
1670
  } else {
1131
1671
  const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user')
1132
1672
  const servicePath = path.join(serviceDir, 'squeezr.service')
@@ -1156,12 +1696,17 @@ WantedBy=default.target
1156
1696
  console.log(`
1157
1697
  Done!
1158
1698
 
1159
- Squeezr is running on http://localhost:8080
1160
- All CLIs (Claude Code, Codex, Aider, Gemini, Ollama) are configured.
1699
+ Squeezr is running on http://localhost:${port}
1161
1700
 
1162
- Run: source ${profile} (or open a new terminal)
1163
- After that, everything is automatic.
1701
+ Configured:
1702
+ Claude Code ANTHROPIC_BASE_URL=http://localhost:${port}
1703
+ Claude Desktop ${platform === 'darwin' ? 'env vars set via launchctl (restart app once)' : 'env vars in ~/.config/environment.d/ (re-login to activate)'}
1704
+ Codex Desktop ~/.codex/config.toml openai_base_url set
1705
+ Codex CLI HTTPS_PROXY=http://localhost:${mitmPort} codex (per-session)
1706
+ Aider / OpenCode ANTHROPIC_BASE_URL + openai_base_url set
1707
+ Gemini CLI GEMINI_API_BASE_URL=http://localhost:${port}
1164
1708
 
1709
+ Run: source ${profile} (or open a new terminal)
1165
1710
  squeezr status — check it's running
1166
1711
  squeezr gain — see token savings
1167
1712
  `)
@@ -1181,7 +1726,7 @@ function isWSL() {
1181
1726
 
1182
1727
  // ── squeezr setup — WSL2 ────────────────────────────────────────────────────
1183
1728
 
1184
- function setupWSL() {
1729
+ async function setupWSL() {
1185
1730
  const nodeExe = process.execPath
1186
1731
  const distIndex = path.join(ROOT, 'dist', 'index.js')
1187
1732
 
@@ -1257,6 +1802,7 @@ function setupWSL() {
1257
1802
  }
1258
1803
 
1259
1804
  // 2. Set Windows env vars via setx.exe (so Windows-launched CLIs see them)
1805
+ // ANTHROPIC_BASE_URL via setx is also visible to Claude Desktop (GUI app).
1260
1806
  const setxExe = '/mnt/c/Windows/System32/setx.exe'
1261
1807
  const winVars = {
1262
1808
  ANTHROPIC_BASE_URL: 'http://localhost:8080',
@@ -1276,6 +1822,37 @@ function setupWSL() {
1276
1822
  console.log(' [skip] setx.exe not found — Windows env vars not set')
1277
1823
  }
1278
1824
 
1825
+ // 3. Configure Codex Desktop + MCP
1826
+ // WSL-side ~/.codex/config.toml (for Codex Desktop running in WSL)
1827
+ configureCodexDesktop(port)
1828
+
1829
+ // Register MCP server in Claude Desktop and Codex Desktop automatically
1830
+ await mcpInstall()
1831
+ // Windows-side %USERPROFILE%\.codex\config.toml (for Codex Desktop on Windows)
1832
+ try {
1833
+ const winHome = execSync('cmd.exe /c echo %USERPROFILE%', { stdio: 'pipe' }).toString().trim().replace(/\r/g, '')
1834
+ const winCodexDir = winHome + '\\.codex'
1835
+ const winMountedDir = winCodexDir.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`)
1836
+ const winMountedToml = winMountedDir + '/config.toml'
1837
+ const winUrl = `http://localhost:${port}/v1`
1838
+ const winLine = `openai_base_url = "${winUrl}"`
1839
+ fs.mkdirSync(winMountedDir, { recursive: true })
1840
+ if (fs.existsSync(winMountedToml)) {
1841
+ let content = fs.readFileSync(winMountedToml, 'utf-8')
1842
+ if (content.includes('openai_base_url')) {
1843
+ content = content.replace(/openai_base_url\s*=\s*"[^"]*"/, winLine)
1844
+ fs.writeFileSync(winMountedToml, content)
1845
+ } else {
1846
+ fs.appendFileSync(winMountedToml, `\n# Squeezr\n${winLine}\n`)
1847
+ }
1848
+ } else {
1849
+ fs.writeFileSync(winMountedToml, `# Squeezr\n${winLine}\n`)
1850
+ }
1851
+ console.log(` [ok] Codex Desktop (Windows): ${winCodexDir}\\config.toml`)
1852
+ } catch {
1853
+ console.log(` [skip] Codex Desktop (Windows): could not write config`)
1854
+ }
1855
+
1279
1856
  // 3. Auto-start: try systemd first (WSL2 with systemd enabled), fallback to
1280
1857
  // Windows Task Scheduler, then plain background process
1281
1858
  let autoStartDone = false
@@ -1350,7 +1927,14 @@ WantedBy=default.target
1350
1927
  Done!
1351
1928
 
1352
1929
  Squeezr is running on http://localhost:${setupPort}
1353
- All CLIs (Claude Code, Codex, Aider, Gemini, Ollama) are configured.
1930
+
1931
+ Configured:
1932
+ Claude Code ANTHROPIC_BASE_URL=http://localhost:${setupPort}
1933
+ Claude Desktop Windows setx env var set (restart app once to pick it up)
1934
+ Codex Desktop ~/.codex/config.toml + Windows %USERPROFILE%\\.codex\\config.toml
1935
+ Codex CLI HTTPS_PROXY=http://localhost:${setupMitmPort} codex (per-session)
1936
+ Aider / OpenCode ANTHROPIC_BASE_URL + openai_base_url set
1937
+ Gemini CLI GEMINI_API_BASE_URL=http://localhost:${setupPort}
1354
1938
 
1355
1939
  Windows env vars are set (effective in new terminals immediately).
1356
1940
  WSL env vars added to ${profile}.
@@ -1624,12 +2208,24 @@ switch (command) {
1624
2208
  case 'uninstall':
1625
2209
  await uninstall()
1626
2210
  break
2211
+ case 'enable-claude-desktop':
2212
+ case 'disable-claude-desktop':
2213
+ await toggleClaudeDesktopIntercept(command === 'enable-claude-desktop')
2214
+ break
2215
+ case 'desktop': {
2216
+ const sub = args[1] ?? 'status'
2217
+ if (sub === 'start') await desktopProxyStart()
2218
+ else if (sub === 'stop') await desktopProxyStop()
2219
+ else if (sub === 'status') await desktopProxyStatus()
2220
+ else { console.error(`Unknown desktop subcommand: ${sub}. Use start|stop|status`); process.exit(1) }
2221
+ break
2222
+ }
1627
2223
  case 'config':
1628
2224
  showConfig()
1629
2225
  break
1630
2226
 
1631
2227
  case 'mcp': {
1632
- const subCmd = args[0] ?? 'install'
2228
+ const subCmd = args[1] ?? 'install'
1633
2229
  if (subCmd === 'uninstall') await mcpUninstall()
1634
2230
  else await mcpInstall()
1635
2231
  break