squeezr-ai 1.46.3 → 1.80.7

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 (119) hide show
  1. package/README.md +199 -315
  2. package/bin/squeezr.js +2535 -2251
  3. package/dist/__tests__/aiRateLimit.test.d.ts +1 -0
  4. package/dist/__tests__/aiRateLimit.test.js +20 -0
  5. package/dist/__tests__/attachmentDedup.test.d.ts +1 -0
  6. package/dist/__tests__/attachmentDedup.test.js +89 -0
  7. package/dist/__tests__/compressibilityProbe.test.d.ts +1 -0
  8. package/dist/__tests__/compressibilityProbe.test.js +45 -0
  9. package/dist/__tests__/compressionGuard.test.d.ts +1 -0
  10. package/dist/__tests__/compressionGuard.test.js +57 -0
  11. package/dist/__tests__/compressor.test.js +104 -51
  12. package/dist/__tests__/diffRead.test.d.ts +1 -0
  13. package/dist/__tests__/diffRead.test.js +83 -0
  14. package/dist/__tests__/glossaryStore.test.d.ts +1 -0
  15. package/dist/__tests__/glossaryStore.test.js +37 -0
  16. package/dist/__tests__/glossarySub.test.d.ts +1 -0
  17. package/dist/__tests__/glossarySub.test.js +162 -0
  18. package/dist/__tests__/imageDedup.test.d.ts +1 -0
  19. package/dist/__tests__/imageDedup.test.js +80 -0
  20. package/dist/__tests__/largeBlock.test.d.ts +1 -0
  21. package/dist/__tests__/largeBlock.test.js +35 -0
  22. package/dist/__tests__/mcpFilter.test.d.ts +1 -0
  23. package/dist/__tests__/mcpFilter.test.js +87 -0
  24. package/dist/__tests__/newFeatures.test.d.ts +1 -0
  25. package/dist/__tests__/newFeatures.test.js +124 -0
  26. package/dist/__tests__/qualityHarness.test.d.ts +1 -0
  27. package/dist/__tests__/qualityHarness.test.js +98 -0
  28. package/dist/__tests__/rateLimitHeaders.test.js +6 -0
  29. package/dist/__tests__/requestCapture.test.d.ts +1 -0
  30. package/dist/__tests__/requestCapture.test.js +37 -0
  31. package/dist/__tests__/skillDedup.test.d.ts +1 -0
  32. package/dist/__tests__/skillDedup.test.js +57 -0
  33. package/dist/__tests__/staleTurns.test.d.ts +1 -0
  34. package/dist/__tests__/staleTurns.test.js +113 -0
  35. package/dist/__tests__/structuredGuard.test.d.ts +1 -0
  36. package/dist/__tests__/structuredGuard.test.js +72 -0
  37. package/dist/__tests__/toolDescComp.test.d.ts +1 -0
  38. package/dist/__tests__/toolDescComp.test.js +157 -0
  39. package/dist/__tests__/toolResultDedup.test.d.ts +1 -0
  40. package/dist/__tests__/toolResultDedup.test.js +40 -0
  41. package/dist/aiRateLimit.d.ts +19 -0
  42. package/dist/aiRateLimit.js +35 -0
  43. package/dist/aiToggle.d.ts +14 -0
  44. package/dist/aiToggle.js +53 -0
  45. package/dist/attachmentCompress.d.ts +9 -0
  46. package/dist/attachmentCompress.js +211 -0
  47. package/dist/attachmentDedup.d.ts +9 -0
  48. package/dist/attachmentDedup.js +89 -0
  49. package/dist/bypass.d.ts +6 -3
  50. package/dist/bypass.js +37 -5
  51. package/dist/cache.d.ts +3 -0
  52. package/dist/cache.js +10 -0
  53. package/dist/circuitBreaker.d.ts +4 -2
  54. package/dist/circuitBreaker.js +6 -3
  55. package/dist/compressibilityProbe.d.ts +8 -0
  56. package/dist/compressibilityProbe.js +47 -0
  57. package/dist/compressionGuard.d.ts +31 -0
  58. package/dist/compressionGuard.js +101 -0
  59. package/dist/compressor.d.ts +51 -1
  60. package/dist/compressor.js +599 -73
  61. package/dist/config.d.ts +21 -1
  62. package/dist/config.js +64 -4
  63. package/dist/dashboard.d.ts +1 -1
  64. package/dist/dashboard.js +621 -116
  65. package/dist/diffRead.d.ts +9 -0
  66. package/dist/diffRead.js +149 -0
  67. package/dist/expand.d.ts +2 -0
  68. package/dist/expand.js +6 -0
  69. package/dist/glossaryStore.d.ts +28 -0
  70. package/dist/glossaryStore.js +131 -0
  71. package/dist/glossarySub.d.ts +38 -0
  72. package/dist/glossarySub.js +123 -0
  73. package/dist/history.d.ts +35 -1
  74. package/dist/history.js +31 -5
  75. package/dist/identGlossary.d.ts +20 -0
  76. package/dist/identGlossary.js +215 -0
  77. package/dist/imageDedup.d.ts +12 -0
  78. package/dist/imageDedup.js +98 -0
  79. package/dist/index.js +7 -0
  80. package/dist/limits.d.ts +5 -2
  81. package/dist/limits.js +47 -4
  82. package/dist/logFeed.d.ts +10 -0
  83. package/dist/logFeed.js +42 -0
  84. package/dist/mcpFilter.d.ts +43 -0
  85. package/dist/mcpFilter.js +89 -0
  86. package/dist/mcpToolFilter.d.ts +32 -0
  87. package/dist/mcpToolFilter.js +140 -0
  88. package/dist/probePort.js +5 -1
  89. package/dist/promptCache.d.ts +44 -0
  90. package/dist/promptCache.js +121 -0
  91. package/dist/qualityGovernor.d.ts +11 -0
  92. package/dist/qualityGovernor.js +69 -0
  93. package/dist/requestCapture.d.ts +21 -0
  94. package/dist/requestCapture.js +79 -0
  95. package/dist/semanticRead.d.ts +9 -0
  96. package/dist/semanticRead.js +188 -0
  97. package/dist/server.js +447 -46
  98. package/dist/sessionCache.js +9 -2
  99. package/dist/skillDedup.d.ts +5 -0
  100. package/dist/skillDedup.js +89 -0
  101. package/dist/staleTurnSummary.d.ts +9 -0
  102. package/dist/staleTurnSummary.js +110 -0
  103. package/dist/staleTurns.d.ts +14 -0
  104. package/dist/staleTurns.js +80 -0
  105. package/dist/stats.d.ts +16 -3
  106. package/dist/stats.js +157 -21
  107. package/dist/stockToolDescs.d.ts +12 -0
  108. package/dist/stockToolDescs.js +69 -0
  109. package/dist/structuredGuard.d.ts +25 -0
  110. package/dist/structuredGuard.js +116 -0
  111. package/dist/systemPrompt.js +6 -2
  112. package/dist/systemSectioning.d.ts +21 -0
  113. package/dist/systemSectioning.js +111 -0
  114. package/dist/toolDescComp.d.ts +30 -0
  115. package/dist/toolDescComp.js +81 -0
  116. package/dist/toolResultDedup.d.ts +9 -0
  117. package/dist/toolResultDedup.js +88 -0
  118. package/package.json +69 -66
  119. package/squeezr.toml +18 -1
package/bin/squeezr.js CHANGED
@@ -1,2251 +1,2535 @@
1
- #!/usr/bin/env node
2
-
3
- import { spawn, execSync } from 'child_process'
4
- import http from 'http'
5
- import path from 'path'
6
- import fs from 'fs'
7
- import os from 'os'
8
- import { fileURLToPath } from 'url'
9
- import { createRequire } from 'module'
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
- const require = createRequire(import.meta.url)
13
- const ROOT = path.join(__dirname, '..')
14
- const pkg = require(path.join(ROOT, 'package.json'))
15
-
16
- const args = process.argv.slice(2)
17
- const command = args[0]
18
-
19
- // ── update check (non-blocking) ───────────────────────────────────────────────
20
-
21
- const UPDATE_CHECK_FILE = path.join(os.homedir(), '.squeezr', 'update-check.json')
22
- const UPDATE_CHECK_INTERVAL = 4 * 60 * 60 * 1000 // 4 hours
23
-
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
-
38
- const updateCheckPromise = (async () => {
39
- try {
40
- // Read cached check
41
- let cached = null
42
- try { cached = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf-8')) } catch {}
43
- if (cached && Date.now() - cached.checkedAt < UPDATE_CHECK_INTERVAL) {
44
- return newerVersionOrNull(cached.latest)
45
- }
46
- // Fetch latest from npm (with timeout)
47
- const { get } = await import('https')
48
- const latest = await new Promise((resolve, reject) => {
49
- const req = get('https://registry.npmjs.org/squeezr-ai/latest', { timeout: 3000 }, res => {
50
- let data = ''
51
- res.on('data', chunk => { data += chunk })
52
- res.on('end', () => {
53
- try { resolve(JSON.parse(data).version) } catch { resolve(null) }
54
- })
55
- })
56
- req.on('error', () => resolve(null))
57
- req.setTimeout(3000, () => { req.destroy(); resolve(null) })
58
- })
59
- if (!latest) return null
60
- // Cache result
61
- const dir = path.dirname(UPDATE_CHECK_FILE)
62
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
63
- fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ latest, checkedAt: Date.now() }))
64
- return newerVersionOrNull(latest)
65
- } catch { return null }
66
- })()
67
-
68
- async function showUpdateBanner() {
69
- try {
70
- const latest = await Promise.race([updateCheckPromise, new Promise(r => setTimeout(() => r(null), 500))])
71
- if (latest) {
72
- console.log('')
73
- console.log(` ╭─────────────────────────────────────────────────────────╮`)
74
- console.log(` │ Update available: v${pkg.version} → v${latest}${' '.repeat(Math.max(0, 30 - pkg.version.length - latest.length))}│`)
75
- console.log(` │ Run: squeezr update │`)
76
- console.log(` ╰─────────────────────────────────────────────────────────╯`)
77
- }
78
- } catch {}
79
- }
80
-
81
- function getPortFromToml() {
82
- try {
83
- const toml = fs.readFileSync(path.join(ROOT, 'squeezr.toml'), 'utf-8')
84
- const m = toml.match(/^port\s*=\s*(\d+)/m)
85
- if (m) return parseInt(m[1])
86
- } catch {}
87
- return null
88
- }
89
-
90
- // Runtime info written by src/index.ts after a successful listen(). Reflects
91
- // the *actual* bound port, which may differ from squeezr.toml when findFreePort
92
- // drifted because the configured port was occupied.
93
- const RUNTIME_FILE = path.join(os.homedir(), '.squeezr', 'runtime.json')
94
-
95
- function readRuntimeInfo() {
96
- try { return JSON.parse(fs.readFileSync(RUNTIME_FILE, 'utf-8')) } catch { return null }
97
- }
98
-
99
- function getMitmPort(port) {
100
- const envMitm = process.env.SQUEEZR_MITM_PORT
101
- if (envMitm) return parseInt(envMitm)
102
- const runtime = readRuntimeInfo()
103
- if (runtime && runtime.mitmPort) return runtime.mitmPort
104
- try {
105
- const toml = fs.readFileSync(path.join(ROOT, 'squeezr.toml'), 'utf-8')
106
- const m = toml.match(/^mitm_port\s*=\s*(\d+)/m)
107
- if (m) return parseInt(m[1])
108
- } catch {}
109
- return Number(port) + 1
110
- }
111
-
112
- function getPort() {
113
- if (process.env.SQUEEZR_PORT) return parseInt(process.env.SQUEEZR_PORT)
114
- const runtime = readRuntimeInfo()
115
- if (runtime && runtime.port) return runtime.port
116
- return getPortFromToml() || 8080
117
- }
118
-
119
- /**
120
- * Verifies that whatever is listening on `port` is actually a squeezr instance
121
- * (by checking the magic `identity` field in /squeezr/health), not an unrelated
122
- * HTTP service that happens to answer 200. Returns the parsed health JSON, or
123
- * null if the port is free, unreachable, or owned by a foreign service.
124
- */
125
- function probeSqueezr(port, timeoutMs = 1500) {
126
- return new Promise(resolve => {
127
- const req = http.get({ host: '127.0.0.1', port, path: '/squeezr/health' }, res => {
128
- let data = ''
129
- res.on('data', chunk => { data += chunk })
130
- res.on('end', () => {
131
- if (res.statusCode !== 200) return resolve(null)
132
- try {
133
- const json = JSON.parse(data)
134
- if (json && json.identity === 'squeezr') return resolve(json)
135
- } catch {}
136
- resolve(null)
137
- })
138
- })
139
- req.on('error', () => resolve(null))
140
- req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null) })
141
- })
142
- }
143
-
144
- /**
145
- * Install/update shell wrapper functions so env vars are auto-refreshed
146
- * after squeezr start/setup/update (child processes can't modify parent env).
147
- */
148
- function installShellWrapper() {
149
- if (process.platform === 'win32') return installPowerShellWrapper()
150
- if (isWSL() || process.platform === 'linux' || process.platform === 'darwin') return installBashWrapper()
151
- }
152
-
153
- function installBashWrapper() {
154
- const port = getPort()
155
- const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
156
- const marker = '# squeezr shell wrapper'
157
- const endMarker = '# end squeezr shell wrapper'
158
- const wrapper = `${marker}
159
- squeezr() {
160
- command squeezr "$@"
161
- case "$1" in
162
- start|setup|update)
163
- export ANTHROPIC_BASE_URL=http://localhost:${port}
164
- export GEMINI_API_BASE_URL=http://localhost:${port}
165
- export NODE_EXTRA_CA_CERTS=${bundlePath}
166
- ;;
167
- esac
168
- }
169
- ${endMarker}`
170
-
171
- const profiles = [
172
- path.join(os.homedir(), '.bashrc'),
173
- path.join(os.homedir(), '.zshrc'),
174
- ]
175
- let installed = false
176
- for (const p of profiles) {
177
- if (!fs.existsSync(p)) continue
178
- try {
179
- const content = fs.readFileSync(p, 'utf-8')
180
- if (!content.includes(marker)) {
181
- fs.appendFileSync(p, `\n${wrapper}\n`)
182
- console.log(` [ok] Shell wrapper added to ${p}`)
183
- installed = true
184
- } else {
185
- const updated = content.replace(new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), wrapper)
186
- fs.writeFileSync(p, updated)
187
- console.log(` [ok] Shell wrapper updated in ${p}`)
188
- }
189
- } catch {}
190
- }
191
- if (installed) {
192
- console.log('')
193
- console.log(' ╔═══════════════════════════════════════════════════════════════╗')
194
- console.log(' ║ ONE-TIME SETUP: Close this terminal and open a new one. ║')
195
- console.log(' ║ This loads the wrapper that auto-refreshes env vars. ║')
196
- console.log(' ║ After that, you will NEVER need to do this again. ║')
197
- console.log(' ╚═══════════════════════════════════════════════════════════════╝')
198
- }
199
- }
200
-
201
- function installPowerShellWrapper() {
202
- try {
203
- const psProfilePath = execSync('powershell -NoProfile -Command "[Environment]::GetFolderPath(\'MyDocuments\') + \'\\WindowsPowerShell\\Microsoft.PowerShell_profile.ps1\'"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
204
- const psProfileDir = path.dirname(psProfilePath)
205
- if (!fs.existsSync(psProfileDir)) fs.mkdirSync(psProfileDir, { recursive: true })
206
- const psMarker = '# squeezr wrapper'
207
- const psLines = []
208
- psLines.push(psMarker)
209
- psLines.push('function squeezr {')
210
- psLines.push(' & squeezr.cmd @args')
211
- psLines.push(' if (@("start","setup","update") -contains $args[0]) {')
212
- psLines.push(' foreach ($k in @("ANTHROPIC_BASE_URL","GEMINI_API_BASE_URL","NODE_EXTRA_CA_CERTS")) {')
213
- psLines.push(' $val = [Environment]::GetEnvironmentVariable($k, "User")')
214
- psLines.push(' if ($val) { [Environment]::SetEnvironmentVariable($k, $val, "Process") }')
215
- psLines.push(' }')
216
- psLines.push(' }')
217
- psLines.push('}')
218
- psLines.push('# end squeezr wrapper')
219
- const psFunction = psLines.join('\r\n')
220
- const existing = fs.existsSync(psProfilePath) ? fs.readFileSync(psProfilePath, 'utf-8') : ''
221
- if (!existing.includes(psMarker)) {
222
- fs.appendFileSync(psProfilePath, `\n${psFunction}\n`)
223
- console.log(` [ok] PowerShell wrapper added to ${psProfilePath}`)
224
- console.log('')
225
- console.log(' ╔═══════════════════════════════════════════════════════════════╗')
226
- console.log(' ║ ONE-TIME SETUP: Close this terminal and open a new one. ║')
227
- console.log(' ║ This loads the wrapper that auto-refreshes env vars. ║')
228
- console.log(' ║ After that, you will NEVER need to do this again. ║')
229
- console.log(' ╚═══════════════════════════════════════════════════════════════╝')
230
- } else {
231
- const updated = existing.replace(/# squeezr wrapper[\s\S]*?# end squeezr wrapper/, psFunction)
232
- fs.writeFileSync(psProfilePath, updated)
233
- console.log(` [ok] PowerShell wrapper updated in ${psProfilePath}`)
234
- }
235
- } catch {
236
- console.log(` [skip] PowerShell profile wrapper could not be installed`)
237
- }
238
- }
239
-
240
- const HELP = `
241
- Squeezr v${pkg.version} — AI context compressor for Claude Code, Codex, Aider, Gemini CLI and Ollama
242
-
243
- Usage:
244
- squeezr Start the proxy (default)
245
- squeezr start Start the proxy
246
- squeezr setup One-time setup: auto-start on login + configure all CLIs
247
- squeezr stop Stop the running proxy
248
- squeezr logs Show last 50 lines of the log file
249
- squeezr gain Show token savings stats
250
- squeezr gain --reset Reset saved stats
251
- squeezr discover Show pattern coverage report (proxy must be running)
252
- squeezr status Check if proxy is running
253
- squeezr config Print config file path and current settings
254
- squeezr mcp install Register Squeezr MCP server in Claude Code, Cursor, Windsurf & Cline
255
- squeezr mcp uninstall Remove Squeezr MCP registration
256
- squeezr ports Change HTTP and MITM proxy ports
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
263
- squeezr bypass Toggle bypass mode (skip compression, keep logging)
264
- squeezr bypass --on Enable bypass (disable compression)
265
- squeezr bypass --off Disable bypass (resume compression)
266
- squeezr update Kill old processes, install latest from npm, restart
267
- squeezr uninstall Remove Squeezr completely (env vars, CA, auto-start, logs)
268
- squeezr version Print version
269
- squeezr help Show this help
270
- `
271
-
272
- function runNode(script, extraArgs = []) {
273
- const distPath = path.join(ROOT, 'dist', script)
274
- if (!fs.existsSync(distPath)) {
275
- console.error(`Error: ${distPath} not found. Run 'npm run build' first.`)
276
- process.exit(1)
277
- }
278
- const child = spawn(process.execPath, [distPath, ...extraArgs], {
279
- stdio: 'inherit',
280
- cwd: ROOT,
281
- })
282
- child.on('exit', code => process.exit(code ?? 0))
283
- }
284
-
285
- async function startDaemon() {
286
- const distIndex = path.join(ROOT, 'dist', 'index.js')
287
- if (!fs.existsSync(distIndex)) {
288
- console.error(`Error: ${distIndex} not found. Run 'npm run build' first.`)
289
- process.exit(1)
290
- }
291
-
292
- // Check if already running — and if the version matches. We use probeSqueezr
293
- // (which validates the `identity` field) so we don't mistake an unrelated
294
- // HTTP service squatting on this port for a real squeezr instance.
295
- const port = getPort()
296
- const running = await probeSqueezr(port)
297
- const runningVersion = running ? running.version : null
298
- if (runningVersion) {
299
- if (runningVersion === pkg.version) {
300
- const mitmPort = getMitmPort(port)
301
- console.log(`Squeezr is already running (v${pkg.version})`)
302
- console.log(` HTTP proxy (Claude/Aider/Gemini): http://localhost:${port}`)
303
- console.log(` MITM proxy (Codex): http://localhost:${mitmPort}`)
304
- console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
305
- return
306
- }
307
- // Version mismatch — old process from before npm update. Kill and restart.
308
- console.log(`Squeezr v${runningVersion} is running but v${pkg.version} is installed. Restarting...`)
309
- stopProxy()
310
- // Wait for ports to free up
311
- await new Promise(r => setTimeout(r, 1500))
312
- }
313
-
314
- // Launch detached background process
315
- const logDir = path.join(os.homedir(), '.squeezr')
316
- const logFile = path.join(logDir, 'squeezr.log')
317
- fs.mkdirSync(logDir, { recursive: true })
318
- const logFd = fs.openSync(logFile, 'a')
319
- const child = spawn(process.execPath, [distIndex], {
320
- detached: true,
321
- stdio: ['ignore', logFd, logFd],
322
- windowsHide: true,
323
- cwd: ROOT,
324
- env: { ...process.env, SQUEEZR_DAEMON: '1' },
325
- })
326
- child.unref()
327
- fs.closeSync(logFd)
328
- const mitmPort = getMitmPort(port)
329
- console.log(`Squeezr started (pid ${child.pid})`)
330
- console.log(` HTTP proxy (Claude/Aider/Gemini): http://localhost:${port}`)
331
- console.log(` MITM proxy (Codex): http://localhost:${mitmPort}`)
332
- console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
333
- console.log(` Logs: ${logFile}`)
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
- }
346
- }
347
-
348
- function showLogs() {
349
- const logFile = path.join(os.homedir(), '.squeezr', 'squeezr.log')
350
- if (!fs.existsSync(logFile)) {
351
- console.log('No log file yet. Run: squeezr setup')
352
- return
353
- }
354
- // Show last 50 lines
355
- const lines = fs.readFileSync(logFile, 'utf-8').split('\n').filter(Boolean)
356
- const tail = lines.slice(-50)
357
- if (tail.length === 0) {
358
- console.log('Log file is empty — no requests yet.')
359
- return
360
- }
361
- console.log(`=== ${logFile} (last ${tail.length} lines) ===\n`)
362
- console.log(tail.join('\n'))
363
- }
364
-
365
- function killMcpProcesses() {
366
- if (process.platform === 'win32') {
367
- try {
368
- execSync(
369
- `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -like '*squeezr*mcp*' -or $_.CommandLine -like '*mcp.js*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"`,
370
- { stdio: 'pipe' }
371
- )
372
- } catch {}
373
- } else {
374
- try { execSync(`pkill -f 'squeezr.*mcp' 2>/dev/null`, { stdio: 'pipe' }) } catch {}
375
- try { execSync(`pkill -f 'mcp\\.js' 2>/dev/null`, { stdio: 'pipe' }) } catch {}
376
- }
377
- }
378
-
379
- function stopProxy() {
380
- const port = getPort()
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) ──────────────────
429
- const ports = [port, mitmPort]
430
- let killed = gracefulOk // count graceful as "killed"
431
-
432
- for (const p of ports) {
433
- try {
434
- let pids = []
435
- if (process.platform === 'win32') {
436
- const out = execSync(`netstat -ano | findstr ":${p} "`, { encoding: 'utf-8', stdio: 'pipe' })
437
- const matches = [...out.matchAll(/LISTENING\s+(\d+)/g)]
438
- pids = [...new Set(matches.map(m => m[1]))]
439
- } else {
440
- try {
441
- const out = execSync(`lsof -ti :${p} -sTCP:LISTEN`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
442
- pids = out.split(/\s+/).filter(Boolean)
443
- } catch {
444
- try {
445
- const out = execSync(`fuser ${p}/tcp 2>/dev/null`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
446
- pids = out.split(/\s+/).filter(Boolean)
447
- } catch {}
448
- }
449
- }
450
- for (const pid of pids) {
451
- try {
452
- if (process.platform === 'win32') {
453
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' })
454
- } else {
455
- execSync(`kill -9 ${pid}`, { stdio: 'pipe' })
456
- }
457
- killed = true
458
- } catch {}
459
- }
460
- } catch {}
461
- }
462
-
463
- // Also stop the MCP server process
464
- killMcpProcesses()
465
-
466
- // Clear HTTPS_PROXY so npm and other tools don't try to use the dead proxy
467
- if (process.platform === 'win32') {
468
- try { execSync('setx HTTPS_PROXY ""', { stdio: 'pipe' }) } catch {}
469
- } else if (isWSL()) {
470
- try {
471
- const setxExe = '/mnt/c/Windows/System32/setx.exe'
472
- if (fs.existsSync(setxExe)) execSync(`"${setxExe}" HTTPS_PROXY ""`, { stdio: 'pipe' })
473
- } catch {}
474
- }
475
-
476
- if (killed) {
477
- console.log(`Squeezr stopped`)
478
- } else {
479
- console.log(`Squeezr is not running`)
480
- }
481
- }
482
-
483
- async function checkStatus() {
484
- const port = getPort()
485
- const mitmPort = getMitmPort(port)
486
- const json = await probeSqueezr(port, 2000)
487
- if (!json) {
488
- // Distinguish "nothing here" from "something foreign here" so the user gets
489
- // an actionable error instead of a misleading "not running".
490
- const occupied = await new Promise(resolve => {
491
- const req = http.get(`http://localhost:${port}/`, res => {
492
- resolve({ status: res.statusCode, server: res.headers.server })
493
- res.resume()
494
- })
495
- req.on('error', () => resolve(null))
496
- req.setTimeout(1500, () => { req.destroy(); resolve(null) })
497
- })
498
- if (occupied) {
499
- console.log(`Squeezr is NOT running on port ${port}, but a foreign service is.`)
500
- console.log(` Foreign response: HTTP ${occupied.status}${occupied.server ? ` (Server: ${occupied.server})` : ''}`)
501
- console.log(` Stop it or change squeezr.toml port, then run: squeezr start`)
502
- } else {
503
- console.log(`Squeezr is NOT running`)
504
- console.log('Start it with: squeezr start')
505
- }
506
- return false
507
- }
508
- console.log(`Squeezr is running (v${json.version})`)
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`)
512
- if (json.mode) console.log(` Mode: ${json.mode}`)
513
- if (json.uptime_seconds != null) {
514
- const s = json.uptime_seconds
515
- const fmt = s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m ${s%60}s` : `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`
516
- console.log(` Uptime: ${fmt}`)
517
- }
518
- if (json.bypassed) console.log(` ⚠ Bypass mode is ON (compression disabled)`)
519
- if (json.circuit_breaker) {
520
- const cb = json.circuit_breaker
521
- const icons = { closed: '🟢 OK', open: '🔴 OPEN', 'half-open': '🟡 PROBING' }
522
- console.log(` Circuit: ${icons[cb.state] || cb.state}${cb.total_trips ? ` (${cb.total_trips} trip${cb.total_trips > 1 ? 's' : ''})` : ''}`)
523
- }
524
- return true
525
- }
526
-
527
- function showConfig() {
528
- const tomlPath = path.join(ROOT, 'squeezr.toml')
529
- console.log(`Config file: ${tomlPath}`)
530
- if (fs.existsSync(tomlPath)) {
531
- console.log('\nCurrent config:')
532
- console.log(fs.readFileSync(tomlPath, 'utf-8'))
533
- } else {
534
- console.log('No squeezr.toml found. Using defaults.')
535
- }
536
- }
537
-
538
-
539
- // ── squeezr mcp ───────────────────────────────────────────────────────────────
540
-
541
- async function mcpInstall() {
542
- const mcpServerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'mcp.js')
543
- const entry = {
544
- type: 'stdio',
545
- command: 'node',
546
- args: [mcpServerPath],
547
- }
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
-
556
- const targets = [
557
- {
558
- name: 'Claude Code',
559
- file: path.join(os.homedir(), '.claude.json'),
560
- key: 'mcpServers',
561
- },
562
- {
563
- name: 'Claude Desktop',
564
- file: claudeDesktopConfig,
565
- key: 'mcpServers',
566
- },
567
- {
568
- name: 'Cursor',
569
- file: path.join(os.homedir(), '.cursor', 'mcp.json'),
570
- key: 'mcpServers',
571
- },
572
- {
573
- name: 'Windsurf',
574
- file: path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
575
- key: 'mcpServers',
576
- },
577
- {
578
- name: 'Cline / Roo-Cline',
579
- file: path.join(os.homedir(), '.vscode', 'extensions', 'mcp_settings.json'),
580
- key: 'mcpServers',
581
- },
582
- ]
583
-
584
- let installed = 0
585
-
586
- for (const target of targets) {
587
- try {
588
- // Only install into configs that already exist (user has that tool)
589
- if (!fs.existsSync(target.file) && target.name !== 'Claude Code') continue
590
-
591
- let cfg = {}
592
- if (fs.existsSync(target.file)) {
593
- try { cfg = JSON.parse(fs.readFileSync(target.file, 'utf-8')) } catch { cfg = {} }
594
- }
595
- cfg[target.key] = cfg[target.key] || {}
596
- cfg[target.key].squeezr = entry
597
-
598
- const dir = path.dirname(target.file)
599
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
600
- fs.writeFileSync(target.file, JSON.stringify(cfg, null, 2))
601
- installed++
602
- console.log()
603
- console.log(' ok ' + target.name + ': ' + target.file)
604
- } catch (e) {
605
- console.warn()
606
- console.warn(' warn ' + target.name + ': ' + (e.message || e))
607
- }
608
- }
609
-
610
- console.log()
611
- console.log('MCP server registered in ' + installed + ' client(s).')
612
- console.log('Server binary: ' + mcpServerPath)
613
- console.log('')
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')
621
- }
622
-
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')
629
- const files = [
630
- path.join(os.homedir(), '.claude.json'),
631
- claudeDesktopConfig,
632
- path.join(os.homedir(), '.cursor', 'mcp.json'),
633
- path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
634
- path.join(os.homedir(), '.vscode', 'extensions', 'mcp_settings.json'),
635
- ]
636
- let removed = 0
637
- for (const file of files) {
638
- if (!fs.existsSync(file)) continue
639
- try {
640
- const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'))
641
- if (cfg.mcpServers?.squeezr) {
642
- delete cfg.mcpServers.squeezr
643
- fs.writeFileSync(file, JSON.stringify(cfg, null, 2))
644
- console.log()
645
- removed++
646
- }
647
- } catch { /* ignore */ }
648
- }
649
- if (removed === 0) console.log('Squeezr MCP not found in any config.')
650
- else console.log()
651
- }
652
-
653
- // ── squeezr ports ─────────────────────────────────────────────────────────────
654
-
655
- async function configurePorts() {
656
- const { createInterface } = await import('readline')
657
- const tomlPath = path.join(ROOT, 'squeezr.toml')
658
- let tomlContent = fs.existsSync(tomlPath) ? fs.readFileSync(tomlPath, 'utf-8') : ''
659
-
660
- // Read current ports from toml
661
- const portMatch = tomlContent.match(/^port\s*=\s*(\d+)/m)
662
- const mitmMatch = tomlContent.match(/^mitm_port\s*=\s*(\d+)/m)
663
- const currentPort = portMatch ? parseInt(portMatch[1]) : 8080
664
- const currentMitm = mitmMatch ? parseInt(mitmMatch[1]) : currentPort + 1
665
-
666
- const rl = createInterface({ input: process.stdin, output: process.stdout })
667
- const ask = (q) => new Promise(resolve => rl.question(q, resolve))
668
-
669
- console.log(`\nCurrent ports:`)
670
- console.log(` HTTP proxy (Claude/Aider/Gemini): ${currentPort}`)
671
- console.log(` MITM proxy (Codex): ${currentMitm}`)
672
- console.log(` Dashboard: ${currentPort}/squeezr/dashboard (same port as proxy)\n`)
673
-
674
- const newPort = await ask(`HTTP proxy port [${currentPort}]: `)
675
- const newMitm = await ask(`MITM proxy port [${currentMitm}]: `)
676
- rl.close()
677
-
678
- const finalPort = newPort.trim() ? parseInt(newPort.trim()) : currentPort
679
- const finalMitm = newMitm.trim() ? parseInt(newMitm.trim()) : currentMitm
680
-
681
- if (isNaN(finalPort) || isNaN(finalMitm) || finalPort < 1 || finalMitm < 1 || finalPort > 65535 || finalMitm > 65535) {
682
- console.error('Invalid port number. Must be between 1 and 65535.')
683
- process.exit(1)
684
- }
685
- if (finalPort === finalMitm) {
686
- console.error('HTTP and MITM ports must be different.')
687
- process.exit(1)
688
- }
689
-
690
- // Update toml
691
- if (portMatch) {
692
- tomlContent = tomlContent.replace(/^port\s*=\s*\d+/m, `port = ${finalPort}`)
693
- } else if (tomlContent.includes('[proxy]')) {
694
- tomlContent = tomlContent.replace('[proxy]', `[proxy]\nport = ${finalPort}`)
695
- } else {
696
- tomlContent = `[proxy]\nport = ${finalPort}\n` + tomlContent
697
- }
698
-
699
- if (mitmMatch) {
700
- tomlContent = tomlContent.replace(/^mitm_port\s*=\s*\d+/m, `mitm_port = ${finalMitm}`)
701
- } else {
702
- // Add after port line
703
- tomlContent = tomlContent.replace(/^(port\s*=\s*\d+)/m, `$1\nmitm_port = ${finalMitm}`)
704
- }
705
-
706
- fs.writeFileSync(tomlPath, tomlContent)
707
- console.log(`\nSaved to ${tomlPath}`)
708
-
709
- // Update env vars
710
- if (process.platform === 'win32') {
711
- try { execSync(`setx SQUEEZR_PORT "${finalPort}"`, { stdio: 'pipe' }) } catch {}
712
- try { execSync(`setx SQUEEZR_MITM_PORT "${finalMitm}"`, { stdio: 'pipe' }) } catch {}
713
- try { execSync(`setx ANTHROPIC_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
714
- try { execSync(`setx GEMINI_API_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
715
- console.log('Environment variables updated. Restart your terminal for changes to take effect.')
716
- } else {
717
- // Update shell profiles directly
718
- const profiles = [
719
- path.join(os.homedir(), '.zshrc'),
720
- path.join(os.homedir(), '.bashrc'),
721
- path.join(os.homedir(), '.bash_profile'),
722
- ]
723
- const envBlock = [
724
- `export SQUEEZR_PORT=${finalPort}`,
725
- `export SQUEEZR_MITM_PORT=${finalMitm}`,
726
- `export ANTHROPIC_BASE_URL=http://localhost:${finalPort}`,
727
- `export GEMINI_API_BASE_URL=http://localhost:${finalPort}`,
728
- ].join('\n')
729
- for (const p of profiles) {
730
- try {
731
- let content = fs.readFileSync(p, 'utf-8')
732
- if (content.includes('# squeezr env vars')) {
733
- // Replace existing block (from marker to the closing fi)
734
- content = content.replace(
735
- /# squeezr env vars[\s\S]*?(?:fi|unset -f _squeezr_alive)/,
736
- `# squeezr env vars\n${envBlock}\n# squeezr auto-heal (validates identity, not just HTTP 200)\n_squeezr_alive() {\n curl -sf --max-time 2 "http://localhost:${finalPort}/squeezr/health" 2>/dev/null | grep -q '"identity":"squeezr"'\n}\nif ! _squeezr_alive; then squeezr start > /dev/null 2>&1; fi\nunset -f _squeezr_alive`
737
- )
738
- fs.writeFileSync(p, content)
739
- console.log(` [ok] Updated ${p}`)
740
- }
741
- } catch {}
742
- }
743
- // Also update env for WSL setx if on WSL
744
- try {
745
- const procVersion = fs.readFileSync('/proc/version', 'utf-8')
746
- if (/microsoft|wsl/i.test(procVersion)) {
747
- const setx = '/mnt/c/Windows/System32/setx.exe'
748
- try { execSync(`"${setx}" SQUEEZR_PORT "${finalPort}"`, { stdio: 'pipe' }) } catch {}
749
- try { execSync(`"${setx}" SQUEEZR_MITM_PORT "${finalMitm}"`, { stdio: 'pipe' }) } catch {}
750
- try { execSync(`"${setx}" ANTHROPIC_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
751
- try { execSync(`"${setx}" GEMINI_API_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
752
- }
753
- } catch {}
754
- }
755
-
756
- // Apply to current process so stop/start works immediately
757
- process.env.SQUEEZR_PORT = String(finalPort)
758
- process.env.SQUEEZR_MITM_PORT = String(finalMitm)
759
- process.env.ANTHROPIC_BASE_URL = `http://localhost:${finalPort}`
760
-
761
- // Auto stop + start
762
- console.log('')
763
- stopProxy()
764
- await new Promise(r => setTimeout(r, 1500))
765
- await startDaemon()
766
- console.log(`\nOpen a new terminal for env vars to apply to other tools.`)
767
- }
768
-
769
- // ── squeezr uninstall ─────────────────────────────────────────────────────────
770
-
771
- async function uninstall() {
772
- const { createInterface } = await import('readline')
773
- const rl = createInterface({ input: process.stdin, output: process.stdout })
774
- const answer = await new Promise(resolve => rl.question(
775
- 'This will remove Squeezr completely: stop proxy, remove env vars, CA certs, auto-start, config, and logs.\nContinue? [y/N] ', resolve
776
- ))
777
- rl.close()
778
- if (answer.trim().toLowerCase() !== 'y') {
779
- console.log('Cancelled.')
780
- return
781
- }
782
-
783
- console.log('\nUninstalling Squeezr...\n')
784
-
785
- // 1. Stop proxy
786
- stopProxy()
787
-
788
- // 2. Remove env vars
789
- if (process.platform === 'win32') {
790
- const vars = ['ANTHROPIC_BASE_URL', 'GEMINI_API_BASE_URL', 'HTTPS_PROXY', 'NODE_EXTRA_CA_CERTS', 'SQUEEZR_PORT', 'SQUEEZR_MITM_PORT', 'openai_base_url', 'NO_PROXY']
791
- for (const v of vars) {
792
- try { execSync(`reg delete "HKCU\\Environment" /v ${v} /f`, { stdio: 'pipe' }) } catch {}
793
- }
794
- console.log(' [ok] Windows env vars removed')
795
- } else {
796
- // Remove squeezr block from shell profiles
797
- const profiles = [
798
- path.join(os.homedir(), '.zshrc'),
799
- path.join(os.homedir(), '.bashrc'),
800
- path.join(os.homedir(), '.bash_profile'),
801
- ]
802
- for (const p of profiles) {
803
- try {
804
- const content = fs.readFileSync(p, 'utf-8')
805
- if (content.includes('# squeezr env vars')) {
806
- const cleaned = content.replace(/\n?# squeezr env vars[\s\S]*?fi\n?/g, '\n')
807
- fs.writeFileSync(p, cleaned)
808
- console.log(` [ok] Cleaned ${p}`)
809
- }
810
- } catch {}
811
- }
812
- // Also clean .profile
813
- const profilePath = path.join(os.homedir(), '.profile')
814
- try {
815
- const content = fs.readFileSync(profilePath, 'utf-8')
816
- if (content.includes('# squeezr env vars')) {
817
- const cleaned = content.replace(/\n?# squeezr env vars[^\n]*(\nexport [^\n]*)*/g, '')
818
- fs.writeFileSync(profilePath, cleaned)
819
- console.log(` [ok] Cleaned ${profilePath}`)
820
- }
821
- } catch {}
822
- }
823
-
824
- // 3. Remove CA from certificate stores
825
- if (process.platform === 'win32') {
826
- try { execSync('certutil -delstore -user Root "Squeezr-MITM-CA"', { stdio: 'pipe' }); console.log(' [ok] CA removed from user certificate store') } catch {}
827
- try { execSync('certutil -delstore Root "Squeezr-MITM-CA"', { stdio: 'pipe' }) } catch {}
828
- } else if (process.platform === 'darwin') {
829
- try { execSync('security delete-certificate -c "Squeezr-MITM-CA" ~/Library/Keychains/login.keychain-db', { stdio: 'pipe' }); console.log(' [ok] CA removed from Keychain') } catch {}
830
- }
831
- // On Linux, CA is only in bundle.crt which gets deleted with ~/.squeezr below
832
-
833
- // 4. Remove auto-start
834
- if (process.platform === 'win32') {
835
- try { execSync('nssm stop SqueezrProxy', { stdio: 'pipe' }) } catch {}
836
- try { execSync('nssm remove SqueezrProxy confirm', { stdio: 'pipe' }) } catch {}
837
- try { execSync('schtasks /Delete /TN "Squeezr" /F', { stdio: 'pipe' }); console.log(' [ok] Removed scheduled task') } catch {}
838
- // Remove Startup folder VBS (fallback auto-start)
839
- const startupVbs = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'squeezr-start.vbs')
840
- try { fs.unlinkSync(startupVbs); console.log(' [ok] Removed startup VBS script') } catch {}
841
- } else if (process.platform === 'darwin') {
842
- const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.squeezr.plist')
843
- try { execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' }) } catch {}
844
- try { fs.unlinkSync(plistPath); console.log(' [ok] Removed launchd plist') } catch {}
845
- } else {
846
- try { execSync('systemctl --user disable --now squeezr', { stdio: 'pipe' }) } catch {}
847
- const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', 'squeezr.service')
848
- try { fs.unlinkSync(servicePath); console.log(' [ok] Removed systemd service') } catch {}
849
- }
850
-
851
- // 5. Remove ~/.squeezr (logs, cache, CA, stats)
852
- const squeezrDir = path.join(os.homedir(), '.squeezr')
853
- try {
854
- fs.rmSync(squeezrDir, { recursive: true, force: true })
855
- console.log(` [ok] Removed ${squeezrDir}`)
856
- } catch {}
857
-
858
- // 6. Remove global config
859
- const tomlPath = path.join(ROOT, 'squeezr.toml')
860
- try { fs.unlinkSync(tomlPath) } catch {}
861
-
862
- // 7. Remove shell wrapper functions from profiles
863
- if (process.platform === 'win32') {
864
- try {
865
- const psProfilePath = execSync('powershell -NoProfile -Command "[Environment]::GetFolderPath(\'MyDocuments\') + \'\\WindowsPowerShell\\Microsoft.PowerShell_profile.ps1\'"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
866
- if (fs.existsSync(psProfilePath)) {
867
- const content = fs.readFileSync(psProfilePath, 'utf-8')
868
- if (content.includes('# squeezr wrapper')) {
869
- const cleaned = content.replace(/\n?# squeezr wrapper[\s\S]*?# end squeezr wrapper\n?/g, '')
870
- fs.writeFileSync(psProfilePath, cleaned)
871
- console.log(` [ok] Removed PowerShell wrapper from ${psProfilePath}`)
872
- }
873
- }
874
- } catch {}
875
- }
876
- // Remove bash/zsh wrapper
877
- for (const p of [path.join(os.homedir(), '.bashrc'), path.join(os.homedir(), '.zshrc')]) {
878
- try {
879
- const content = fs.readFileSync(p, 'utf-8')
880
- if (content.includes('# squeezr shell wrapper')) {
881
- const cleaned = content.replace(/\n?# squeezr shell wrapper[\s\S]*?# end squeezr shell wrapper\n?/g, '')
882
- fs.writeFileSync(p, cleaned)
883
- console.log(` [ok] Removed shell wrapper from ${p}`)
884
- }
885
- } catch {}
886
- }
887
-
888
- // 8. Remove MCP registrations
889
- console.log(' [..] Removing MCP registrations...')
890
- try { await mcpUninstall() } catch {}
891
-
892
- // 9. npm uninstall -g (clear HTTPS_PROXY first so npm doesn't hit dead proxy)
893
- console.log(' [..] Uninstalling npm package...')
894
- const cleanEnv = { ...process.env, HTTPS_PROXY: '', https_proxy: '', HTTP_PROXY: '', http_proxy: '' }
895
- try {
896
- execSync('npm uninstall -g squeezr-ai', { stdio: 'inherit', env: cleanEnv })
897
- console.log(' [ok] npm package removed')
898
- } catch {
899
- try {
900
- execSync('sudo npm uninstall -g squeezr-ai', { stdio: 'inherit', env: cleanEnv })
901
- console.log(' [ok] npm package removed')
902
- } catch {
903
- console.log(' [warn] Could not uninstall npm package. Run manually: npm uninstall -g squeezr-ai')
904
- }
905
- }
906
-
907
- console.log('\nDone! Squeezr has been completely removed.\n')
908
- }
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
-
1290
- // ── squeezr setup ─────────────────────────────────────────────────────────────
1291
-
1292
- async function setupWindows() {
1293
- const squeezrBin = process.argv[1]
1294
- const nodeExe = process.execPath
1295
- const distIndex = path.join(ROOT, 'dist', 'index.js')
1296
-
1297
- console.log('Setting up Squeezr for Windows...\n')
1298
-
1299
- // 1. Set env vars permanently via setx (user scope, no admin needed)
1300
- const port = getPort()
1301
- const mitmPort = getMitmPort(port)
1302
- const caPath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'ca.crt')
1303
- const vars = {
1304
- ANTHROPIC_BASE_URL: `http://localhost:${port}`,
1305
- // openai_base_url NOT set — Codex uses WebSocket and must go via HTTPS_PROXY/MITM,
1306
- // not through the HTTP proxy. Setting it breaks Codex's ws:// connections.
1307
- GEMINI_API_BASE_URL: `http://localhost:${port}`,
1308
- // HTTPS_PROXY intentionally NOT set globally it routes ALL HTTPS traffic through
1309
- // the MITM proxy which breaks Claude Code, npm, and other tools. Only Codex needs it.
1310
- // Users who need Codex MITM can set it per-session: $env:HTTPS_PROXY="http://localhost:8081"
1311
- NODE_EXTRA_CA_CERTS: caPath,
1312
- }
1313
- // Clean up HTTPS_PROXY from registry if set by older versions
1314
- try { execSync('reg delete "HKCU\\Environment" /v HTTPS_PROXY /f', { stdio: 'pipe' }) } catch {}
1315
- for (const [key, value] of Object.entries(vars)) {
1316
- try {
1317
- execSync(`setx ${key} "${value}"`, { stdio: 'pipe' })
1318
- console.log(` [ok] ${key}=${value}`)
1319
- } catch {
1320
- console.log(` [skip] ${key} could not be set`)
1321
- }
1322
- }
1323
-
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
1333
- installShellWrapper()
1334
-
1335
- // 2. Auto-start: try NSSM (Windows service, survives crashes) → fallback to Task Scheduler
1336
- const logDir = path.join(os.homedir(), '.squeezr')
1337
- const serviceName = 'SqueezrProxy'
1338
- let autoStartOk = false
1339
-
1340
- const nssmAvailable = (() => {
1341
- try { execSync('where nssm', { stdio: 'pipe' }); return true } catch { return false }
1342
- })()
1343
-
1344
- if (nssmAvailable) {
1345
- try {
1346
- // Remove existing service if present (ignore errors)
1347
- try { execSync(`nssm stop ${serviceName}`, { stdio: 'pipe' }) } catch {}
1348
- try { execSync(`nssm remove ${serviceName} confirm`, { stdio: 'pipe' }) } catch {}
1349
-
1350
- execSync(`nssm install ${serviceName} "${nodeExe}" "${distIndex}"`, { stdio: 'pipe' })
1351
- execSync(`nssm set ${serviceName} AppDirectory "${ROOT}"`, { stdio: 'pipe' })
1352
- execSync(`nssm set ${serviceName} AppStdout "${logDir}\\service-stdout.log"`, { stdio: 'pipe' })
1353
- execSync(`nssm set ${serviceName} AppStderr "${logDir}\\service-stderr.log"`, { stdio: 'pipe' })
1354
- execSync(`nssm set ${serviceName} AppRotateFiles 1`, { stdio: 'pipe' })
1355
- execSync(`nssm set ${serviceName} AppRotateSeconds 86400`, { stdio: 'pipe' })
1356
- execSync(`nssm set ${serviceName} AppExit Default Restart`, { stdio: 'pipe' })
1357
- execSync(`nssm set ${serviceName} AppRestartDelay 3000`, { stdio: 'pipe' })
1358
- execSync(`nssm set ${serviceName} Description "Squeezr AI token compression proxy on port 8080"`, { stdio: 'pipe' })
1359
- execSync(`nssm start ${serviceName}`, { stdio: 'pipe' })
1360
- console.log(` [ok] Auto-start registered as Windows service via NSSM (auto-restart on crash)`)
1361
- autoStartOk = true
1362
- } catch (err) {
1363
- const msg = err.stderr?.toString() || err.message || ''
1364
- if (msg.includes('Access') || msg.includes('admin') || msg.includes('5')) {
1365
- console.log(` [warn] NSSM requires admin run as Administrator for service install`)
1366
- } else {
1367
- console.log(` [warn] NSSM install failed: ${msg.trim().split('\n')[0]}`)
1368
- }
1369
- }
1370
- }
1371
-
1372
- if (!autoStartOk) {
1373
- // Fallback: Task Scheduler (no crash recovery, but works without admin)
1374
- const taskName = 'Squeezr'
1375
- const nodeArg = `${nodeExe} \`"${distIndex}\`"`
1376
- const ps = [
1377
- `$e = Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue`,
1378
- `if ($e) { Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false }`,
1379
- `$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-WindowStyle Hidden -NonInteractive -Command "${nodeArg}"' -WorkingDirectory '${ROOT}'`,
1380
- `$t = New-ScheduledTaskTrigger -AtLogon`,
1381
- `$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1)`,
1382
- `Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Force | Out-Null`,
1383
- ].join('; ')
1384
- try {
1385
- execSync(`powershell -NoProfile -Command "${ps}"`, { stdio: 'pipe' })
1386
- console.log(` [ok] Auto-start registered in Task Scheduler (install NSSM for crash recovery)`)
1387
- autoStartOk = true
1388
- } catch {
1389
- // ignore — will fall through to Startup folder VBS
1390
- }
1391
- }
1392
-
1393
- if (!autoStartOk) {
1394
- // Final fallback: VBS script in user Startup folder (no admin, no special tools)
1395
- try {
1396
- const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
1397
- const squeezrCmd = path.join(appData, 'npm', 'squeezr.cmd')
1398
- const startupDir = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
1399
- const vbsPath = path.join(startupDir, 'squeezr-start.vbs')
1400
- const cmdToRun = fs.existsSync(squeezrCmd) ? squeezrCmd : nodeExe
1401
- const cmdArg = fs.existsSync(squeezrCmd) ? 'start' : `"${distIndex}"`
1402
- const vbsContent = [
1403
- 'Set WshShell = CreateObject("WScript.Shell")',
1404
- `WshShell.Run """${cmdToRun}"" ${cmdArg}", 0, False`,
1405
- '',
1406
- ].join('\r\n')
1407
- fs.mkdirSync(startupDir, { recursive: true })
1408
- fs.writeFileSync(vbsPath, vbsContent)
1409
- console.log(` [ok] Auto-start registered in Startup folder (${vbsPath})`)
1410
- } catch (err) {
1411
- console.log(` [warn] Auto-start failed run as admin or install NSSM: https://nssm.cc`)
1412
- }
1413
- }
1414
-
1415
- // 3. Start Squeezr right now as a detached background process (no window)
1416
- // Logs go to ~/.squeezr/squeezr.log
1417
- const logFile = path.join(logDir, 'squeezr.log')
1418
- fs.mkdirSync(logDir, { recursive: true })
1419
- const logFd = fs.openSync(logFile, 'a')
1420
- const child = spawn(nodeExe, [distIndex], {
1421
- detached: true,
1422
- stdio: ['ignore', logFd, logFd],
1423
- windowsHide: true,
1424
- cwd: ROOT,
1425
- })
1426
- child.unref()
1427
- fs.closeSync(logFd)
1428
- console.log(` [ok] Squeezr started in background (pid ${child.pid})`)
1429
- console.log(` [ok] Logs → ${logFile}`)
1430
-
1431
- // 4. Trust MITM CA in Windows Certificate Store (for Rust apps like Codex)
1432
- // Node.js apps use NODE_EXTRA_CA_CERTS; Rust/native apps need the cert store.
1433
- // The CA is generated on first proxy start wait briefly for it to appear.
1434
- const waitForCa = (retries = 10, interval = 500) => new Promise(resolve => {
1435
- const check = (n) => {
1436
- if (fs.existsSync(caPath)) return resolve(true)
1437
- if (n <= 0) return resolve(false)
1438
- setTimeout(() => check(n - 1), interval)
1439
- }
1440
- check(retries)
1441
- })
1442
-
1443
- waitForCa().then(found => {
1444
- if (!found) {
1445
- console.log(` [warn] MITM CA not found yet — run 'squeezr setup' again after first start`)
1446
- printDone()
1447
- return
1448
- }
1449
- // Try machine store (admin) first, fall back to user store (no admin)
1450
- try {
1451
- execSync(`certutil -addstore -f Root "${caPath}"`, { stdio: 'pipe' })
1452
- console.log(` [ok] MITM CA trusted in Windows Certificate Store (machine-level)`)
1453
- } catch {
1454
- try {
1455
- execSync(`certutil -addstore -user Root "${caPath}"`, { stdio: 'pipe' })
1456
- console.log(` [ok] MITM CA trusted in Windows Certificate Store (user-level)`)
1457
- } catch {
1458
- console.log(` [warn] Could not trust MITM CA trust manually:`)
1459
- console.log(` certutil -addstore -user Root "${caPath}"`)
1460
- }
1461
- }
1462
- printDone()
1463
- })
1464
-
1465
- function printDone() {
1466
- console.log(`
1467
- Done!
1468
-
1469
- Squeezr is running on http://localhost:${port}
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}
1479
-
1480
- squeezr status — check it's running
1481
- squeezr gain — see token savings
1482
- `)
1483
- }
1484
- }
1485
-
1486
- async function setupUnix() {
1487
- const squeezrBin = process.argv[1]
1488
- const nodeExe = process.execPath
1489
- const platform = process.platform
1490
-
1491
- console.log(`Setting up Squeezr for ${platform === 'darwin' ? 'macOS' : 'Linux'}...\n`)
1492
-
1493
- // 1. Set env vars + auto-heal guard in shell profile
1494
- const distIndex = path.join(ROOT, 'dist', 'index.js')
1495
- const port = getPort()
1496
- const mitmPort = getMitmPort(port)
1497
- const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
1498
- // The auto-heal validates that whatever answers on the configured port is
1499
- // actually squeezr (by checking the magic `identity` field). A bare
1500
- // `curl -sf .../squeezr/health` is NOT enough because curl returns success on
1501
- // 3xx redirects, so a foreign service (e.g. an Apache+WordPress container on
1502
- // 8080) would be mistaken for a healthy squeezr and Claude Code would then
1503
- // route its API requests into the wrong service, producing cryptic errors
1504
- // like `undefined is not an object (evaluating '$.speed')`.
1505
- const shellBlock = [
1506
- `# squeezr env vars`,
1507
- `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1508
- `export openai_base_url=http://localhost:${port}`,
1509
- `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1510
- `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1511
- `# NOTE: HTTPS_PROXY is intentionally NOT set globally — it would route ALL HTTPS`,
1512
- `# (including Claude Code) through the MITM proxy and cause 502 errors.`,
1513
- `# For Codex, set it per-session only: HTTPS_PROXY=http://localhost:${mitmPort} codex`,
1514
- `# squeezr auto-heal: start proxy if not running (validates identity, not just HTTP 200)`,
1515
- `_squeezr_alive() {`,
1516
- ` curl -sf --max-time 2 "http://localhost:${port}/squeezr/health" 2>/dev/null | grep -q '"identity":"squeezr"'`,
1517
- `}`,
1518
- `if ! _squeezr_alive; then`,
1519
- ` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
1520
- ` disown`,
1521
- `fi`,
1522
- `unset -f _squeezr_alive`,
1523
- ].join('\n')
1524
- const marker = '# squeezr env vars'
1525
-
1526
- // Env-only block (no auto-heal) for .profile — loaded by login shells
1527
- // before .bashrc's "case $-" interactive guard
1528
- const envOnlyBlock = [
1529
- `# squeezr env vars`,
1530
- `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1531
- `export openai_base_url=http://localhost:${port}`,
1532
- `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1533
- `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1534
- ].join('\n')
1535
-
1536
- // Write env vars to ~/.profile (login shell — always loaded)
1537
- const profilePath = path.join(os.homedir(), '.profile')
1538
- try {
1539
- const profileContent = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf-8') : ''
1540
- if (!profileContent.includes(marker)) {
1541
- fs.appendFileSync(profilePath, `\n${envOnlyBlock}\n`)
1542
- console.log(` [ok] Env vars added to ${profilePath}`)
1543
- } else {
1544
- const updated = profileContent.replace(/# squeezr env vars[\s\S]*?(?=\n(?!export )|\n*$)/, envOnlyBlock)
1545
- fs.writeFileSync(profilePath, updated)
1546
- console.log(` [ok] Env vars updated in ${profilePath}`)
1547
- }
1548
- } catch {}
1549
-
1550
- // Write full block (env + auto-heal) to interactive shell profile
1551
- const profiles = [
1552
- path.join(os.homedir(), '.zshrc'),
1553
- path.join(os.homedir(), '.bashrc'),
1554
- path.join(os.homedir(), '.bash_profile'),
1555
- ]
1556
- const profile = profiles.find(p => fs.existsSync(p)) ?? profiles[0]
1557
- const existing = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : ''
1558
- if (!existing.includes(marker)) {
1559
- fs.appendFileSync(profile, `\n${shellBlock}\n`)
1560
- console.log(` [ok] Env vars + auto-heal added to ${profile}`)
1561
- } else {
1562
- const updatedContent = existing.replace(
1563
- /# squeezr env vars[\s\S]*?fi\n?/,
1564
- shellBlock + '\n'
1565
- )
1566
- fs.writeFileSync(profile, updatedContent)
1567
- console.log(` [ok] Env vars + auto-heal updated in ${profile}`)
1568
- }
1569
-
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
1621
- if (platform === 'darwin') {
1622
- const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents')
1623
- const plistPath = path.join(plistDir, 'com.squeezr.plist')
1624
- fs.mkdirSync(plistDir, { recursive: true })
1625
- fs.writeFileSync(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
1626
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1627
- <plist version="1.0">
1628
- <dict>
1629
- <key>Label</key><string>com.squeezr</string>
1630
- <key>ProgramArguments</key>
1631
- <array><string>${nodeExe}</string><string>${squeezrBin}</string></array>
1632
- <key>RunAtLoad</key><true/>
1633
- <key>KeepAlive</key><true/>
1634
- <key>StandardOutPath</key><string>${os.homedir()}/.squeezr/squeezr.log</string>
1635
- <key>StandardErrorPath</key><string>${os.homedir()}/.squeezr/squeezr.log</string>
1636
- </dict>
1637
- </plist>`)
1638
- try {
1639
- execSync(`launchctl unload "${plistPath}" 2>/dev/null; launchctl load "${plistPath}"`, { stdio: 'pipe' })
1640
- console.log(` [ok] Auto-start registered via launchd`)
1641
- console.log(` [ok] Squeezr started now`)
1642
- } catch {
1643
- console.log(` [warn] launchctl failed — starting in background`)
1644
- spawn(nodeExe, [squeezrBin], { detached: true, stdio: 'ignore' }).unref()
1645
- }
1646
-
1647
- // Trust MITM CA in macOS Keychain (for Codex TLS interception)
1648
- // CA is generated on first proxy start — wait briefly for it to appear
1649
- const caPath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'ca.crt')
1650
- const waitForCa = (retries = 10, interval = 500) => new Promise(resolve => {
1651
- const check = (n) => {
1652
- if (fs.existsSync(caPath)) return resolve(true)
1653
- if (n <= 0) return resolve(false)
1654
- setTimeout(() => check(n - 1), interval)
1655
- }
1656
- check(retries)
1657
- })
1658
- waitForCa().then(found => {
1659
- if (found) {
1660
- try {
1661
- execSync(`security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${caPath}" 2>/dev/null`, { stdio: 'pipe' })
1662
- console.log(` [ok] MITM CA trusted in macOS Keychain`)
1663
- } catch {
1664
- console.log(` [info] To trust MITM CA for Codex: security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${caPath}"`)
1665
- }
1666
- }
1667
- })
1668
-
1669
- // 3b. Linux — systemd
1670
- } else {
1671
- const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user')
1672
- const servicePath = path.join(serviceDir, 'squeezr.service')
1673
- fs.mkdirSync(serviceDir, { recursive: true })
1674
- fs.writeFileSync(servicePath, `[Unit]
1675
- Description=Squeezr AI proxy
1676
- After=network.target
1677
-
1678
- [Service]
1679
- ExecStart=${nodeExe} ${squeezrBin}
1680
- Restart=always
1681
- RestartSec=5
1682
-
1683
- [Install]
1684
- WantedBy=default.target
1685
- `)
1686
- try {
1687
- execSync('systemctl --user daemon-reload && systemctl --user enable --now squeezr', { stdio: 'pipe' })
1688
- console.log(` [ok] Auto-start registered via systemd`)
1689
- console.log(` [ok] Squeezr started now`)
1690
- } catch {
1691
- console.log(` [warn] systemctl failed — starting in background`)
1692
- spawn(nodeExe, [squeezrBin], { detached: true, stdio: 'ignore' }).unref()
1693
- }
1694
- }
1695
-
1696
- console.log(`
1697
- Done!
1698
-
1699
- Squeezr is running on http://localhost:${port}
1700
-
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}
1708
-
1709
- Run: source ${profile} (or open a new terminal)
1710
- squeezr status — check it's running
1711
- squeezr gain — see token savings
1712
- `)
1713
- installShellWrapper()
1714
- }
1715
-
1716
- // ── WSL2 detection ───────────────────────────────────────────────────────────
1717
-
1718
- function isWSL() {
1719
- try {
1720
- const release = fs.readFileSync('/proc/version', 'utf-8')
1721
- return /microsoft|wsl/i.test(release)
1722
- } catch {
1723
- return false
1724
- }
1725
- }
1726
-
1727
- // ── squeezr setup — WSL2 ────────────────────────────────────────────────────
1728
-
1729
- async function setupWSL() {
1730
- const nodeExe = process.execPath
1731
- const distIndex = path.join(ROOT, 'dist', 'index.js')
1732
-
1733
- console.log('Setting up Squeezr for WSL2...\n')
1734
-
1735
- // 1. Set env vars + auto-heal guard in WSL shell profile (.bashrc / .zshrc)
1736
- // The guard checks if the proxy is alive on terminal open. If not, it starts
1737
- // it in the background. This is the safety net for WSL2 where systemd and
1738
- // Task Scheduler may both fail.
1739
- const port = getPort()
1740
- const mitmPort = getMitmPort(port)
1741
- const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
1742
- const shellBlock = [
1743
- `# squeezr env vars`,
1744
- `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1745
- `export openai_base_url=http://localhost:${port}`,
1746
- `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1747
- `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1748
- `# NOTE: HTTPS_PROXY is intentionally NOT set globally — set per-session for Codex only:`,
1749
- `# HTTPS_PROXY=http://localhost:${mitmPort} codex`,
1750
- `# squeezr auto-heal: start proxy if not running (validates identity, not just HTTP 200)`,
1751
- `_squeezr_alive() {`,
1752
- ` curl -sf --max-time 2 "http://localhost:${port}/squeezr/health" 2>/dev/null | grep -q '"identity":"squeezr"'`,
1753
- `}`,
1754
- `if ! _squeezr_alive; then`,
1755
- ` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
1756
- ` disown`,
1757
- `fi`,
1758
- `unset -f _squeezr_alive`,
1759
- ].join('\n')
1760
- const marker = '# squeezr env vars'
1761
-
1762
- // Env-only block for .profile (loaded before .bashrc's interactive guard)
1763
- const envOnlyBlock = [
1764
- `# squeezr env vars`,
1765
- `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1766
- `export openai_base_url=http://localhost:${port}`,
1767
- `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1768
- `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1769
- ].join('\n')
1770
-
1771
- const profilePath = path.join(os.homedir(), '.profile')
1772
- try {
1773
- const profileContent = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf-8') : ''
1774
- if (!profileContent.includes(marker)) {
1775
- fs.appendFileSync(profilePath, `\n${envOnlyBlock}\n`)
1776
- console.log(` [ok] Env vars added to ${profilePath}`)
1777
- } else {
1778
- const updated = profileContent.replace(/# squeezr env vars[\s\S]*?(?=\n(?!export )|\n*$)/, envOnlyBlock)
1779
- fs.writeFileSync(profilePath, updated)
1780
- console.log(` [ok] Env vars updated in ${profilePath}`)
1781
- }
1782
- } catch {}
1783
-
1784
- // Full block (env + auto-heal) in interactive shell profile
1785
- const profiles = [
1786
- path.join(os.homedir(), '.zshrc'),
1787
- path.join(os.homedir(), '.bashrc'),
1788
- path.join(os.homedir(), '.bash_profile'),
1789
- ]
1790
- const profile = profiles.find(p => fs.existsSync(p)) ?? profiles[1]
1791
- const existing = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : ''
1792
- if (!existing.includes(marker)) {
1793
- fs.appendFileSync(profile, `\n${shellBlock}\n`)
1794
- console.log(` [ok] Env vars + auto-heal added to ${profile}`)
1795
- } else {
1796
- const updatedContent = existing.replace(
1797
- /# squeezr env vars[\s\S]*?fi\n?/,
1798
- shellBlock + '\n'
1799
- )
1800
- fs.writeFileSync(profile, updatedContent)
1801
- console.log(` [ok] Env vars + auto-heal updated in ${profile}`)
1802
- }
1803
-
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).
1806
- const setxExe = '/mnt/c/Windows/System32/setx.exe'
1807
- const winVars = {
1808
- ANTHROPIC_BASE_URL: 'http://localhost:8080',
1809
- openai_base_url: 'http://localhost:8080',
1810
- GEMINI_API_BASE_URL: 'http://localhost:8080',
1811
- }
1812
- if (fs.existsSync(setxExe)) {
1813
- for (const [key, value] of Object.entries(winVars)) {
1814
- try {
1815
- execSync(`"${setxExe}" ${key} "${value}"`, { stdio: 'pipe' })
1816
- console.log(` [ok] Windows env: ${key}=${value}`)
1817
- } catch {
1818
- console.log(` [skip] Windows env: ${key} could not be set`)
1819
- }
1820
- }
1821
- } else {
1822
- console.log(' [skip] setx.exe not found — Windows env vars not set')
1823
- }
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
-
1856
- // 3. Auto-start: try systemd first (WSL2 with systemd enabled), fallback to
1857
- // Windows Task Scheduler, then plain background process
1858
- let autoStartDone = false
1859
-
1860
- // 3a. Try systemd (works on newer WSL2 with [boot] systemd=true in wsl.conf)
1861
- try {
1862
- const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user')
1863
- fs.mkdirSync(serviceDir, { recursive: true })
1864
- const servicePath = path.join(serviceDir, 'squeezr.service')
1865
- fs.writeFileSync(servicePath, `[Unit]
1866
- Description=Squeezr AI proxy
1867
- After=network.target
1868
-
1869
- [Service]
1870
- ExecStart=${nodeExe} ${distIndex}
1871
- Restart=always
1872
- RestartSec=5
1873
- WorkingDirectory=${ROOT}
1874
-
1875
- [Install]
1876
- WantedBy=default.target
1877
- `)
1878
- execSync('systemctl --user daemon-reload && systemctl --user enable --now squeezr', { stdio: 'pipe' })
1879
- console.log(' [ok] Auto-start registered via systemd')
1880
- autoStartDone = true
1881
- } catch {
1882
- // systemd not available — try Windows Task Scheduler
1883
- }
1884
-
1885
- // 3b. Fallback: Windows Task Scheduler via powershell.exe
1886
- if (!autoStartDone) {
1887
- const winNodeExe = execSync('wslpath -w "$(which node)"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1888
- const winDistIndex = execSync(`wslpath -w "${distIndex}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1889
- const winRoot = execSync(`wslpath -w "${ROOT}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1890
- const taskName = 'Squeezr'
1891
- const ps = [
1892
- `$e = Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue`,
1893
- `if ($e) { Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false }`,
1894
- `$a = New-ScheduledTaskAction -Execute 'wsl.exe' -Argument '-d ${os.hostname()} -- ${nodeExe} ${distIndex}' -WorkingDirectory '${winRoot}'`,
1895
- `$t = New-ScheduledTaskTrigger -AtLogon`,
1896
- `$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1)`,
1897
- `Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Force | Out-Null`,
1898
- ].join('; ')
1899
-
1900
- try {
1901
- execSync(`powershell.exe -NoProfile -Command "${ps}"`, { stdio: 'pipe' })
1902
- console.log(' [ok] Auto-start registered via Windows Task Scheduler')
1903
- autoStartDone = true
1904
- } catch {
1905
- console.log(' [warn] Task Scheduler failed — run PowerShell as admin for auto-start')
1906
- }
1907
- }
1908
-
1909
- // 4. Start proxy now as a detached background process
1910
- const logDir = path.join(os.homedir(), '.squeezr')
1911
- const logFile = path.join(logDir, 'squeezr.log')
1912
- fs.mkdirSync(logDir, { recursive: true })
1913
- const logFd = fs.openSync(logFile, 'a')
1914
- const child = spawn(nodeExe, [distIndex], {
1915
- detached: true,
1916
- stdio: ['ignore', logFd, logFd],
1917
- cwd: ROOT,
1918
- })
1919
- child.unref()
1920
- fs.closeSync(logFd)
1921
- console.log(` [ok] Squeezr started in background (pid ${child.pid})`)
1922
- console.log(` [ok] Logs → ${logFile}`)
1923
-
1924
- const setupPort = getPort()
1925
- const setupMitmPort = getMitmPort(setupPort)
1926
- console.log(`
1927
- Done!
1928
-
1929
- Squeezr is running on http://localhost:${setupPort}
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}
1938
-
1939
- Windows env vars are set (effective in new terminals immediately).
1940
- WSL env vars added to ${profile}.
1941
-
1942
- squeezr status — check it's running
1943
- squeezr gain — see token savings
1944
- `)
1945
- installShellWrapper()
1946
- }
1947
-
1948
- // ── squeezr tunnel ────────────────────────────────────────────────────────────
1949
- // Exposes the local proxy via a Cloudflare Quick Tunnel (free, no account needed).
1950
- // Cursor IDE requires a public HTTPS URL because its servers call the endpoint
1951
- // from Cloudflare's infrastructure localhost is unreachable from there.
1952
-
1953
- async function startTunnel() {
1954
- const port = getPort()
1955
-
1956
- // Verify proxy is running first
1957
- const running = await new Promise(resolve => {
1958
- const req = http.get(`http://localhost:${port}/squeezr/health`, res => {
1959
- resolve(res.statusCode === 200)
1960
- })
1961
- req.on('error', () => resolve(false))
1962
- req.setTimeout(2000, () => { req.destroy(); resolve(false) })
1963
- })
1964
-
1965
- if (!running) {
1966
- console.error(`Squeezr proxy is not running on port ${port}.`)
1967
- console.error(`Start it first: squeezr start`)
1968
- process.exit(1)
1969
- }
1970
-
1971
- console.log(`Starting Cloudflare Quick Tunnel for http://localhost:${port}...`)
1972
- console.log(`(free, no account needed — powered by trycloudflare.com)\n`)
1973
-
1974
- // Try cloudflared binary, fall back to npx
1975
- let tunnelCmd, tunnelArgs
1976
- try {
1977
- execSync('cloudflared --version', { stdio: 'pipe' })
1978
- tunnelCmd = 'cloudflared'
1979
- tunnelArgs = ['tunnel', '--url', `http://localhost:${port}`]
1980
- } catch {
1981
- tunnelCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'
1982
- tunnelArgs = ['cloudflared@latest', 'tunnel', '--url', `http://localhost:${port}`]
1983
- console.log(`cloudflared not installed using npx cloudflared (may take a moment to download)\n`)
1984
- }
1985
-
1986
- let tunnelUrl = null
1987
- const child = spawn(tunnelCmd, tunnelArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
1988
-
1989
- const printInstructions = (url) => {
1990
- console.log(`\n ╔══════════════════════════════════════════════════════════════════╗`)
1991
- console.log(` ║ Tunnel active: ${url.padEnd(49)}║`)
1992
- console.log(` ╠══════════════════════════════════════════════════════════════════╣`)
1993
- console.log(` ║ CURSOR SETUP ║`)
1994
- console.log(` ║ ║`)
1995
- console.log(` ║ 1. Cursor → Settings → Models ║`)
1996
- console.log(` ║ 2. Add your OpenAI or Anthropic API key ║`)
1997
- console.log(` ║ 3. Enable "Override OpenAI Base URL" ║`)
1998
- console.log(` ║ 4. Set URL to: ${(url + '/v1').padEnd(49)}║`)
1999
- console.log(` ║ 5. Disable all built-in Cursor models ║`)
2000
- console.log(` ║ 6. Add a custom model pointing to the same URL ║`)
2001
- console.log(` ║ ║`)
2002
- console.log(` ║ CONTINUE EXTENSION (VS Code / JetBrains) ║`)
2003
- console.log(` ║ No tunnel needed — use http://localhost:${port} directly ${' '.repeat(Math.max(0, 17 - String(port).length))}║`)
2004
- console.log(` ║ ║`)
2005
- console.log(` ║ Press Ctrl+C to stop the tunnel ║`)
2006
- console.log(` ╚══════════════════════════════════════════════════════════════════╝\n`)
2007
- }
2008
-
2009
- const parseUrl = (line) => {
2010
- const m = line.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)
2011
- return m ? m[0] : null
2012
- }
2013
-
2014
- child.stdout.on('data', (chunk) => {
2015
- const text = chunk.toString()
2016
- if (!tunnelUrl) {
2017
- const found = parseUrl(text)
2018
- if (found) { tunnelUrl = found; printInstructions(tunnelUrl) }
2019
- }
2020
- process.stdout.write(text)
2021
- })
2022
-
2023
- child.stderr.on('data', (chunk) => {
2024
- const text = chunk.toString()
2025
- if (!tunnelUrl) {
2026
- const found = parseUrl(text)
2027
- if (found) { tunnelUrl = found; printInstructions(tunnelUrl) }
2028
- }
2029
- // Only show cloudflared logs if no URL yet (suppress verbose after)
2030
- if (!tunnelUrl) process.stderr.write(text)
2031
- })
2032
-
2033
- child.on('error', (err) => {
2034
- if (err.code === 'ENOENT') {
2035
- console.error(`Could not start tunnel. Install cloudflared: https://developers.cloudflare.com/cloudflared/downloads`)
2036
- } else {
2037
- console.error(`Tunnel error: ${err.message}`)
2038
- }
2039
- process.exit(1)
2040
- })
2041
-
2042
- child.on('exit', (code) => {
2043
- if (code !== 0) console.log(`\nTunnel stopped (exit ${code})`)
2044
- process.exit(0)
2045
- })
2046
-
2047
- process.on('SIGINT', () => { child.kill(); process.exit(0) })
2048
- process.on('SIGTERM', () => { child.kill(); process.exit(0) })
2049
- }
2050
-
2051
- // ── CLI router ────────────────────────────────────────────────────────────────
2052
-
2053
- switch (command) {
2054
- case undefined:
2055
- case 'start':
2056
- startDaemon()
2057
- break
2058
-
2059
- case 'setup':
2060
- if (process.platform === 'win32') setupWindows()
2061
- else if (isWSL()) setupWSL()
2062
- else setupUnix()
2063
- break
2064
-
2065
- case 'update':
2066
- await (async () => {
2067
- console.log('Stopping Squeezr...')
2068
- stopProxy() // also kills MCP via killMcpProcesses()
2069
- console.log('Releasing file locks...')
2070
- killMcpProcesses() // double-kill in case stopProxy was too fast
2071
- await new Promise(r => setTimeout(r, 2000))
2072
-
2073
- console.log('Installing latest version...')
2074
- const cleanEnv = { ...process.env, HTTPS_PROXY: '', https_proxy: '', HTTP_PROXY: '', http_proxy: '' }
2075
- let installed = false
2076
- for (let attempt = 1; attempt <= 4; attempt++) {
2077
- try {
2078
- execSync('npm install -g squeezr-ai@latest', { stdio: 'inherit', env: cleanEnv })
2079
- installed = true
2080
- break
2081
- } catch (err) {
2082
- const msg = String(err?.stderr || err?.message || '')
2083
- if ((msg.includes('EBUSY') || msg.includes('EPERM')) && attempt < 4) {
2084
- console.log(` Files still locked, retrying in 3s (attempt ${attempt}/4)...`)
2085
- // Try harder to release locks on retry
2086
- killMcpProcesses()
2087
- await new Promise(r => setTimeout(r, 3000))
2088
- } else if (!msg.includes('EBUSY') && !msg.includes('EPERM') && process.platform !== 'win32') {
2089
- // On Unix, try sudo as fallback (not useful on Windows)
2090
- try {
2091
- execSync('sudo npm install -g squeezr-ai@latest', { stdio: 'inherit', env: cleanEnv })
2092
- installed = true
2093
- } catch {}
2094
- break
2095
- } else {
2096
- break
2097
- }
2098
- }
2099
- }
2100
- if (!installed) {
2101
- console.error('\nUpdate failed: files are still locked.')
2102
- console.error('Fix: close Claude Code completely (this releases the MCP server lock), then run "squeezr update" again.')
2103
- process.exit(1)
2104
- }
2105
-
2106
- // Clear update check cache
2107
- try { fs.unlinkSync(UPDATE_CHECK_FILE) } catch {}
2108
-
2109
- // Resolve the NEW package root from npm global modules
2110
- let newRoot = ROOT
2111
- try {
2112
- const npmRoot = execSync('npm root -g', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
2113
- const candidate = path.join(npmRoot, 'squeezr-ai')
2114
- if (fs.existsSync(path.join(candidate, 'package.json'))) newRoot = candidate
2115
- } catch {}
2116
-
2117
- // Read the new version and write cache so no stale banner appears
2118
- try {
2119
- const newPkg = JSON.parse(fs.readFileSync(path.join(newRoot, 'package.json'), 'utf-8'))
2120
- const dir = path.dirname(UPDATE_CHECK_FILE)
2121
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
2122
- fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ latest: newPkg.version, checkedAt: Date.now() }))
2123
- console.log(`\nUpdated to v${newPkg.version}`)
2124
- } catch {}
2125
-
2126
- // Start the daemon directly from the new dist/index.js (no re-exec of old binary)
2127
- console.log('Starting Squeezr...')
2128
- const newDistIndex = path.join(newRoot, 'dist', 'index.js')
2129
- const startPort = getPort()
2130
- const startMitmPort = getMitmPort(startPort)
2131
- const logDir = path.join(os.homedir(), '.squeezr')
2132
- const logFile = path.join(logDir, 'squeezr.log')
2133
- fs.mkdirSync(logDir, { recursive: true })
2134
- const logFd = fs.openSync(logFile, 'a')
2135
- const child = spawn(process.execPath, [newDistIndex], {
2136
- detached: true,
2137
- stdio: ['ignore', logFd, logFd],
2138
- cwd: newRoot,
2139
- env: { ...process.env, SQUEEZR_DAEMON: '1' },
2140
- })
2141
- child.unref()
2142
- fs.closeSync(logFd)
2143
- console.log(`Squeezr started (pid ${child.pid})`)
2144
- console.log(` HTTP proxy (Claude/Aider/Gemini): http://localhost:${startPort}`)
2145
- console.log(` MITM proxy (Codex): http://localhost:${startMitmPort}`)
2146
- console.log(` Dashboard: http://localhost:${startPort}/squeezr/dashboard`)
2147
- console.log(` Logs: ${logFile}`)
2148
-
2149
- // Ensure PowerShell wrapper is installed (so env vars refresh automatically)
2150
- installShellWrapper()
2151
- })()
2152
- break
2153
- case 'stop':
2154
- stopProxy()
2155
- break
2156
-
2157
- case 'logs':
2158
- showLogs()
2159
- break
2160
-
2161
- case 'gain':
2162
- runNode('gain.js', args.slice(1))
2163
- break
2164
-
2165
- case 'discover':
2166
- runNode('discover.js', args.slice(1))
2167
- break
2168
-
2169
- case 'status':
2170
- checkStatus()
2171
- break
2172
-
2173
- case 'ports':
2174
- await configurePorts()
2175
- break
2176
-
2177
- case 'tunnel':
2178
- await startTunnel()
2179
- break
2180
-
2181
- case 'bypass':
2182
- await (async () => {
2183
- const port = getPort()
2184
- const body = args[1] === '--on' ? JSON.stringify({ enabled: true })
2185
- : args[1] === '--off' ? JSON.stringify({ enabled: false })
2186
- : '{}'
2187
- try {
2188
- const res = await fetch(`http://localhost:${port}/squeezr/bypass`, {
2189
- method: 'POST',
2190
- headers: { 'content-type': 'application/json' },
2191
- body,
2192
- })
2193
- const json = await res.json()
2194
- if (json.bypassed) {
2195
- console.log('⏸️ Bypass mode ON — compression disabled')
2196
- console.log(' Requests pass through uncompressed but are still logged.')
2197
- console.log(' Turn off: squeezr bypass --off')
2198
- } else {
2199
- console.log('▶️ Bypass mode OFF — compression active')
2200
- }
2201
- } catch {
2202
- console.log('Squeezr is NOT running')
2203
- console.log('Start it with: squeezr start')
2204
- }
2205
- })()
2206
- break
2207
-
2208
- case 'uninstall':
2209
- await uninstall()
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
- }
2223
- case 'config':
2224
- showConfig()
2225
- break
2226
-
2227
- case 'mcp': {
2228
- const subCmd = args[1] ?? 'install'
2229
- if (subCmd === 'uninstall') await mcpUninstall()
2230
- else await mcpInstall()
2231
- break
2232
- }
2233
- case 'version':
2234
- case '--version':
2235
- case '-v':
2236
- console.log(pkg.version)
2237
- break
2238
-
2239
- case '--help':
2240
- case '-h':
2241
- case 'help':
2242
- console.log(HELP)
2243
- break
2244
-
2245
- default:
2246
- console.error(`Unknown command: ${command}`)
2247
- console.log(HELP)
2248
- process.exit(1)
2249
- }
2250
-
2251
- if (command !== 'update') await showUpdateBanner()
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, execSync } from 'child_process'
4
+ import http from 'http'
5
+ import path from 'path'
6
+ import fs from 'fs'
7
+ import os from 'os'
8
+ import { fileURLToPath } from 'url'
9
+ import { createRequire } from 'module'
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
+ const require = createRequire(import.meta.url)
13
+ const ROOT = path.join(__dirname, '..')
14
+ const pkg = require(path.join(ROOT, 'package.json'))
15
+
16
+ const args = process.argv.slice(2)
17
+ const command = args[0]
18
+
19
+ // ── update check (non-blocking) ───────────────────────────────────────────────
20
+
21
+ const UPDATE_CHECK_FILE = path.join(os.homedir(), '.squeezr', 'update-check.json')
22
+ const UPDATE_CHECK_INTERVAL = 4 * 60 * 60 * 1000 // 4 hours
23
+
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
+
38
+ const updateCheckPromise = (async () => {
39
+ try {
40
+ // Read cached check
41
+ let cached = null
42
+ try { cached = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf-8')) } catch {}
43
+ if (cached && Date.now() - cached.checkedAt < UPDATE_CHECK_INTERVAL) {
44
+ return newerVersionOrNull(cached.latest)
45
+ }
46
+ // Fetch latest from npm (with timeout)
47
+ const { get } = await import('https')
48
+ const latest = await new Promise((resolve, reject) => {
49
+ const req = get('https://registry.npmjs.org/squeezr-ai/latest', { timeout: 3000 }, res => {
50
+ let data = ''
51
+ res.on('data', chunk => { data += chunk })
52
+ res.on('end', () => {
53
+ try { resolve(JSON.parse(data).version) } catch { resolve(null) }
54
+ })
55
+ })
56
+ req.on('error', () => resolve(null))
57
+ req.setTimeout(3000, () => { req.destroy(); resolve(null) })
58
+ })
59
+ if (!latest) return null
60
+ // Cache result
61
+ const dir = path.dirname(UPDATE_CHECK_FILE)
62
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
63
+ fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ latest, checkedAt: Date.now() }))
64
+ return newerVersionOrNull(latest)
65
+ } catch { return null }
66
+ })()
67
+
68
+ async function showUpdateBanner() {
69
+ try {
70
+ const latest = await Promise.race([updateCheckPromise, new Promise(r => setTimeout(() => r(null), 500))])
71
+ if (latest) {
72
+ console.log('')
73
+ console.log(` ╭─────────────────────────────────────────────────────────╮`)
74
+ console.log(` │ Update available: v${pkg.version} → v${latest}${' '.repeat(Math.max(0, 30 - pkg.version.length - latest.length))}│`)
75
+ console.log(` │ Run: squeezr update │`)
76
+ console.log(` ╰─────────────────────────────────────────────────────────╯`)
77
+ }
78
+ } catch {}
79
+ }
80
+
81
+ function getPortFromToml() {
82
+ try {
83
+ const toml = fs.readFileSync(path.join(ROOT, 'squeezr.toml'), 'utf-8')
84
+ const m = toml.match(/^port\s*=\s*(\d+)/m)
85
+ if (m) return parseInt(m[1])
86
+ } catch {}
87
+ return null
88
+ }
89
+
90
+ // Runtime info written by src/index.ts after a successful listen(). Reflects
91
+ // the *actual* bound port, which may differ from squeezr.toml when findFreePort
92
+ // drifted because the configured port was occupied.
93
+ const RUNTIME_FILE = path.join(os.homedir(), '.squeezr', 'runtime.json')
94
+
95
+ function readRuntimeInfo() {
96
+ try { return JSON.parse(fs.readFileSync(RUNTIME_FILE, 'utf-8')) } catch { return null }
97
+ }
98
+
99
+ function getMitmPort(port) {
100
+ const envMitm = process.env.SQUEEZR_MITM_PORT
101
+ if (envMitm) return parseInt(envMitm)
102
+ const runtime = readRuntimeInfo()
103
+ if (runtime && runtime.mitmPort) return runtime.mitmPort
104
+ try {
105
+ const toml = fs.readFileSync(path.join(ROOT, 'squeezr.toml'), 'utf-8')
106
+ const m = toml.match(/^mitm_port\s*=\s*(\d+)/m)
107
+ if (m) return parseInt(m[1])
108
+ } catch {}
109
+ return Number(port) + 1
110
+ }
111
+
112
+ function getPort() {
113
+ if (process.env.SQUEEZR_PORT) return parseInt(process.env.SQUEEZR_PORT)
114
+ const runtime = readRuntimeInfo()
115
+ if (runtime && runtime.port) return runtime.port
116
+ return getPortFromToml() || 8080
117
+ }
118
+
119
+ /**
120
+ * Verifies that whatever is listening on `port` is actually a squeezr instance
121
+ * (by checking the magic `identity` field in /squeezr/health), not an unrelated
122
+ * HTTP service that happens to answer 200. Returns the parsed health JSON, or
123
+ * null if the port is free, unreachable, or owned by a foreign service.
124
+ */
125
+ function probeSqueezr(port, timeoutMs = 1500) {
126
+ return new Promise(resolve => {
127
+ const req = http.get({ host: '127.0.0.1', port, path: '/squeezr/health' }, res => {
128
+ let data = ''
129
+ res.on('data', chunk => { data += chunk })
130
+ res.on('end', () => {
131
+ if (res.statusCode !== 200) return resolve(null)
132
+ try {
133
+ const json = JSON.parse(data)
134
+ if (json && json.identity === 'squeezr') return resolve(json)
135
+ } catch {}
136
+ resolve(null)
137
+ })
138
+ })
139
+ req.on('error', () => resolve(null))
140
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null) })
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Install/update shell wrapper functions so env vars are auto-refreshed
146
+ * after squeezr start/setup/update (child processes can't modify parent env).
147
+ */
148
+ function installShellWrapper() {
149
+ if (process.platform === 'win32') return installPowerShellWrapper()
150
+ if (isWSL() || process.platform === 'linux' || process.platform === 'darwin') return installBashWrapper()
151
+ }
152
+
153
+ function installBashWrapper() {
154
+ const port = getPort()
155
+ const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
156
+ const marker = '# squeezr shell wrapper'
157
+ const endMarker = '# end squeezr shell wrapper'
158
+ const wrapper = `${marker}
159
+ squeezr() {
160
+ command squeezr "$@"
161
+ case "$1" in
162
+ start|setup|update)
163
+ export ANTHROPIC_BASE_URL=http://localhost:${port}
164
+ export GEMINI_API_BASE_URL=http://localhost:${port}
165
+ export NODE_EXTRA_CA_CERTS=${bundlePath}
166
+ ;;
167
+ esac
168
+ }
169
+ ${endMarker}`
170
+
171
+ const profiles = [
172
+ path.join(os.homedir(), '.bashrc'),
173
+ path.join(os.homedir(), '.zshrc'),
174
+ ]
175
+ let installed = false
176
+ for (const p of profiles) {
177
+ if (!fs.existsSync(p)) continue
178
+ try {
179
+ const content = fs.readFileSync(p, 'utf-8')
180
+ if (!content.includes(marker)) {
181
+ fs.appendFileSync(p, `\n${wrapper}\n`)
182
+ console.log(` [ok] Shell wrapper added to ${p}`)
183
+ installed = true
184
+ } else {
185
+ const updated = content.replace(new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), wrapper)
186
+ fs.writeFileSync(p, updated)
187
+ console.log(` [ok] Shell wrapper updated in ${p}`)
188
+ }
189
+ } catch {}
190
+ }
191
+ if (installed) {
192
+ console.log('')
193
+ console.log(' ╔═══════════════════════════════════════════════════════════════╗')
194
+ console.log(' ║ ONE-TIME SETUP: Close this terminal and open a new one. ║')
195
+ console.log(' ║ This loads the wrapper that auto-refreshes env vars. ║')
196
+ console.log(' ║ After that, you will NEVER need to do this again. ║')
197
+ console.log(' ╚═══════════════════════════════════════════════════════════════╝')
198
+ }
199
+ }
200
+
201
+ function installPowerShellWrapper() {
202
+ try {
203
+ const psProfilePath = execSync('powershell -NoProfile -Command "[Environment]::GetFolderPath(\'MyDocuments\') + \'\\WindowsPowerShell\\Microsoft.PowerShell_profile.ps1\'"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
204
+ const psProfileDir = path.dirname(psProfilePath)
205
+ if (!fs.existsSync(psProfileDir)) fs.mkdirSync(psProfileDir, { recursive: true })
206
+ const psMarker = '# squeezr wrapper'
207
+ const psLines = []
208
+ psLines.push(psMarker)
209
+ psLines.push('function squeezr {')
210
+ psLines.push(' & squeezr.cmd @args')
211
+ psLines.push(' if (@("start","setup","update") -contains $args[0]) {')
212
+ psLines.push(' foreach ($k in @("ANTHROPIC_BASE_URL","GEMINI_API_BASE_URL","NODE_EXTRA_CA_CERTS")) {')
213
+ psLines.push(' $val = [Environment]::GetEnvironmentVariable($k, "User")')
214
+ psLines.push(' if ($val) { [Environment]::SetEnvironmentVariable($k, $val, "Process") }')
215
+ psLines.push(' }')
216
+ psLines.push(' }')
217
+ psLines.push('}')
218
+ psLines.push('# end squeezr wrapper')
219
+ const psFunction = psLines.join('\r\n')
220
+ const existing = fs.existsSync(psProfilePath) ? fs.readFileSync(psProfilePath, 'utf-8') : ''
221
+ if (!existing.includes(psMarker)) {
222
+ fs.appendFileSync(psProfilePath, `\n${psFunction}\n`)
223
+ console.log(` [ok] PowerShell wrapper added to ${psProfilePath}`)
224
+ console.log('')
225
+ console.log(' ╔═══════════════════════════════════════════════════════════════╗')
226
+ console.log(' ║ ONE-TIME SETUP: Close this terminal and open a new one. ║')
227
+ console.log(' ║ This loads the wrapper that auto-refreshes env vars. ║')
228
+ console.log(' ║ After that, you will NEVER need to do this again. ║')
229
+ console.log(' ╚═══════════════════════════════════════════════════════════════╝')
230
+ } else {
231
+ const updated = existing.replace(/# squeezr wrapper[\s\S]*?# end squeezr wrapper/, psFunction)
232
+ fs.writeFileSync(psProfilePath, updated)
233
+ console.log(` [ok] PowerShell wrapper updated in ${psProfilePath}`)
234
+ }
235
+ } catch {
236
+ console.log(` [skip] PowerShell profile wrapper could not be installed`)
237
+ }
238
+ }
239
+
240
+ const HELP = `
241
+ Squeezr v${pkg.version} — AI context compressor for Claude Code, Codex, Aider, Gemini CLI and Ollama
242
+
243
+ Usage:
244
+ squeezr Start the proxy (default)
245
+ squeezr start Start the proxy
246
+ squeezr setup One-time setup: auto-start on login + configure all CLIs
247
+ squeezr stop Stop the running proxy
248
+ squeezr restart Stop and restart the proxy (reloads config)
249
+ squeezr logs Show last 50 lines of the log file
250
+ squeezr gain Show token savings stats
251
+ squeezr gain --reset Reset saved stats
252
+ squeezr discover Show pattern coverage report (proxy must be running)
253
+ squeezr status Check if proxy is running
254
+ squeezr config Print config file path and current settings
255
+ squeezr mcp install Register Squeezr MCP server in Claude Code, Cursor, Windsurf & Cline
256
+ squeezr mcp uninstall Remove Squeezr MCP registration
257
+ squeezr ports Change HTTP and MITM proxy ports
258
+ squeezr tunnel Expose proxy via Cloudflare Tunnel for Cursor IDE
259
+ squeezr enable-claude-desktop Enable hosts-file redirect for Claude Desktop (admin once)
260
+ squeezr disable-claude-desktop Disable hosts-file redirect for Claude Desktop
261
+ squeezr desktop start Start SEPARATE proxy for Claude/Codex Desktop (ports 8443+8088)
262
+ squeezr desktop stop Stop the desktop proxy (does NOT affect main proxy)
263
+ squeezr desktop status Show desktop proxy status
264
+ squeezr bypass Toggle bypass mode (skip compression, keep logging)
265
+ squeezr bypass --on Enable bypass (disable compression)
266
+ squeezr bypass --off Disable bypass (resume compression)
267
+ squeezr zest Install Zest local AI compression model (guided wizard)
268
+ squeezr update Kill old processes, install latest from npm, restart
269
+ squeezr uninstall Remove Squeezr completely (env vars, CA, auto-start, logs)
270
+ squeezr version Print version
271
+ squeezr help Show this help
272
+ `
273
+
274
+ function runNode(script, extraArgs = []) {
275
+ const distPath = path.join(ROOT, 'dist', script)
276
+ if (!fs.existsSync(distPath)) {
277
+ console.error(`Error: ${distPath} not found. Run 'npm run build' first.`)
278
+ process.exit(1)
279
+ }
280
+ const child = spawn(process.execPath, [distPath, ...extraArgs], {
281
+ stdio: 'inherit',
282
+ cwd: ROOT,
283
+ })
284
+ child.on('exit', code => process.exit(code ?? 0))
285
+ }
286
+
287
+ async function startDaemon() {
288
+ const distIndex = path.join(ROOT, 'dist', 'index.js')
289
+ if (!fs.existsSync(distIndex)) {
290
+ console.error(`Error: ${distIndex} not found. Run 'npm run build' first.`)
291
+ process.exit(1)
292
+ }
293
+
294
+ // Check if already running and if the version matches. We use probeSqueezr
295
+ // (which validates the `identity` field) so we don't mistake an unrelated
296
+ // HTTP service squatting on this port for a real squeezr instance.
297
+ const port = getPort()
298
+ const running = await probeSqueezr(port)
299
+ const runningVersion = running ? running.version : null
300
+ if (runningVersion) {
301
+ if (runningVersion === pkg.version) {
302
+ const mitmPort = getMitmPort(port)
303
+ console.log(`Squeezr is already running (v${pkg.version})`)
304
+ console.log(` HTTP proxy (Claude/Aider/Gemini): http://localhost:${port}`)
305
+ console.log(` MITM proxy (Codex): http://localhost:${mitmPort}`)
306
+ console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
307
+ return
308
+ }
309
+ // Version mismatch — old process from before npm update. Kill and restart.
310
+ console.log(`Squeezr v${runningVersion} is running but v${pkg.version} is installed. Restarting...`)
311
+ stopProxy()
312
+ // Wait for ports to free up
313
+ await new Promise(r => setTimeout(r, 1500))
314
+ }
315
+
316
+ // Launch detached background process
317
+ const logDir = path.join(os.homedir(), '.squeezr')
318
+ const logFile = path.join(logDir, 'squeezr.log')
319
+ fs.mkdirSync(logDir, { recursive: true })
320
+ const logFd = fs.openSync(logFile, 'a')
321
+ const child = spawn(process.execPath, [distIndex], {
322
+ detached: true,
323
+ stdio: ['ignore', logFd, logFd],
324
+ windowsHide: true,
325
+ cwd: ROOT,
326
+ env: { ...process.env, SQUEEZR_DAEMON: '1' },
327
+ })
328
+ child.unref()
329
+ fs.closeSync(logFd)
330
+ const mitmPort = getMitmPort(port)
331
+ console.log(`Squeezr started (pid ${child.pid})`)
332
+ console.log(` HTTP proxy (Claude/Aider/Gemini): http://localhost:${port}`)
333
+ console.log(` MITM proxy (Codex): http://localhost:${mitmPort}`)
334
+ console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
335
+ console.log(` Logs: ${logFile}`)
336
+
337
+ // ── Also start the SEPARATE Desktop proxy (independent process) ────────────
338
+ // This is the proxy that serves Claude Desktop and Codex Desktop. It runs in
339
+ // its own Node process on ports 8443 + 8088. If it crashes, the main proxy
340
+ // (just started above) keeps running. Failures here are non-fatal — we just
341
+ // warn and continue.
342
+ try {
343
+ await desktopProxyStart()
344
+ } catch (e) {
345
+ console.warn(`Desktop proxy did not start: ${e?.message ?? e}`)
346
+ console.warn(`(The main proxy is fine. Try \`squeezr desktop start\` separately.)`)
347
+ }
348
+ }
349
+
350
+ function showLogs() {
351
+ const logFile = path.join(os.homedir(), '.squeezr', 'squeezr.log')
352
+ if (!fs.existsSync(logFile)) {
353
+ console.log('No log file yet. Run: squeezr setup')
354
+ return
355
+ }
356
+ // Show last 50 lines
357
+ const lines = fs.readFileSync(logFile, 'utf-8').split('\n').filter(Boolean)
358
+ const tail = lines.slice(-50)
359
+ if (tail.length === 0) {
360
+ console.log('Log file is empty — no requests yet.')
361
+ return
362
+ }
363
+ console.log(`=== ${logFile} (last ${tail.length} lines) ===\n`)
364
+ console.log(tail.join('\n'))
365
+ }
366
+
367
+ function killMcpProcesses() {
368
+ if (process.platform === 'win32') {
369
+ try {
370
+ execSync(
371
+ `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -like '*squeezr*mcp*' -or $_.CommandLine -like '*mcp.js*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"`,
372
+ { stdio: 'pipe' }
373
+ )
374
+ } catch {}
375
+ } else {
376
+ try { execSync(`pkill -f 'squeezr.*mcp' 2>/dev/null`, { stdio: 'pipe' }) } catch {}
377
+ try { execSync(`pkill -f 'mcp\\.js' 2>/dev/null`, { stdio: 'pipe' }) } catch {}
378
+ }
379
+ }
380
+
381
+ function stopProxy() {
382
+ const port = getPort()
383
+ const mitmPort = getMitmPort(port)
384
+
385
+ // ── Step 0: Stop the SEPARATE Desktop proxy first (independent process) ────
386
+ // It has its own PID file. Failure here doesn't block main-proxy shutdown.
387
+ try {
388
+ const desktopPid = readDesktopPid()
389
+ if (desktopPid) {
390
+ try { process.kill(desktopPid, 'SIGTERM') } catch {}
391
+ // Give it a moment to exit gracefully
392
+ for (let i = 0; i < 20; i++) {
393
+ try { process.kill(desktopPid, 0) } catch { break }
394
+ execSync(process.platform === 'win32' ? `ping -n 1 127.0.0.1 > nul` : `sleep 0.1`, { stdio: 'pipe' })
395
+ }
396
+ try { process.kill(desktopPid, 'SIGKILL') } catch {}
397
+ try { fs.unlinkSync(DESKTOP_PID_FILE) } catch {}
398
+ // Also clean up any orphan listeners on the desktop ports
399
+ for (const dp of [DESKTOP_HTTPS_PORT, DESKTOP_HTTP_PORT]) {
400
+ try {
401
+ if (process.platform === 'win32') {
402
+ const out = execSync(`netstat -ano | findstr ":${dp} "`, { encoding: 'utf-8', stdio: 'pipe' })
403
+ const matches = [...out.matchAll(/LISTENING\s+(\d+)/g)]
404
+ for (const m of matches) {
405
+ try { execSync(`taskkill /F /PID ${m[1]}`, { stdio: 'pipe' }) } catch {}
406
+ }
407
+ } else {
408
+ try { execSync(`lsof -ti :${dp} -sTCP:LISTEN | xargs -r kill -9`, { stdio: 'pipe' }) } catch {}
409
+ }
410
+ } catch {}
411
+ }
412
+ }
413
+ } catch {}
414
+
415
+ // ── Step 1: Graceful shutdown via HTTP — persists history/cache before exit ──
416
+ // /squeezr/control/stop emits SIGTERM which calls persistAndExit().
417
+ // This prevents losing the current session's savings history on stop.
418
+ let gracefulOk = false
419
+ try {
420
+ const req = http.request({ hostname: 'localhost', port, path: '/squeezr/control/stop', method: 'POST', timeout: 2000 })
421
+ req.on('error', () => {})
422
+ req.end()
423
+ gracefulOk = true
424
+ // Give the process time to persist and exit cleanly
425
+ execSync(process.platform === 'win32'
426
+ ? `ping -n 2 127.0.0.1 > nul` // ~1s sleep on Windows
427
+ : `sleep 1`, { stdio: 'pipe' })
428
+ } catch {}
429
+
430
+ // ── Step 2: Force-kill anything still listening (fallback) ──────────────────
431
+ const ports = [port, mitmPort]
432
+ let killed = gracefulOk // count graceful as "killed"
433
+
434
+ for (const p of ports) {
435
+ try {
436
+ let pids = []
437
+ if (process.platform === 'win32') {
438
+ const out = execSync(`netstat -ano | findstr ":${p} "`, { encoding: 'utf-8', stdio: 'pipe' })
439
+ const matches = [...out.matchAll(/LISTENING\s+(\d+)/g)]
440
+ pids = [...new Set(matches.map(m => m[1]))]
441
+ } else {
442
+ try {
443
+ const out = execSync(`lsof -ti :${p} -sTCP:LISTEN`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
444
+ pids = out.split(/\s+/).filter(Boolean)
445
+ } catch {
446
+ try {
447
+ const out = execSync(`fuser ${p}/tcp 2>/dev/null`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
448
+ pids = out.split(/\s+/).filter(Boolean)
449
+ } catch {}
450
+ }
451
+ }
452
+ for (const pid of pids) {
453
+ try {
454
+ if (process.platform === 'win32') {
455
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' })
456
+ } else {
457
+ execSync(`kill -9 ${pid}`, { stdio: 'pipe' })
458
+ }
459
+ killed = true
460
+ } catch {}
461
+ }
462
+ } catch {}
463
+ }
464
+
465
+ // Also stop the MCP server process
466
+ killMcpProcesses()
467
+
468
+ // Clear HTTPS_PROXY so npm and other tools don't try to use the dead proxy
469
+ if (process.platform === 'win32') {
470
+ try { execSync('setx HTTPS_PROXY ""', { stdio: 'pipe' }) } catch {}
471
+ } else if (isWSL()) {
472
+ try {
473
+ const setxExe = '/mnt/c/Windows/System32/setx.exe'
474
+ if (fs.existsSync(setxExe)) execSync(`"${setxExe}" HTTPS_PROXY ""`, { stdio: 'pipe' })
475
+ } catch {}
476
+ }
477
+
478
+ if (killed) {
479
+ console.log(`Squeezr stopped`)
480
+ } else {
481
+ console.log(`Squeezr is not running`)
482
+ }
483
+ }
484
+
485
+ async function checkStatus() {
486
+ const port = getPort()
487
+ const mitmPort = getMitmPort(port)
488
+ const json = await probeSqueezr(port, 2000)
489
+ if (!json) {
490
+ // Distinguish "nothing here" from "something foreign here" so the user gets
491
+ // an actionable error instead of a misleading "not running".
492
+ const occupied = await new Promise(resolve => {
493
+ const req = http.get(`http://localhost:${port}/`, res => {
494
+ resolve({ status: res.statusCode, server: res.headers.server })
495
+ res.resume()
496
+ })
497
+ req.on('error', () => resolve(null))
498
+ req.setTimeout(1500, () => { req.destroy(); resolve(null) })
499
+ })
500
+ if (occupied) {
501
+ console.log(`Squeezr is NOT running on port ${port}, but a foreign service is.`)
502
+ console.log(` Foreign response: HTTP ${occupied.status}${occupied.server ? ` (Server: ${occupied.server})` : ''}`)
503
+ console.log(` Stop it or change squeezr.toml port, then run: squeezr start`)
504
+ } else {
505
+ console.log(`Squeezr is NOT running`)
506
+ console.log('Start it with: squeezr start')
507
+ }
508
+ return false
509
+ }
510
+ console.log(`Squeezr is running (v${json.version})`)
511
+ console.log(` HTTP proxy (Claude Code, Claude Desktop, Codex Desktop, Aider, Gemini): http://localhost:${port}`)
512
+ console.log(` MITM proxy (Codex CLI TLS): http://localhost:${mitmPort}`)
513
+ console.log(` Dashboard: http://localhost:${port}/squeezr/dashboard`)
514
+ if (json.mode) console.log(` Mode: ${json.mode}`)
515
+ if (json.uptime_seconds != null) {
516
+ const s = json.uptime_seconds
517
+ const fmt = s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m ${s%60}s` : `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`
518
+ console.log(` Uptime: ${fmt}`)
519
+ }
520
+ if (json.bypassed) console.log(` ⚠ Bypass mode is ON (compression disabled)`)
521
+ if (json.circuit_breaker) {
522
+ const cb = json.circuit_breaker
523
+ const icons = { closed: '🟢 OK', open: '🔴 OPEN', 'half-open': '🟡 PROBING' }
524
+ console.log(` Circuit: ${icons[cb.state] || cb.state}${cb.total_trips ? ` (${cb.total_trips} trip${cb.total_trips > 1 ? 's' : ''})` : ''}`)
525
+ }
526
+ return true
527
+ }
528
+
529
+ function showConfig() {
530
+ const tomlPath = path.join(ROOT, 'squeezr.toml')
531
+ console.log(`Config file: ${tomlPath}`)
532
+ if (fs.existsSync(tomlPath)) {
533
+ console.log('\nCurrent config:')
534
+ console.log(fs.readFileSync(tomlPath, 'utf-8'))
535
+ } else {
536
+ console.log('No squeezr.toml found. Using defaults.')
537
+ }
538
+ }
539
+
540
+
541
+ // ── squeezr mcp ───────────────────────────────────────────────────────────────
542
+
543
+ async function mcpInstall() {
544
+ const mcpServerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'mcp.js')
545
+ const entry = {
546
+ type: 'stdio',
547
+ command: 'node',
548
+ args: [mcpServerPath],
549
+ }
550
+
551
+ // Claude Desktop config path varies by platform
552
+ const claudeDesktopConfig = process.platform === 'win32'
553
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
554
+ : process.platform === 'darwin'
555
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
556
+ : path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json')
557
+
558
+ const targets = [
559
+ {
560
+ name: 'Claude Code',
561
+ file: path.join(os.homedir(), '.claude.json'),
562
+ key: 'mcpServers',
563
+ },
564
+ {
565
+ name: 'Claude Desktop',
566
+ file: claudeDesktopConfig,
567
+ key: 'mcpServers',
568
+ },
569
+ {
570
+ name: 'Cursor',
571
+ file: path.join(os.homedir(), '.cursor', 'mcp.json'),
572
+ key: 'mcpServers',
573
+ },
574
+ {
575
+ name: 'Windsurf',
576
+ file: path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
577
+ key: 'mcpServers',
578
+ },
579
+ {
580
+ name: 'Cline / Roo-Cline',
581
+ file: path.join(os.homedir(), '.vscode', 'extensions', 'mcp_settings.json'),
582
+ key: 'mcpServers',
583
+ },
584
+ ]
585
+
586
+ let installed = 0
587
+
588
+ for (const target of targets) {
589
+ try {
590
+ // Only install into configs that already exist (user has that tool)
591
+ if (!fs.existsSync(target.file) && target.name !== 'Claude Code') continue
592
+
593
+ let cfg = {}
594
+ if (fs.existsSync(target.file)) {
595
+ try { cfg = JSON.parse(fs.readFileSync(target.file, 'utf-8')) } catch { cfg = {} }
596
+ }
597
+ cfg[target.key] = cfg[target.key] || {}
598
+ cfg[target.key].squeezr = entry
599
+
600
+ const dir = path.dirname(target.file)
601
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
602
+ fs.writeFileSync(target.file, JSON.stringify(cfg, null, 2))
603
+ installed++
604
+ console.log()
605
+ console.log(' ok ' + target.name + ': ' + target.file)
606
+ } catch (e) {
607
+ console.warn()
608
+ console.warn(' warn ' + target.name + ': ' + (e.message || e))
609
+ }
610
+ }
611
+
612
+ console.log()
613
+ console.log('MCP server registered in ' + installed + ' client(s).')
614
+ console.log('Server binary: ' + mcpServerPath)
615
+ console.log('')
616
+ console.log('Available tools in Claude Desktop, Claude Code, Codex Desktop, Cursor…:')
617
+ console.log(' squeezr_status Check if Squeezr is running')
618
+ console.log(' squeezr_stats Real-time token savings')
619
+ console.log(' squeezr_set_mode Change compression aggressiveness')
620
+ console.log(' squeezr_config Current configuration')
621
+ console.log(' squeezr_habits — Wasteful pattern report')
622
+ console.log(' squeezr_open_dashboard — Open the Squeezr dashboard in your browser')
623
+ }
624
+
625
+ async function mcpUninstall() {
626
+ const claudeDesktopConfig = process.platform === 'win32'
627
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
628
+ : process.platform === 'darwin'
629
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
630
+ : path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json')
631
+ const files = [
632
+ path.join(os.homedir(), '.claude.json'),
633
+ claudeDesktopConfig,
634
+ path.join(os.homedir(), '.cursor', 'mcp.json'),
635
+ path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'),
636
+ path.join(os.homedir(), '.vscode', 'extensions', 'mcp_settings.json'),
637
+ ]
638
+ let removed = 0
639
+ for (const file of files) {
640
+ if (!fs.existsSync(file)) continue
641
+ try {
642
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'))
643
+ if (cfg.mcpServers?.squeezr) {
644
+ delete cfg.mcpServers.squeezr
645
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2))
646
+ console.log()
647
+ removed++
648
+ }
649
+ } catch { /* ignore */ }
650
+ }
651
+ if (removed === 0) console.log('Squeezr MCP not found in any config.')
652
+ else console.log()
653
+ }
654
+
655
+ // ── squeezr ports ─────────────────────────────────────────────────────────────
656
+
657
+ async function configurePorts() {
658
+ const { createInterface } = await import('readline')
659
+ const tomlPath = path.join(ROOT, 'squeezr.toml')
660
+ let tomlContent = fs.existsSync(tomlPath) ? fs.readFileSync(tomlPath, 'utf-8') : ''
661
+
662
+ // Read current ports from toml
663
+ const portMatch = tomlContent.match(/^port\s*=\s*(\d+)/m)
664
+ const mitmMatch = tomlContent.match(/^mitm_port\s*=\s*(\d+)/m)
665
+ const currentPort = portMatch ? parseInt(portMatch[1]) : 8080
666
+ const currentMitm = mitmMatch ? parseInt(mitmMatch[1]) : currentPort + 1
667
+
668
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
669
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve))
670
+
671
+ console.log(`\nCurrent ports:`)
672
+ console.log(` HTTP proxy (Claude/Aider/Gemini): ${currentPort}`)
673
+ console.log(` MITM proxy (Codex): ${currentMitm}`)
674
+ console.log(` Dashboard: ${currentPort}/squeezr/dashboard (same port as proxy)\n`)
675
+
676
+ const newPort = await ask(`HTTP proxy port [${currentPort}]: `)
677
+ const newMitm = await ask(`MITM proxy port [${currentMitm}]: `)
678
+ rl.close()
679
+
680
+ const finalPort = newPort.trim() ? parseInt(newPort.trim()) : currentPort
681
+ const finalMitm = newMitm.trim() ? parseInt(newMitm.trim()) : currentMitm
682
+
683
+ if (isNaN(finalPort) || isNaN(finalMitm) || finalPort < 1 || finalMitm < 1 || finalPort > 65535 || finalMitm > 65535) {
684
+ console.error('Invalid port number. Must be between 1 and 65535.')
685
+ process.exit(1)
686
+ }
687
+ if (finalPort === finalMitm) {
688
+ console.error('HTTP and MITM ports must be different.')
689
+ process.exit(1)
690
+ }
691
+
692
+ // Update toml
693
+ if (portMatch) {
694
+ tomlContent = tomlContent.replace(/^port\s*=\s*\d+/m, `port = ${finalPort}`)
695
+ } else if (tomlContent.includes('[proxy]')) {
696
+ tomlContent = tomlContent.replace('[proxy]', `[proxy]\nport = ${finalPort}`)
697
+ } else {
698
+ tomlContent = `[proxy]\nport = ${finalPort}\n` + tomlContent
699
+ }
700
+
701
+ if (mitmMatch) {
702
+ tomlContent = tomlContent.replace(/^mitm_port\s*=\s*\d+/m, `mitm_port = ${finalMitm}`)
703
+ } else {
704
+ // Add after port line
705
+ tomlContent = tomlContent.replace(/^(port\s*=\s*\d+)/m, `$1\nmitm_port = ${finalMitm}`)
706
+ }
707
+
708
+ fs.writeFileSync(tomlPath, tomlContent)
709
+ console.log(`\nSaved to ${tomlPath}`)
710
+
711
+ // Update env vars
712
+ if (process.platform === 'win32') {
713
+ try { execSync(`setx SQUEEZR_PORT "${finalPort}"`, { stdio: 'pipe' }) } catch {}
714
+ try { execSync(`setx SQUEEZR_MITM_PORT "${finalMitm}"`, { stdio: 'pipe' }) } catch {}
715
+ try { execSync(`setx ANTHROPIC_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
716
+ try { execSync(`setx GEMINI_API_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
717
+ console.log('Environment variables updated. Restart your terminal for changes to take effect.')
718
+ } else {
719
+ // Update shell profiles directly
720
+ const profiles = [
721
+ path.join(os.homedir(), '.zshrc'),
722
+ path.join(os.homedir(), '.bashrc'),
723
+ path.join(os.homedir(), '.bash_profile'),
724
+ ]
725
+ const envBlock = [
726
+ `export SQUEEZR_PORT=${finalPort}`,
727
+ `export SQUEEZR_MITM_PORT=${finalMitm}`,
728
+ `export ANTHROPIC_BASE_URL=http://localhost:${finalPort}`,
729
+ `export GEMINI_API_BASE_URL=http://localhost:${finalPort}`,
730
+ ].join('\n')
731
+ for (const p of profiles) {
732
+ try {
733
+ let content = fs.readFileSync(p, 'utf-8')
734
+ if (content.includes('# squeezr env vars')) {
735
+ // Replace existing block (from marker to the closing fi)
736
+ content = content.replace(
737
+ /# squeezr env vars[\s\S]*?(?:fi|unset -f _squeezr_alive)/,
738
+ `# squeezr env vars\n${envBlock}\n# squeezr auto-heal (validates identity, not just HTTP 200)\n_squeezr_alive() {\n curl -sf --max-time 2 "http://localhost:${finalPort}/squeezr/health" 2>/dev/null | grep -q '"identity":"squeezr"'\n}\nif ! _squeezr_alive; then squeezr start > /dev/null 2>&1; fi\nunset -f _squeezr_alive`
739
+ )
740
+ fs.writeFileSync(p, content)
741
+ console.log(` [ok] Updated ${p}`)
742
+ }
743
+ } catch {}
744
+ }
745
+ // Also update env for WSL setx if on WSL
746
+ try {
747
+ const procVersion = fs.readFileSync('/proc/version', 'utf-8')
748
+ if (/microsoft|wsl/i.test(procVersion)) {
749
+ const setx = '/mnt/c/Windows/System32/setx.exe'
750
+ try { execSync(`"${setx}" SQUEEZR_PORT "${finalPort}"`, { stdio: 'pipe' }) } catch {}
751
+ try { execSync(`"${setx}" SQUEEZR_MITM_PORT "${finalMitm}"`, { stdio: 'pipe' }) } catch {}
752
+ try { execSync(`"${setx}" ANTHROPIC_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
753
+ try { execSync(`"${setx}" GEMINI_API_BASE_URL "http://localhost:${finalPort}"`, { stdio: 'pipe' }) } catch {}
754
+ }
755
+ } catch {}
756
+ }
757
+
758
+ // Apply to current process so stop/start works immediately
759
+ process.env.SQUEEZR_PORT = String(finalPort)
760
+ process.env.SQUEEZR_MITM_PORT = String(finalMitm)
761
+ process.env.ANTHROPIC_BASE_URL = `http://localhost:${finalPort}`
762
+
763
+ // Auto stop + start
764
+ console.log('')
765
+ stopProxy()
766
+ await new Promise(r => setTimeout(r, 1500))
767
+ await startDaemon()
768
+ console.log(`\nOpen a new terminal for env vars to apply to other tools.`)
769
+ }
770
+
771
+ // ── squeezr uninstall ─────────────────────────────────────────────────────────
772
+
773
+ async function uninstall() {
774
+ const { createInterface } = await import('readline')
775
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
776
+ const answer = await new Promise(resolve => rl.question(
777
+ 'This will remove Squeezr completely: stop proxy, remove env vars, CA certs, auto-start, config, and logs.\nContinue? [y/N] ', resolve
778
+ ))
779
+ rl.close()
780
+ if (answer.trim().toLowerCase() !== 'y') {
781
+ console.log('Cancelled.')
782
+ return
783
+ }
784
+
785
+ console.log('\nUninstalling Squeezr...\n')
786
+
787
+ // 1. Stop proxy
788
+ stopProxy()
789
+
790
+ // 2. Remove env vars
791
+ if (process.platform === 'win32') {
792
+ const vars = ['ANTHROPIC_BASE_URL', 'GEMINI_API_BASE_URL', 'HTTPS_PROXY', 'NODE_EXTRA_CA_CERTS', 'SQUEEZR_PORT', 'SQUEEZR_MITM_PORT', 'openai_base_url', 'NO_PROXY']
793
+ for (const v of vars) {
794
+ try { execSync(`reg delete "HKCU\\Environment" /v ${v} /f`, { stdio: 'pipe' }) } catch {}
795
+ }
796
+ console.log(' [ok] Windows env vars removed')
797
+ } else {
798
+ // Remove squeezr block from shell profiles
799
+ const profiles = [
800
+ path.join(os.homedir(), '.zshrc'),
801
+ path.join(os.homedir(), '.bashrc'),
802
+ path.join(os.homedir(), '.bash_profile'),
803
+ ]
804
+ for (const p of profiles) {
805
+ try {
806
+ const content = fs.readFileSync(p, 'utf-8')
807
+ if (content.includes('# squeezr env vars')) {
808
+ const cleaned = content.replace(/\n?# squeezr env vars[\s\S]*?fi\n?/g, '\n')
809
+ fs.writeFileSync(p, cleaned)
810
+ console.log(` [ok] Cleaned ${p}`)
811
+ }
812
+ } catch {}
813
+ }
814
+ // Also clean .profile
815
+ const profilePath = path.join(os.homedir(), '.profile')
816
+ try {
817
+ const content = fs.readFileSync(profilePath, 'utf-8')
818
+ if (content.includes('# squeezr env vars')) {
819
+ const cleaned = content.replace(/\n?# squeezr env vars[^\n]*(\nexport [^\n]*)*/g, '')
820
+ fs.writeFileSync(profilePath, cleaned)
821
+ console.log(` [ok] Cleaned ${profilePath}`)
822
+ }
823
+ } catch {}
824
+ }
825
+
826
+ // 3. Remove CA from certificate stores
827
+ if (process.platform === 'win32') {
828
+ try { execSync('certutil -delstore -user Root "Squeezr-MITM-CA"', { stdio: 'pipe' }); console.log(' [ok] CA removed from user certificate store') } catch {}
829
+ try { execSync('certutil -delstore Root "Squeezr-MITM-CA"', { stdio: 'pipe' }) } catch {}
830
+ } else if (process.platform === 'darwin') {
831
+ try { execSync('security delete-certificate -c "Squeezr-MITM-CA" ~/Library/Keychains/login.keychain-db', { stdio: 'pipe' }); console.log(' [ok] CA removed from Keychain') } catch {}
832
+ }
833
+ // On Linux, CA is only in bundle.crt which gets deleted with ~/.squeezr below
834
+
835
+ // 4. Remove auto-start
836
+ if (process.platform === 'win32') {
837
+ try { execSync('nssm stop SqueezrProxy', { stdio: 'pipe' }) } catch {}
838
+ try { execSync('nssm remove SqueezrProxy confirm', { stdio: 'pipe' }) } catch {}
839
+ try { execSync('schtasks /Delete /TN "Squeezr" /F', { stdio: 'pipe' }); console.log(' [ok] Removed scheduled task') } catch {}
840
+ // Remove Startup folder VBS (fallback auto-start)
841
+ const startupVbs = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'squeezr-start.vbs')
842
+ try { fs.unlinkSync(startupVbs); console.log(' [ok] Removed startup VBS script') } catch {}
843
+ } else if (process.platform === 'darwin') {
844
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.squeezr.plist')
845
+ try { execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' }) } catch {}
846
+ try { fs.unlinkSync(plistPath); console.log(' [ok] Removed launchd plist') } catch {}
847
+ } else {
848
+ try { execSync('systemctl --user disable --now squeezr', { stdio: 'pipe' }) } catch {}
849
+ const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', 'squeezr.service')
850
+ try { fs.unlinkSync(servicePath); console.log(' [ok] Removed systemd service') } catch {}
851
+ }
852
+
853
+ // 5. Remove ~/.squeezr (logs, cache, CA, stats)
854
+ const squeezrDir = path.join(os.homedir(), '.squeezr')
855
+ try {
856
+ fs.rmSync(squeezrDir, { recursive: true, force: true })
857
+ console.log(` [ok] Removed ${squeezrDir}`)
858
+ } catch {}
859
+
860
+ // 6. Remove global config
861
+ const tomlPath = path.join(ROOT, 'squeezr.toml')
862
+ try { fs.unlinkSync(tomlPath) } catch {}
863
+
864
+ // 7. Remove shell wrapper functions from profiles
865
+ if (process.platform === 'win32') {
866
+ try {
867
+ const psProfilePath = execSync('powershell -NoProfile -Command "[Environment]::GetFolderPath(\'MyDocuments\') + \'\\WindowsPowerShell\\Microsoft.PowerShell_profile.ps1\'"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
868
+ if (fs.existsSync(psProfilePath)) {
869
+ const content = fs.readFileSync(psProfilePath, 'utf-8')
870
+ if (content.includes('# squeezr wrapper')) {
871
+ const cleaned = content.replace(/\n?# squeezr wrapper[\s\S]*?# end squeezr wrapper\n?/g, '')
872
+ fs.writeFileSync(psProfilePath, cleaned)
873
+ console.log(` [ok] Removed PowerShell wrapper from ${psProfilePath}`)
874
+ }
875
+ }
876
+ } catch {}
877
+ }
878
+ // Remove bash/zsh wrapper
879
+ for (const p of [path.join(os.homedir(), '.bashrc'), path.join(os.homedir(), '.zshrc')]) {
880
+ try {
881
+ const content = fs.readFileSync(p, 'utf-8')
882
+ if (content.includes('# squeezr shell wrapper')) {
883
+ const cleaned = content.replace(/\n?# squeezr shell wrapper[\s\S]*?# end squeezr shell wrapper\n?/g, '')
884
+ fs.writeFileSync(p, cleaned)
885
+ console.log(` [ok] Removed shell wrapper from ${p}`)
886
+ }
887
+ } catch {}
888
+ }
889
+
890
+ // 8. Remove MCP registrations
891
+ console.log(' [..] Removing MCP registrations...')
892
+ try { await mcpUninstall() } catch {}
893
+
894
+ // 9. npm uninstall -g (clear HTTPS_PROXY first so npm doesn't hit dead proxy)
895
+ console.log(' [..] Uninstalling npm package...')
896
+ const cleanEnv = { ...process.env, HTTPS_PROXY: '', https_proxy: '', HTTP_PROXY: '', http_proxy: '' }
897
+ try {
898
+ execSync('npm uninstall -g squeezr-ai', { stdio: 'inherit', env: cleanEnv })
899
+ console.log(' [ok] npm package removed')
900
+ } catch {
901
+ try {
902
+ execSync('sudo npm uninstall -g squeezr-ai', { stdio: 'inherit', env: cleanEnv })
903
+ console.log(' [ok] npm package removed')
904
+ } catch {
905
+ console.log(' [warn] Could not uninstall npm package. Run manually: npm uninstall -g squeezr-ai')
906
+ }
907
+ }
908
+
909
+ console.log('\nDone! Squeezr has been completely removed.\n')
910
+ }
911
+
912
+ // ── Claude Desktop hosts file + TLS intercept ────────────────────────────────
913
+ // Claude Desktop ignores ANTHROPIC_BASE_URL (it's an Electron GUI app), so the
914
+ // only way to intercept is at DNS level: hosts file redirects api.anthropic.com
915
+ // 127.0.0.1, and Squeezr listens on :443 with TLS using its local CA cert.
916
+ //
917
+ // This function adds/removes the hosts file entry + firewall rule. Requires
918
+ // admin. On Windows, re-launches itself elevated via PowerShell Start-Process -Verb RunAs.
919
+
920
+ const HOSTS_FILE_WIN = 'C:\\Windows\\System32\\drivers\\etc\\hosts'
921
+ const HOSTS_FILE_UNIX = '/etc/hosts'
922
+ const HOSTS_MARKER_BEGIN = '# squeezr-claude-desktop BEGIN'
923
+ const HOSTS_MARKER_END = '# squeezr-claude-desktop END'
924
+ const INTERCEPTED_DOMAINS = ['api.anthropic.com']
925
+ const CLAUDE_DESKTOP_FLAG_FILE = path.join(os.homedir(), '.squeezr', 'claude-desktop-enabled')
926
+ const MITM_INTERNAL_PORT = 8443
927
+
928
+ async function toggleClaudeDesktopIntercept(enable) {
929
+ const isWin = process.platform === 'win32'
930
+ const hostsPath = isWin ? HOSTS_FILE_WIN : HOSTS_FILE_UNIX
931
+
932
+ // Check if running as admin
933
+ const isAdmin = await checkIsAdmin()
934
+ if (!isAdmin) {
935
+ if (isWin) {
936
+ console.log('Admin rights required to modify hosts file and bind to port 443.')
937
+ console.log('Relaunching elevated...\n')
938
+ const verb = enable ? 'enable-claude-desktop' : 'disable-claude-desktop'
939
+ try {
940
+ execSync(
941
+ `powershell -NoProfile -Command "Start-Process -FilePath '${process.execPath}' -ArgumentList '${process.argv[1]}','${verb}' -Verb RunAs -Wait"`,
942
+ { stdio: 'inherit' }
943
+ )
944
+ console.log('\nDone. Restart Squeezr to apply: squeezr stop && squeezr start')
945
+ } catch (e) {
946
+ console.error('Failed to launch elevated process: ' + e.message)
947
+ }
948
+ return
949
+ } else {
950
+ console.error('Run with sudo: sudo squeezr ' + (enable ? 'enable' : 'disable') + '-claude-desktop')
951
+ process.exit(1)
952
+ }
953
+ }
954
+
955
+ // Read hosts file
956
+ let content = ''
957
+ try { content = fs.readFileSync(hostsPath, 'utf-8') } catch (e) {
958
+ console.error('Could not read hosts file: ' + e.message)
959
+ process.exit(1)
960
+ }
961
+
962
+ // Strip any existing squeezr block
963
+ const blockRegex = new RegExp(
964
+ `\\r?\\n?${HOSTS_MARKER_BEGIN}[\\s\\S]*?${HOSTS_MARKER_END}\\r?\\n?`,
965
+ 'g'
966
+ )
967
+ content = content.replace(blockRegex, '')
968
+
969
+ if (enable) {
970
+ const block = [
971
+ '',
972
+ HOSTS_MARKER_BEGIN,
973
+ '# Redirects Claude Desktop to Squeezr for token compression.',
974
+ '# Remove this block (or run `squeezr disable-claude-desktop`) to undo.',
975
+ ...INTERCEPTED_DOMAINS.map(d => `127.0.0.1 ${d}`),
976
+ HOSTS_MARKER_END,
977
+ '',
978
+ ].join('\n')
979
+ content += block
980
+
981
+ fs.writeFileSync(hostsPath, content)
982
+ console.log('[1/4] Hosts file updated: api.anthropic.com → 127.0.0.1')
983
+
984
+ if (isWin) {
985
+ // 2. netsh portproxy 443 8443 (so Squeezr listens on 8443 without admin)
986
+ try {
987
+ execSync(`netsh interface portproxy delete v4tov4 listenport=443 listenaddress=127.0.0.1`, { stdio: 'pipe' })
988
+ } catch {}
989
+ try {
990
+ execSync(`netsh interface portproxy add v4tov4 listenport=443 listenaddress=127.0.0.1 connectport=${MITM_INTERNAL_PORT} connectaddress=127.0.0.1`, { stdio: 'pipe' })
991
+ console.log(`[2/4] Port forwarding 127.0.0.1:443 127.0.0.1:${MITM_INTERNAL_PORT} (netsh portproxy)`)
992
+ } catch (e) {
993
+ console.warn('Could not add netsh portproxy: ' + e.message)
994
+ }
995
+
996
+ // 3. Firewall: open inbound 443
997
+ try {
998
+ execSync('netsh advfirewall firewall delete rule name="Squeezr-Claude-Desktop-443"', { stdio: 'pipe' })
999
+ } catch {}
1000
+ try {
1001
+ execSync('netsh advfirewall firewall add rule name="Squeezr-Claude-Desktop-443" dir=in action=allow protocol=TCP localport=443', { stdio: 'pipe' })
1002
+ console.log('[3/4] Firewall rule added for port 443.')
1003
+ } catch (e) {
1004
+ console.warn('Could not add firewall rule: ' + e.message)
1005
+ }
1006
+ // Flush DNS cache so the change takes effect immediately
1007
+ try { execSync('ipconfig /flushdns', { stdio: 'pipe' }) } catch {}
1008
+ }
1009
+
1010
+ // Note: no persistent flag file needed — Squeezr auto-detects the hosts file
1011
+ // entry on startup and activates the MITM listener accordingly.
1012
+
1013
+ console.log('\n[NEXT] Restart Squeezr: squeezr stop && squeezr start')
1014
+ console.log(' Close Claude Desktop completely (including system tray).')
1015
+ console.log(' Reopen Claude Desktop and try any query.')
1016
+ console.log(' Verify in dashboard "By client" section → should show "claude_desktop".')
1017
+ } else {
1018
+ fs.writeFileSync(hostsPath, content)
1019
+ console.log('[1/4] Hosts file cleaned: api.anthropic.com restored to normal DNS.')
1020
+ if (isWin) {
1021
+ try {
1022
+ execSync(`netsh interface portproxy delete v4tov4 listenport=443 listenaddress=127.0.0.1`, { stdio: 'pipe' })
1023
+ console.log('[2/4] Port forwarding rule removed.')
1024
+ } catch {}
1025
+ try {
1026
+ execSync('netsh advfirewall firewall delete rule name="Squeezr-Claude-Desktop-443"', { stdio: 'pipe' })
1027
+ console.log('[3/4] Firewall rule removed.')
1028
+ } catch {}
1029
+ try { execSync('ipconfig /flushdns', { stdio: 'pipe' }) } catch {}
1030
+ }
1031
+ // Legacy flag cleanup (if exists from older version)
1032
+ try {
1033
+ const legacyFlag = path.join(os.homedir(), '.squeezr', 'claude-desktop-enabled')
1034
+ if (fs.existsSync(legacyFlag)) fs.unlinkSync(legacyFlag)
1035
+ } catch {}
1036
+ console.log('\n[NEXT] Restart Squeezr: squeezr stop && squeezr start')
1037
+ }
1038
+ }
1039
+
1040
+ async function checkIsAdmin() {
1041
+ if (process.platform === 'win32') {
1042
+ try {
1043
+ execSync('net session', { stdio: 'pipe' })
1044
+ return true
1045
+ } catch {
1046
+ return false
1047
+ }
1048
+ } else {
1049
+ return process.getuid && process.getuid() === 0
1050
+ }
1051
+ }
1052
+
1053
+ // ── Desktop proxy lifecycle (TOTALLY SEPARATE from main proxy on 8080) ───────
1054
+ // `squeezr desktop start` spawns dist/desktopProxy.js detached
1055
+ // `squeezr desktop stop` → kills via PID file (does NOT touch port 8080)
1056
+ // `squeezr desktop status` prints state
1057
+ //
1058
+ // Critical: this proxy lives or dies independently. Crashing it cannot affect
1059
+ // the main proxy on 8080 (Claude Code, Codex CLI, Aider, Gemini CLI).
1060
+
1061
+ const DESKTOP_PID_FILE = path.join(os.homedir(), '.squeezr', 'desktop-proxy.pid')
1062
+ const DESKTOP_LOG_FILE = path.join(os.homedir(), '.squeezr', 'desktop-proxy.log')
1063
+ const DESKTOP_HTTPS_PORT = 8443
1064
+ const DESKTOP_HTTP_PORT = 8088
1065
+
1066
+ function readDesktopPid() {
1067
+ try {
1068
+ const raw = fs.readFileSync(DESKTOP_PID_FILE, 'utf-8').trim()
1069
+ const pid = Number(raw)
1070
+ if (!pid || Number.isNaN(pid)) return null
1071
+ // Check process actually exists
1072
+ try { process.kill(pid, 0); return pid } catch { return null }
1073
+ } catch { return null }
1074
+ }
1075
+
1076
+ // Probe whether the desktop proxy listener is actually answering. Used to
1077
+ // detect orphan processes — situations where the PID file is stale but a
1078
+ // previous desktop proxy is still bound to the ports.
1079
+ async function isDesktopPortBound(port) {
1080
+ return new Promise(resolve => {
1081
+ const net = require('node:net')
1082
+ const sock = net.connect({ host: '127.0.0.1', port, timeout: 500 }, () => {
1083
+ sock.end()
1084
+ resolve(true)
1085
+ })
1086
+ sock.once('error', () => resolve(false))
1087
+ sock.once('timeout', () => { sock.destroy(); resolve(false) })
1088
+ })
1089
+ }
1090
+
1091
+ // Find the PID owning a local TCP listener on Windows via `netstat -ano`. We
1092
+ // use this only to clean up orphan desktop-proxy processes; it is a best
1093
+ // effort and returns null if it can't parse the output.
1094
+ function findPidByPort(port) {
1095
+ if (process.platform !== 'win32') return null
1096
+ try {
1097
+ const out = require('node:child_process').execSync(`netstat -ano -p TCP`, { encoding: 'utf-8' })
1098
+ for (const line of out.split(/\r?\n/)) {
1099
+ const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/)
1100
+ if (m && Number(m[1]) === port) return Number(m[2])
1101
+ }
1102
+ } catch { /* ignore */ }
1103
+ return null
1104
+ }
1105
+
1106
+ async function desktopProxyStart() {
1107
+ const existing = readDesktopPid()
1108
+ if (existing) {
1109
+ console.log(`Desktop proxy already running (pid=${existing}).`)
1110
+ console.log(` HTTPS: https://127.0.0.1:${DESKTOP_HTTPS_PORT} (Claude Desktop)`)
1111
+ console.log(` HTTP: http://127.0.0.1:${DESKTOP_HTTP_PORT} (Codex Desktop)`)
1112
+ return
1113
+ }
1114
+ // Detect orphan listener (PID file dead but listener still up) — common
1115
+ // after an ungraceful restart. Reclaim it by adopting the running PID,
1116
+ // otherwise the spawn below would crash with EADDRINUSE.
1117
+ if (await isDesktopPortBound(DESKTOP_HTTPS_PORT) || await isDesktopPortBound(DESKTOP_HTTP_PORT)) {
1118
+ const orphanPid = findPidByPort(DESKTOP_HTTPS_PORT) ?? findPidByPort(DESKTOP_HTTP_PORT)
1119
+ if (orphanPid) {
1120
+ try { fs.writeFileSync(DESKTOP_PID_FILE, String(orphanPid), 'utf-8') } catch {}
1121
+ console.log(`Desktop proxy is already bound to ports ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} (pid=${orphanPid}, adopted).`)
1122
+ } else {
1123
+ console.log(`Desktop proxy ports ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} are already in use by an unknown process. Use 'squeezr desktop stop' first.`)
1124
+ }
1125
+ return
1126
+ }
1127
+ const distPath = path.join(ROOT, 'dist', 'desktopProxy.js')
1128
+ if (!fs.existsSync(distPath)) {
1129
+ console.error(`Error: ${distPath} not found. Run 'npm run build' first.`)
1130
+ process.exit(1)
1131
+ }
1132
+ try { fs.mkdirSync(path.dirname(DESKTOP_LOG_FILE), { recursive: true }) } catch {}
1133
+ const out = fs.openSync(DESKTOP_LOG_FILE, 'a')
1134
+ const err = fs.openSync(DESKTOP_LOG_FILE, 'a')
1135
+ const child = spawn(process.execPath, [distPath], {
1136
+ detached: true,
1137
+ stdio: ['ignore', out, err],
1138
+ env: {
1139
+ ...process.env,
1140
+ SQUEEZR_DESKTOP_HTTPS_PORT: String(DESKTOP_HTTPS_PORT),
1141
+ SQUEEZR_DESKTOP_HTTP_PORT: String(DESKTOP_HTTP_PORT),
1142
+ },
1143
+ })
1144
+ child.unref()
1145
+ // Wait briefly to confirm it didn't immediately exit
1146
+ await new Promise(r => setTimeout(r, 800))
1147
+ if (child.exitCode !== null) {
1148
+ console.error(`Desktop proxy failed to start (exit ${child.exitCode}). Check log: ${DESKTOP_LOG_FILE}`)
1149
+ process.exit(1)
1150
+ }
1151
+ console.log(`Desktop proxy started (pid=${child.pid}).`)
1152
+ console.log(` HTTPS: https://127.0.0.1:${DESKTOP_HTTPS_PORT} (Claude Desktop)`)
1153
+ console.log(` HTTP: http://127.0.0.1:${DESKTOP_HTTP_PORT} (Codex Desktop)`)
1154
+ console.log(` Log: ${DESKTOP_LOG_FILE}`)
1155
+ console.log(``)
1156
+ console.log(`Note: the main Squeezr proxy on 8080 is unaffected by this command.`)
1157
+ }
1158
+
1159
+ async function desktopProxyStop() {
1160
+ let pid = readDesktopPid()
1161
+ if (!pid) {
1162
+ // PID file is missing or its pid is dead — but a listener may still be
1163
+ // bound by an orphan. Try to locate it by port and kill that instead.
1164
+ if (await isDesktopPortBound(DESKTOP_HTTPS_PORT) || await isDesktopPortBound(DESKTOP_HTTP_PORT)) {
1165
+ pid = findPidByPort(DESKTOP_HTTPS_PORT) ?? findPidByPort(DESKTOP_HTTP_PORT)
1166
+ if (!pid) {
1167
+ console.log(`Desktop proxy ports ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} are in use but PID cannot be resolved. Stop the owning process manually.`)
1168
+ return
1169
+ }
1170
+ console.log(`Recovered orphan desktop proxy pid=${pid} from listener.`)
1171
+ } else {
1172
+ console.log('Desktop proxy is not running.')
1173
+ return
1174
+ }
1175
+ }
1176
+ try {
1177
+ process.kill(pid, 'SIGTERM')
1178
+ // Wait up to 3s for graceful exit
1179
+ for (let i = 0; i < 30; i++) {
1180
+ try { process.kill(pid, 0) } catch { break }
1181
+ await new Promise(r => setTimeout(r, 100))
1182
+ }
1183
+ // Force kill if still alive
1184
+ try { process.kill(pid, 'SIGKILL') } catch {}
1185
+ try { fs.unlinkSync(DESKTOP_PID_FILE) } catch {}
1186
+ console.log(`Desktop proxy stopped (was pid=${pid}).`)
1187
+ } catch (e) {
1188
+ console.error(`Failed to stop desktop proxy: ${e.message}`)
1189
+ process.exit(1)
1190
+ }
1191
+ }
1192
+
1193
+ async function desktopProxyStatus() {
1194
+ let pid = readDesktopPid()
1195
+ // Fallback when PID file is stale: probe the desktop ports. If the listener
1196
+ // answers, the proxy IS running — just under a different PID than the one
1197
+ // recorded. This is the common shape after an ungraceful restart.
1198
+ if (!pid) {
1199
+ const bound = (await isDesktopPortBound(DESKTOP_HTTPS_PORT))
1200
+ || (await isDesktopPortBound(DESKTOP_HTTP_PORT))
1201
+ if (!bound) {
1202
+ console.log('Desktop proxy is NOT running.')
1203
+ console.log('Start it with: squeezr desktop start')
1204
+ return
1205
+ }
1206
+ const orphanPid = findPidByPort(DESKTOP_HTTPS_PORT) ?? findPidByPort(DESKTOP_HTTP_PORT)
1207
+ if (orphanPid) {
1208
+ try { fs.writeFileSync(DESKTOP_PID_FILE, String(orphanPid), 'utf-8') } catch {}
1209
+ pid = orphanPid
1210
+ console.log(`Desktop proxy is running (pid=${pid}, recovered from orphan listener).`)
1211
+ } else {
1212
+ console.log(`Desktop proxy listener is bound on ${DESKTOP_HTTPS_PORT}/${DESKTOP_HTTP_PORT} but its PID could not be resolved.`)
1213
+ }
1214
+ } else {
1215
+ console.log(`Desktop proxy is running (pid=${pid}).`)
1216
+ }
1217
+ console.log(` HTTPS: https://127.0.0.1:${DESKTOP_HTTPS_PORT} (Claude Desktop)`)
1218
+ console.log(` HTTP: http://127.0.0.1:${DESKTOP_HTTP_PORT} (Codex Desktop)`)
1219
+ console.log(` Log: ${DESKTOP_LOG_FILE}`)
1220
+ // Surface the activation state of the Claude Desktop hosts redirect the
1221
+ // most common reason "nothing appears in the logs" even when this listener
1222
+ // is bound is that the user never ran `enable-claude-desktop`, so Claude
1223
+ // Desktop's traffic goes directly to api.anthropic.com.
1224
+ const hostsActive = await isClaudeDesktopHostsActive()
1225
+ if (hostsActive) {
1226
+ console.log(``)
1227
+ console.log(` Claude Desktop interception: ACTIVE (hosts redirect set)`)
1228
+ } else {
1229
+ console.log(``)
1230
+ console.log(` Claude Desktop interception: NOT SET`)
1231
+ console.log(` Run as admin: squeezr enable-claude-desktop`)
1232
+ console.log(` Without this, Claude Desktop talks to api.anthropic.com directly`)
1233
+ console.log(` and no traffic will reach this proxy.`)
1234
+ }
1235
+ }
1236
+
1237
+ // Best-effort detection of whether the hosts file currently redirects
1238
+ // api.anthropic.com to 127.0.0.1 (the prerequisite for Claude Desktop
1239
+ // traffic to reach the desktop proxy).
1240
+ async function isClaudeDesktopHostsActive() {
1241
+ try {
1242
+ const hostsPath = process.platform === 'win32'
1243
+ ? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
1244
+ : '/etc/hosts'
1245
+ const txt = fs.readFileSync(hostsPath, 'utf-8')
1246
+ return /^\s*127\.0\.0\.1\s+api\.anthropic\.com\b/m.test(txt)
1247
+ || txt.includes('# squeezr-claude-desktop BEGIN')
1248
+ } catch { return false }
1249
+ }
1250
+
1251
+ // ── Codex Desktop config helper ──────────────────────────────────────────────
1252
+ // Writes openai_base_url to ~/.codex/config.toml so Codex Desktop routes
1253
+ // through Squeezr's HTTP proxy (no MITM needed — uses standard base URL).
1254
+
1255
+ function configureCodexDesktop(port) {
1256
+ const codexDir = path.join(os.homedir(), '.codex')
1257
+ const codexToml = path.join(codexDir, 'config.toml')
1258
+ const url = `http://localhost:${port}/v1`
1259
+ const marker = 'openai_base_url'
1260
+ const line = `openai_base_url = "${url}"`
1261
+
1262
+ try {
1263
+ fs.mkdirSync(codexDir, { recursive: true })
1264
+ if (fs.existsSync(codexToml)) {
1265
+ let content = fs.readFileSync(codexToml, 'utf-8')
1266
+ // Match openai_base_url = "anything" including empty "" which Codex Desktop
1267
+ // sometimes writes back to its config (bug we observed in the wild).
1268
+ // Force-overwrite any existing value, even if empty.
1269
+ const re = /openai_base_url\s*=\s*"[^"]*"/
1270
+ if (re.test(content)) {
1271
+ const before = content.match(re)?.[0] ?? ''
1272
+ content = content.replace(re, line)
1273
+ fs.writeFileSync(codexToml, content)
1274
+ if (before === `openai_base_url = ""`) {
1275
+ console.log(` [ok] Codex Desktop: FIXED empty openai_base_url in ${codexToml}`)
1276
+ } else {
1277
+ console.log(` [ok] Codex Desktop: updated ${codexToml}`)
1278
+ }
1279
+ } else {
1280
+ fs.appendFileSync(codexToml, `\n# Squeezr: route Codex Desktop through the compression proxy\n${line}\n`)
1281
+ console.log(` [ok] Codex Desktop: configured ${codexToml}`)
1282
+ }
1283
+ } else {
1284
+ fs.writeFileSync(codexToml, `# Squeezr: route Codex Desktop through the compression proxy\n${line}\n`)
1285
+ console.log(` [ok] Codex Desktop: created ${codexToml}`)
1286
+ }
1287
+ } catch (err) {
1288
+ console.log(` [warn] Codex Desktop config: ${err.message}`)
1289
+ }
1290
+ }
1291
+
1292
+ // ── squeezr setup ─────────────────────────────────────────────────────────────
1293
+
1294
+ async function setupWindows() {
1295
+ const squeezrBin = process.argv[1]
1296
+ const nodeExe = process.execPath
1297
+ const distIndex = path.join(ROOT, 'dist', 'index.js')
1298
+
1299
+ console.log('Setting up Squeezr for Windows...\n')
1300
+
1301
+ // 1. Set env vars permanently via setx (user scope, no admin needed)
1302
+ const port = getPort()
1303
+ const mitmPort = getMitmPort(port)
1304
+ const caPath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'ca.crt')
1305
+ const vars = {
1306
+ ANTHROPIC_BASE_URL: `http://localhost:${port}`,
1307
+ // openai_base_url NOT set — Codex uses WebSocket and must go via HTTPS_PROXY/MITM,
1308
+ // not through the HTTP proxy. Setting it breaks Codex's ws:// connections.
1309
+ GEMINI_API_BASE_URL: `http://localhost:${port}`,
1310
+ // HTTPS_PROXY intentionally NOT set globally it routes ALL HTTPS traffic through
1311
+ // the MITM proxy which breaks Claude Code, npm, and other tools. Only Codex needs it.
1312
+ // Users who need Codex MITM can set it per-session: $env:HTTPS_PROXY="http://localhost:8081"
1313
+ NODE_EXTRA_CA_CERTS: caPath,
1314
+ }
1315
+ // Clean up HTTPS_PROXY from registry if set by older versions
1316
+ try { execSync('reg delete "HKCU\\Environment" /v HTTPS_PROXY /f', { stdio: 'pipe' }) } catch {}
1317
+ for (const [key, value] of Object.entries(vars)) {
1318
+ try {
1319
+ execSync(`setx ${key} "${value}"`, { stdio: 'pipe' })
1320
+ console.log(` [ok] ${key}=${value}`)
1321
+ } catch {
1322
+ console.log(` [skip] ${key} could not be set`)
1323
+ }
1324
+ }
1325
+
1326
+ // 1b. Configure Codex Desktop (~/.codex/config.toml openai_base_url)
1327
+ // On Windows, ANTHROPIC_BASE_URL from setx is already visible to all GUI apps
1328
+ // (including Claude Desktop) since user-level env vars propagate to new processes.
1329
+ configureCodexDesktop(port)
1330
+
1331
+ // 1c. Register MCP server in Claude Desktop + Codex Desktop automatically
1332
+ await mcpInstall()
1333
+
1334
+ // 1c. Install PowerShell wrapper so env vars auto-refresh after start/setup/update
1335
+ installShellWrapper()
1336
+
1337
+ // 2. Auto-start: try NSSM (Windows service, survives crashes) → fallback to Task Scheduler
1338
+ const logDir = path.join(os.homedir(), '.squeezr')
1339
+ const serviceName = 'SqueezrProxy'
1340
+ let autoStartOk = false
1341
+
1342
+ const nssmAvailable = (() => {
1343
+ try { execSync('where nssm', { stdio: 'pipe' }); return true } catch { return false }
1344
+ })()
1345
+
1346
+ if (nssmAvailable) {
1347
+ try {
1348
+ // Remove existing service if present (ignore errors)
1349
+ try { execSync(`nssm stop ${serviceName}`, { stdio: 'pipe' }) } catch {}
1350
+ try { execSync(`nssm remove ${serviceName} confirm`, { stdio: 'pipe' }) } catch {}
1351
+
1352
+ execSync(`nssm install ${serviceName} "${nodeExe}" "${distIndex}"`, { stdio: 'pipe' })
1353
+ execSync(`nssm set ${serviceName} AppDirectory "${ROOT}"`, { stdio: 'pipe' })
1354
+ execSync(`nssm set ${serviceName} AppStdout "${logDir}\\service-stdout.log"`, { stdio: 'pipe' })
1355
+ execSync(`nssm set ${serviceName} AppStderr "${logDir}\\service-stderr.log"`, { stdio: 'pipe' })
1356
+ execSync(`nssm set ${serviceName} AppRotateFiles 1`, { stdio: 'pipe' })
1357
+ execSync(`nssm set ${serviceName} AppRotateSeconds 86400`, { stdio: 'pipe' })
1358
+ execSync(`nssm set ${serviceName} AppExit Default Restart`, { stdio: 'pipe' })
1359
+ execSync(`nssm set ${serviceName} AppRestartDelay 3000`, { stdio: 'pipe' })
1360
+ execSync(`nssm set ${serviceName} Description "Squeezr AI token compression proxy on port 8080"`, { stdio: 'pipe' })
1361
+ execSync(`nssm start ${serviceName}`, { stdio: 'pipe' })
1362
+ console.log(` [ok] Auto-start registered as Windows service via NSSM (auto-restart on crash)`)
1363
+ autoStartOk = true
1364
+ } catch (err) {
1365
+ const msg = err.stderr?.toString() || err.message || ''
1366
+ if (msg.includes('Access') || msg.includes('admin') || msg.includes('5')) {
1367
+ console.log(` [warn] NSSM requires admin — run as Administrator for service install`)
1368
+ } else {
1369
+ console.log(` [warn] NSSM install failed: ${msg.trim().split('\n')[0]}`)
1370
+ }
1371
+ }
1372
+ }
1373
+
1374
+ if (!autoStartOk) {
1375
+ // Fallback: Task Scheduler (no crash recovery, but works without admin)
1376
+ const taskName = 'Squeezr'
1377
+ const nodeArg = `${nodeExe} \`"${distIndex}\`"`
1378
+ const ps = [
1379
+ `$e = Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue`,
1380
+ `if ($e) { Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false }`,
1381
+ `$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-WindowStyle Hidden -NonInteractive -Command "${nodeArg}"' -WorkingDirectory '${ROOT}'`,
1382
+ `$t = New-ScheduledTaskTrigger -AtLogon`,
1383
+ `$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1)`,
1384
+ `Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Force | Out-Null`,
1385
+ ].join('; ')
1386
+ try {
1387
+ execSync(`powershell -NoProfile -Command "${ps}"`, { stdio: 'pipe' })
1388
+ console.log(` [ok] Auto-start registered in Task Scheduler (install NSSM for crash recovery)`)
1389
+ autoStartOk = true
1390
+ } catch {
1391
+ // ignore — will fall through to Startup folder VBS
1392
+ }
1393
+ }
1394
+
1395
+ if (!autoStartOk) {
1396
+ // Final fallback: VBS script in user Startup folder (no admin, no special tools)
1397
+ try {
1398
+ const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
1399
+ const squeezrCmd = path.join(appData, 'npm', 'squeezr.cmd')
1400
+ const startupDir = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
1401
+ const vbsPath = path.join(startupDir, 'squeezr-start.vbs')
1402
+ const cmdToRun = fs.existsSync(squeezrCmd) ? squeezrCmd : nodeExe
1403
+ const cmdArg = fs.existsSync(squeezrCmd) ? 'start' : `"${distIndex}"`
1404
+ const vbsContent = [
1405
+ 'Set WshShell = CreateObject("WScript.Shell")',
1406
+ `WshShell.Run """${cmdToRun}"" ${cmdArg}", 0, False`,
1407
+ '',
1408
+ ].join('\r\n')
1409
+ fs.mkdirSync(startupDir, { recursive: true })
1410
+ fs.writeFileSync(vbsPath, vbsContent)
1411
+ console.log(` [ok] Auto-start registered in Startup folder (${vbsPath})`)
1412
+ } catch (err) {
1413
+ console.log(` [warn] Auto-start failed — run as admin or install NSSM: https://nssm.cc`)
1414
+ }
1415
+ }
1416
+
1417
+ // 3. Start Squeezr right now as a detached background process (no window)
1418
+ // Logs go to ~/.squeezr/squeezr.log
1419
+ const logFile = path.join(logDir, 'squeezr.log')
1420
+ fs.mkdirSync(logDir, { recursive: true })
1421
+ const logFd = fs.openSync(logFile, 'a')
1422
+ const child = spawn(nodeExe, [distIndex], {
1423
+ detached: true,
1424
+ stdio: ['ignore', logFd, logFd],
1425
+ windowsHide: true,
1426
+ cwd: ROOT,
1427
+ })
1428
+ child.unref()
1429
+ fs.closeSync(logFd)
1430
+ console.log(` [ok] Squeezr started in background (pid ${child.pid})`)
1431
+ console.log(` [ok] Logs ${logFile}`)
1432
+
1433
+ // 4. Trust MITM CA in Windows Certificate Store (for Rust apps like Codex)
1434
+ // Node.js apps use NODE_EXTRA_CA_CERTS; Rust/native apps need the cert store.
1435
+ // The CA is generated on first proxy start — wait briefly for it to appear.
1436
+ const waitForCa = (retries = 10, interval = 500) => new Promise(resolve => {
1437
+ const check = (n) => {
1438
+ if (fs.existsSync(caPath)) return resolve(true)
1439
+ if (n <= 0) return resolve(false)
1440
+ setTimeout(() => check(n - 1), interval)
1441
+ }
1442
+ check(retries)
1443
+ })
1444
+
1445
+ waitForCa().then(found => {
1446
+ if (!found) {
1447
+ console.log(` [warn] MITM CA not found yet — run 'squeezr setup' again after first start`)
1448
+ printDone()
1449
+ return
1450
+ }
1451
+ // Try machine store (admin) first, fall back to user store (no admin)
1452
+ try {
1453
+ execSync(`certutil -addstore -f Root "${caPath}"`, { stdio: 'pipe' })
1454
+ console.log(` [ok] MITM CA trusted in Windows Certificate Store (machine-level)`)
1455
+ } catch {
1456
+ try {
1457
+ execSync(`certutil -addstore -user Root "${caPath}"`, { stdio: 'pipe' })
1458
+ console.log(` [ok] MITM CA trusted in Windows Certificate Store (user-level)`)
1459
+ } catch {
1460
+ console.log(` [warn] Could not trust MITM CA — trust manually:`)
1461
+ console.log(` certutil -addstore -user Root "${caPath}"`)
1462
+ }
1463
+ }
1464
+ printDone()
1465
+ })
1466
+
1467
+ function printDone() {
1468
+ console.log(`
1469
+ Done!
1470
+
1471
+ Squeezr is running on http://localhost:${port}
1472
+ MITM proxy on http://localhost:${mitmPort} (Codex CLI TLS interception)
1473
+
1474
+ Configured:
1475
+ Claude Code ANTHROPIC_BASE_URL=http://localhost:${port}
1476
+ Claude Desktop same — setx env var is visible to all GUI apps
1477
+ Codex Desktop ~/.codex/config.toml openai_base_url set
1478
+ Codex CLI HTTPS_PROXY=http://localhost:${mitmPort} codex (per-session)
1479
+ Aider / OpenCode ANTHROPIC_BASE_URL + openai_base_url set
1480
+ Gemini CLI GEMINI_API_BASE_URL=http://localhost:${port}
1481
+
1482
+ squeezr status — check it's running
1483
+ squeezr gain — see token savings
1484
+ `)
1485
+ }
1486
+ }
1487
+
1488
+ async function setupUnix() {
1489
+ const squeezrBin = process.argv[1]
1490
+ const nodeExe = process.execPath
1491
+ const platform = process.platform
1492
+
1493
+ console.log(`Setting up Squeezr for ${platform === 'darwin' ? 'macOS' : 'Linux'}...\n`)
1494
+
1495
+ // 1. Set env vars + auto-heal guard in shell profile
1496
+ const distIndex = path.join(ROOT, 'dist', 'index.js')
1497
+ const port = getPort()
1498
+ const mitmPort = getMitmPort(port)
1499
+ const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
1500
+ // The auto-heal validates that whatever answers on the configured port is
1501
+ // actually squeezr (by checking the magic `identity` field). A bare
1502
+ // `curl -sf .../squeezr/health` is NOT enough because curl returns success on
1503
+ // 3xx redirects, so a foreign service (e.g. an Apache+WordPress container on
1504
+ // 8080) would be mistaken for a healthy squeezr — and Claude Code would then
1505
+ // route its API requests into the wrong service, producing cryptic errors
1506
+ // like `undefined is not an object (evaluating '$.speed')`.
1507
+ const shellBlock = [
1508
+ `# squeezr env vars`,
1509
+ `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1510
+ `export openai_base_url=http://localhost:${port}`,
1511
+ `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1512
+ `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1513
+ `# NOTE: HTTPS_PROXY is intentionally NOT set globally — it would route ALL HTTPS`,
1514
+ `# (including Claude Code) through the MITM proxy and cause 502 errors.`,
1515
+ `# For Codex, set it per-session only: HTTPS_PROXY=http://localhost:${mitmPort} codex`,
1516
+ `# squeezr auto-heal: start proxy if not running (validates identity, not just HTTP 200)`,
1517
+ `_squeezr_alive() {`,
1518
+ ` curl -sf --max-time 2 "http://localhost:${port}/squeezr/health" 2>/dev/null | grep -q '"identity":"squeezr"'`,
1519
+ `}`,
1520
+ `if ! _squeezr_alive; then`,
1521
+ ` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
1522
+ ` disown`,
1523
+ `fi`,
1524
+ `unset -f _squeezr_alive`,
1525
+ ].join('\n')
1526
+ const marker = '# squeezr env vars'
1527
+
1528
+ // Env-only block (no auto-heal) for .profile — loaded by login shells
1529
+ // before .bashrc's "case $-" interactive guard
1530
+ const envOnlyBlock = [
1531
+ `# squeezr env vars`,
1532
+ `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1533
+ `export openai_base_url=http://localhost:${port}`,
1534
+ `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1535
+ `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1536
+ ].join('\n')
1537
+
1538
+ // Write env vars to ~/.profile (login shell — always loaded)
1539
+ const profilePath = path.join(os.homedir(), '.profile')
1540
+ try {
1541
+ const profileContent = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf-8') : ''
1542
+ if (!profileContent.includes(marker)) {
1543
+ fs.appendFileSync(profilePath, `\n${envOnlyBlock}\n`)
1544
+ console.log(` [ok] Env vars added to ${profilePath}`)
1545
+ } else {
1546
+ const updated = profileContent.replace(/# squeezr env vars[\s\S]*?(?=\n(?!export )|\n*$)/, envOnlyBlock)
1547
+ fs.writeFileSync(profilePath, updated)
1548
+ console.log(` [ok] Env vars updated in ${profilePath}`)
1549
+ }
1550
+ } catch {}
1551
+
1552
+ // Write full block (env + auto-heal) to interactive shell profile
1553
+ const profiles = [
1554
+ path.join(os.homedir(), '.zshrc'),
1555
+ path.join(os.homedir(), '.bashrc'),
1556
+ path.join(os.homedir(), '.bash_profile'),
1557
+ ]
1558
+ const profile = profiles.find(p => fs.existsSync(p)) ?? profiles[0]
1559
+ const existing = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : ''
1560
+ if (!existing.includes(marker)) {
1561
+ fs.appendFileSync(profile, `\n${shellBlock}\n`)
1562
+ console.log(` [ok] Env vars + auto-heal added to ${profile}`)
1563
+ } else {
1564
+ const updatedContent = existing.replace(
1565
+ /# squeezr env vars[\s\S]*?fi\n?/,
1566
+ shellBlock + '\n'
1567
+ )
1568
+ fs.writeFileSync(profile, updatedContent)
1569
+ console.log(` [ok] Env vars + auto-heal updated in ${profile}`)
1570
+ }
1571
+
1572
+ // 2. Configure Codex Desktop + Claude Desktop
1573
+ // Codex Desktop reads ~/.codex/config.toml (openai_base_url key).
1574
+ configureCodexDesktop(port)
1575
+
1576
+ // Register MCP server in Claude Desktop and Codex Desktop automatically
1577
+ await mcpInstall()
1578
+
1579
+ // Claude Desktop (GUI app) does not read shell env vars.
1580
+ // macOS: inject via a launchd env-setter plist (persists across reboots).
1581
+ // Linux: write to ~/.config/environment.d/ (systemd user env, read by GUI apps).
1582
+ if (platform === 'darwin') {
1583
+ const envPlistDir = path.join(os.homedir(), 'Library', 'LaunchAgents')
1584
+ const envPlistPath = path.join(envPlistDir, 'com.squeezr.env.plist')
1585
+ fs.mkdirSync(envPlistDir, { recursive: true })
1586
+ const envVars = [
1587
+ ['ANTHROPIC_BASE_URL', `http://localhost:${port}`],
1588
+ ['GEMINI_API_BASE_URL', `http://localhost:${port}`],
1589
+ ['NODE_EXTRA_CA_CERTS', bundlePath],
1590
+ ]
1591
+ const envSetCmds = envVars.map(([k, v]) => `launchctl setenv ${k} "${v}"`).join(' && ')
1592
+ fs.writeFileSync(envPlistPath, `<?xml version="1.0" encoding="UTF-8"?>
1593
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1594
+ <plist version="1.0">
1595
+ <dict>
1596
+ <key>Label</key><string>com.squeezr.env</string>
1597
+ <key>ProgramArguments</key>
1598
+ <array><string>/bin/sh</string><string>-c</string><string>${envSetCmds}</string></array>
1599
+ <key>RunAtLoad</key><true/>
1600
+ </dict>
1601
+ </plist>`)
1602
+ try {
1603
+ execSync(`launchctl unload "${envPlistPath}" 2>/dev/null; launchctl load -w "${envPlistPath}"`, { stdio: 'pipe' })
1604
+ console.log(` [ok] Claude Desktop: env vars set via launchctl (visible to all GUI apps)`)
1605
+ } catch {
1606
+ console.log(` [warn] Claude Desktop: launchctl env plist failed — restart Claude Desktop manually after setup`)
1607
+ }
1608
+ } else {
1609
+ // Linux: systemd user environment.d — read by all user processes incl. GUI apps
1610
+ const envDDir = path.join(os.homedir(), '.config', 'environment.d')
1611
+ const envDPath = path.join(envDDir, 'squeezr.conf')
1612
+ fs.mkdirSync(envDDir, { recursive: true })
1613
+ fs.writeFileSync(envDPath, [
1614
+ `# Squeezr — visible to all GUI apps (Claude Desktop, etc.)`,
1615
+ `ANTHROPIC_BASE_URL=http://localhost:${port}`,
1616
+ `GEMINI_API_BASE_URL=http://localhost:${port}`,
1617
+ `NODE_EXTRA_CA_CERTS=${bundlePath}`,
1618
+ ].join('\n') + '\n')
1619
+ console.log(` [ok] Claude Desktop: env vars written to ${envDPath} (effective after next login)`)
1620
+ }
1621
+
1622
+ // 3a. macOS launchd
1623
+ if (platform === 'darwin') {
1624
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents')
1625
+ const plistPath = path.join(plistDir, 'com.squeezr.plist')
1626
+ fs.mkdirSync(plistDir, { recursive: true })
1627
+ fs.writeFileSync(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
1628
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1629
+ <plist version="1.0">
1630
+ <dict>
1631
+ <key>Label</key><string>com.squeezr</string>
1632
+ <key>ProgramArguments</key>
1633
+ <array><string>${nodeExe}</string><string>${squeezrBin}</string></array>
1634
+ <key>RunAtLoad</key><true/>
1635
+ <key>KeepAlive</key><true/>
1636
+ <key>StandardOutPath</key><string>${os.homedir()}/.squeezr/squeezr.log</string>
1637
+ <key>StandardErrorPath</key><string>${os.homedir()}/.squeezr/squeezr.log</string>
1638
+ </dict>
1639
+ </plist>`)
1640
+ try {
1641
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null; launchctl load "${plistPath}"`, { stdio: 'pipe' })
1642
+ console.log(` [ok] Auto-start registered via launchd`)
1643
+ console.log(` [ok] Squeezr started now`)
1644
+ } catch {
1645
+ console.log(` [warn] launchctl failed — starting in background`)
1646
+ spawn(nodeExe, [squeezrBin], { detached: true, stdio: 'ignore' }).unref()
1647
+ }
1648
+
1649
+ // Trust MITM CA in macOS Keychain (for Codex TLS interception)
1650
+ // CA is generated on first proxy start wait briefly for it to appear
1651
+ const caPath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'ca.crt')
1652
+ const waitForCa = (retries = 10, interval = 500) => new Promise(resolve => {
1653
+ const check = (n) => {
1654
+ if (fs.existsSync(caPath)) return resolve(true)
1655
+ if (n <= 0) return resolve(false)
1656
+ setTimeout(() => check(n - 1), interval)
1657
+ }
1658
+ check(retries)
1659
+ })
1660
+ waitForCa().then(found => {
1661
+ if (found) {
1662
+ try {
1663
+ execSync(`security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${caPath}" 2>/dev/null`, { stdio: 'pipe' })
1664
+ console.log(` [ok] MITM CA trusted in macOS Keychain`)
1665
+ } catch {
1666
+ console.log(` [info] To trust MITM CA for Codex: security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${caPath}"`)
1667
+ }
1668
+ }
1669
+ })
1670
+
1671
+ // 3b. Linux systemd
1672
+ } else {
1673
+ const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user')
1674
+ const servicePath = path.join(serviceDir, 'squeezr.service')
1675
+ fs.mkdirSync(serviceDir, { recursive: true })
1676
+ fs.writeFileSync(servicePath, `[Unit]
1677
+ Description=Squeezr AI proxy
1678
+ After=network.target
1679
+
1680
+ [Service]
1681
+ ExecStart=${nodeExe} ${squeezrBin}
1682
+ Restart=always
1683
+ RestartSec=5
1684
+
1685
+ [Install]
1686
+ WantedBy=default.target
1687
+ `)
1688
+ try {
1689
+ execSync('systemctl --user daemon-reload && systemctl --user enable --now squeezr', { stdio: 'pipe' })
1690
+ console.log(` [ok] Auto-start registered via systemd`)
1691
+ console.log(` [ok] Squeezr started now`)
1692
+ } catch {
1693
+ console.log(` [warn] systemctl failed — starting in background`)
1694
+ spawn(nodeExe, [squeezrBin], { detached: true, stdio: 'ignore' }).unref()
1695
+ }
1696
+ }
1697
+
1698
+ console.log(`
1699
+ Done!
1700
+
1701
+ Squeezr is running on http://localhost:${port}
1702
+
1703
+ Configured:
1704
+ Claude Code ANTHROPIC_BASE_URL=http://localhost:${port}
1705
+ Claude Desktop ${platform === 'darwin' ? 'env vars set via launchctl (restart app once)' : 'env vars in ~/.config/environment.d/ (re-login to activate)'}
1706
+ Codex Desktop ~/.codex/config.toml openai_base_url set
1707
+ Codex CLI HTTPS_PROXY=http://localhost:${mitmPort} codex (per-session)
1708
+ Aider / OpenCode ANTHROPIC_BASE_URL + openai_base_url set
1709
+ Gemini CLI GEMINI_API_BASE_URL=http://localhost:${port}
1710
+
1711
+ Run: source ${profile} (or open a new terminal)
1712
+ squeezr status — check it's running
1713
+ squeezr gain — see token savings
1714
+ `)
1715
+ installShellWrapper()
1716
+ }
1717
+
1718
+ // ── WSL2 detection ───────────────────────────────────────────────────────────
1719
+
1720
+ function isWSL() {
1721
+ try {
1722
+ const release = fs.readFileSync('/proc/version', 'utf-8')
1723
+ return /microsoft|wsl/i.test(release)
1724
+ } catch {
1725
+ return false
1726
+ }
1727
+ }
1728
+
1729
+ // ── squeezr setup — WSL2 ────────────────────────────────────────────────────
1730
+
1731
+ async function setupWSL() {
1732
+ const nodeExe = process.execPath
1733
+ const distIndex = path.join(ROOT, 'dist', 'index.js')
1734
+
1735
+ console.log('Setting up Squeezr for WSL2...\n')
1736
+
1737
+ // 1. Set env vars + auto-heal guard in WSL shell profile (.bashrc / .zshrc)
1738
+ // The guard checks if the proxy is alive on terminal open. If not, it starts
1739
+ // it in the background. This is the safety net for WSL2 where systemd and
1740
+ // Task Scheduler may both fail.
1741
+ const port = getPort()
1742
+ const mitmPort = getMitmPort(port)
1743
+ const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
1744
+ const shellBlock = [
1745
+ `# squeezr env vars`,
1746
+ `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1747
+ `export openai_base_url=http://localhost:${port}`,
1748
+ `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1749
+ `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1750
+ `# NOTE: HTTPS_PROXY is intentionally NOT set globally set per-session for Codex only:`,
1751
+ `# HTTPS_PROXY=http://localhost:${mitmPort} codex`,
1752
+ `# squeezr auto-heal: start proxy if not running (validates identity, not just HTTP 200)`,
1753
+ `_squeezr_alive() {`,
1754
+ ` curl -sf --max-time 2 "http://localhost:${port}/squeezr/health" 2>/dev/null | grep -q '"identity":"squeezr"'`,
1755
+ `}`,
1756
+ `if ! _squeezr_alive; then`,
1757
+ ` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
1758
+ ` disown`,
1759
+ `fi`,
1760
+ `unset -f _squeezr_alive`,
1761
+ ].join('\n')
1762
+ const marker = '# squeezr env vars'
1763
+
1764
+ // Env-only block for .profile (loaded before .bashrc's interactive guard)
1765
+ const envOnlyBlock = [
1766
+ `# squeezr env vars`,
1767
+ `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
1768
+ `export openai_base_url=http://localhost:${port}`,
1769
+ `export GEMINI_API_BASE_URL=http://localhost:${port}`,
1770
+ `export NODE_EXTRA_CA_CERTS=${bundlePath}`,
1771
+ ].join('\n')
1772
+
1773
+ const profilePath = path.join(os.homedir(), '.profile')
1774
+ try {
1775
+ const profileContent = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf-8') : ''
1776
+ if (!profileContent.includes(marker)) {
1777
+ fs.appendFileSync(profilePath, `\n${envOnlyBlock}\n`)
1778
+ console.log(` [ok] Env vars added to ${profilePath}`)
1779
+ } else {
1780
+ const updated = profileContent.replace(/# squeezr env vars[\s\S]*?(?=\n(?!export )|\n*$)/, envOnlyBlock)
1781
+ fs.writeFileSync(profilePath, updated)
1782
+ console.log(` [ok] Env vars updated in ${profilePath}`)
1783
+ }
1784
+ } catch {}
1785
+
1786
+ // Full block (env + auto-heal) in interactive shell profile
1787
+ const profiles = [
1788
+ path.join(os.homedir(), '.zshrc'),
1789
+ path.join(os.homedir(), '.bashrc'),
1790
+ path.join(os.homedir(), '.bash_profile'),
1791
+ ]
1792
+ const profile = profiles.find(p => fs.existsSync(p)) ?? profiles[1]
1793
+ const existing = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : ''
1794
+ if (!existing.includes(marker)) {
1795
+ fs.appendFileSync(profile, `\n${shellBlock}\n`)
1796
+ console.log(` [ok] Env vars + auto-heal added to ${profile}`)
1797
+ } else {
1798
+ const updatedContent = existing.replace(
1799
+ /# squeezr env vars[\s\S]*?fi\n?/,
1800
+ shellBlock + '\n'
1801
+ )
1802
+ fs.writeFileSync(profile, updatedContent)
1803
+ console.log(` [ok] Env vars + auto-heal updated in ${profile}`)
1804
+ }
1805
+
1806
+ // 2. Set Windows env vars via setx.exe (so Windows-launched CLIs see them)
1807
+ // ANTHROPIC_BASE_URL via setx is also visible to Claude Desktop (GUI app).
1808
+ const setxExe = '/mnt/c/Windows/System32/setx.exe'
1809
+ const winVars = {
1810
+ ANTHROPIC_BASE_URL: 'http://localhost:8080',
1811
+ openai_base_url: 'http://localhost:8080',
1812
+ GEMINI_API_BASE_URL: 'http://localhost:8080',
1813
+ }
1814
+ if (fs.existsSync(setxExe)) {
1815
+ for (const [key, value] of Object.entries(winVars)) {
1816
+ try {
1817
+ execSync(`"${setxExe}" ${key} "${value}"`, { stdio: 'pipe' })
1818
+ console.log(` [ok] Windows env: ${key}=${value}`)
1819
+ } catch {
1820
+ console.log(` [skip] Windows env: ${key} could not be set`)
1821
+ }
1822
+ }
1823
+ } else {
1824
+ console.log(' [skip] setx.exe not found — Windows env vars not set')
1825
+ }
1826
+
1827
+ // 3. Configure Codex Desktop + MCP
1828
+ // WSL-side ~/.codex/config.toml (for Codex Desktop running in WSL)
1829
+ configureCodexDesktop(port)
1830
+
1831
+ // Register MCP server in Claude Desktop and Codex Desktop automatically
1832
+ await mcpInstall()
1833
+ // Windows-side %USERPROFILE%\.codex\config.toml (for Codex Desktop on Windows)
1834
+ try {
1835
+ const winHome = execSync('cmd.exe /c echo %USERPROFILE%', { stdio: 'pipe' }).toString().trim().replace(/\r/g, '')
1836
+ const winCodexDir = winHome + '\\.codex'
1837
+ const winMountedDir = winCodexDir.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`)
1838
+ const winMountedToml = winMountedDir + '/config.toml'
1839
+ const winUrl = `http://localhost:${port}/v1`
1840
+ const winLine = `openai_base_url = "${winUrl}"`
1841
+ fs.mkdirSync(winMountedDir, { recursive: true })
1842
+ if (fs.existsSync(winMountedToml)) {
1843
+ let content = fs.readFileSync(winMountedToml, 'utf-8')
1844
+ if (content.includes('openai_base_url')) {
1845
+ content = content.replace(/openai_base_url\s*=\s*"[^"]*"/, winLine)
1846
+ fs.writeFileSync(winMountedToml, content)
1847
+ } else {
1848
+ fs.appendFileSync(winMountedToml, `\n# Squeezr\n${winLine}\n`)
1849
+ }
1850
+ } else {
1851
+ fs.writeFileSync(winMountedToml, `# Squeezr\n${winLine}\n`)
1852
+ }
1853
+ console.log(` [ok] Codex Desktop (Windows): ${winCodexDir}\\config.toml`)
1854
+ } catch {
1855
+ console.log(` [skip] Codex Desktop (Windows): could not write config`)
1856
+ }
1857
+
1858
+ // 3. Auto-start: try systemd first (WSL2 with systemd enabled), fallback to
1859
+ // Windows Task Scheduler, then plain background process
1860
+ let autoStartDone = false
1861
+
1862
+ // 3a. Try systemd (works on newer WSL2 with [boot] systemd=true in wsl.conf)
1863
+ try {
1864
+ const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user')
1865
+ fs.mkdirSync(serviceDir, { recursive: true })
1866
+ const servicePath = path.join(serviceDir, 'squeezr.service')
1867
+ fs.writeFileSync(servicePath, `[Unit]
1868
+ Description=Squeezr AI proxy
1869
+ After=network.target
1870
+
1871
+ [Service]
1872
+ ExecStart=${nodeExe} ${distIndex}
1873
+ Restart=always
1874
+ RestartSec=5
1875
+ WorkingDirectory=${ROOT}
1876
+
1877
+ [Install]
1878
+ WantedBy=default.target
1879
+ `)
1880
+ execSync('systemctl --user daemon-reload && systemctl --user enable --now squeezr', { stdio: 'pipe' })
1881
+ console.log(' [ok] Auto-start registered via systemd')
1882
+ autoStartDone = true
1883
+ } catch {
1884
+ // systemd not available — try Windows Task Scheduler
1885
+ }
1886
+
1887
+ // 3b. Fallback: Windows Task Scheduler via powershell.exe
1888
+ if (!autoStartDone) {
1889
+ const winNodeExe = execSync('wslpath -w "$(which node)"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1890
+ const winDistIndex = execSync(`wslpath -w "${distIndex}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1891
+ const winRoot = execSync(`wslpath -w "${ROOT}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1892
+ const taskName = 'Squeezr'
1893
+ const ps = [
1894
+ `$e = Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue`,
1895
+ `if ($e) { Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false }`,
1896
+ `$a = New-ScheduledTaskAction -Execute 'wsl.exe' -Argument '-d ${os.hostname()} -- ${nodeExe} ${distIndex}' -WorkingDirectory '${winRoot}'`,
1897
+ `$t = New-ScheduledTaskTrigger -AtLogon`,
1898
+ `$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1)`,
1899
+ `Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Force | Out-Null`,
1900
+ ].join('; ')
1901
+
1902
+ try {
1903
+ execSync(`powershell.exe -NoProfile -Command "${ps}"`, { stdio: 'pipe' })
1904
+ console.log(' [ok] Auto-start registered via Windows Task Scheduler')
1905
+ autoStartDone = true
1906
+ } catch {
1907
+ console.log(' [warn] Task Scheduler failed — run PowerShell as admin for auto-start')
1908
+ }
1909
+ }
1910
+
1911
+ // 4. Start proxy now as a detached background process
1912
+ const logDir = path.join(os.homedir(), '.squeezr')
1913
+ const logFile = path.join(logDir, 'squeezr.log')
1914
+ fs.mkdirSync(logDir, { recursive: true })
1915
+ const logFd = fs.openSync(logFile, 'a')
1916
+ const child = spawn(nodeExe, [distIndex], {
1917
+ detached: true,
1918
+ stdio: ['ignore', logFd, logFd],
1919
+ cwd: ROOT,
1920
+ })
1921
+ child.unref()
1922
+ fs.closeSync(logFd)
1923
+ console.log(` [ok] Squeezr started in background (pid ${child.pid})`)
1924
+ console.log(` [ok] Logs ${logFile}`)
1925
+
1926
+ const setupPort = getPort()
1927
+ const setupMitmPort = getMitmPort(setupPort)
1928
+ console.log(`
1929
+ Done!
1930
+
1931
+ Squeezr is running on http://localhost:${setupPort}
1932
+
1933
+ Configured:
1934
+ Claude Code ANTHROPIC_BASE_URL=http://localhost:${setupPort}
1935
+ Claude Desktop Windows setx env var set (restart app once to pick it up)
1936
+ Codex Desktop ~/.codex/config.toml + Windows %USERPROFILE%\\.codex\\config.toml
1937
+ Codex CLI HTTPS_PROXY=http://localhost:${setupMitmPort} codex (per-session)
1938
+ Aider / OpenCode ANTHROPIC_BASE_URL + openai_base_url set
1939
+ Gemini CLI GEMINI_API_BASE_URL=http://localhost:${setupPort}
1940
+
1941
+ Windows env vars are set (effective in new terminals immediately).
1942
+ WSL env vars added to ${profile}.
1943
+
1944
+ squeezr status — check it's running
1945
+ squeezr gain — see token savings
1946
+ `)
1947
+ installShellWrapper()
1948
+ }
1949
+
1950
+ // ── squeezr tunnel ────────────────────────────────────────────────────────────
1951
+ // Exposes the local proxy via a Cloudflare Quick Tunnel (free, no account needed).
1952
+ // Cursor IDE requires a public HTTPS URL because its servers call the endpoint
1953
+ // from Cloudflare's infrastructure — localhost is unreachable from there.
1954
+
1955
+ async function startTunnel() {
1956
+ const port = getPort()
1957
+
1958
+ // Verify proxy is running first
1959
+ const running = await new Promise(resolve => {
1960
+ const req = http.get(`http://localhost:${port}/squeezr/health`, res => {
1961
+ resolve(res.statusCode === 200)
1962
+ })
1963
+ req.on('error', () => resolve(false))
1964
+ req.setTimeout(2000, () => { req.destroy(); resolve(false) })
1965
+ })
1966
+
1967
+ if (!running) {
1968
+ console.error(`Squeezr proxy is not running on port ${port}.`)
1969
+ console.error(`Start it first: squeezr start`)
1970
+ process.exit(1)
1971
+ }
1972
+
1973
+ console.log(`Starting Cloudflare Quick Tunnel for http://localhost:${port}...`)
1974
+ console.log(`(free, no account needed powered by trycloudflare.com)\n`)
1975
+
1976
+ // Try cloudflared binary, fall back to npx
1977
+ let tunnelCmd, tunnelArgs
1978
+ try {
1979
+ execSync('cloudflared --version', { stdio: 'pipe' })
1980
+ tunnelCmd = 'cloudflared'
1981
+ tunnelArgs = ['tunnel', '--url', `http://localhost:${port}`]
1982
+ } catch {
1983
+ tunnelCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'
1984
+ tunnelArgs = ['cloudflared@latest', 'tunnel', '--url', `http://localhost:${port}`]
1985
+ console.log(`cloudflared not installed — using npx cloudflared (may take a moment to download)\n`)
1986
+ }
1987
+
1988
+ let tunnelUrl = null
1989
+ const child = spawn(tunnelCmd, tunnelArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
1990
+
1991
+ const printInstructions = (url) => {
1992
+ console.log(`\n ╔══════════════════════════════════════════════════════════════════╗`)
1993
+ console.log(` ║ Tunnel active: ${url.padEnd(49)}║`)
1994
+ console.log(` ╠══════════════════════════════════════════════════════════════════╣`)
1995
+ console.log(` ║ CURSOR SETUP ║`)
1996
+ console.log(` ║ ║`)
1997
+ console.log(` ║ 1. Cursor Settings Models ║`)
1998
+ console.log(` ║ 2. Add your OpenAI or Anthropic API key ║`)
1999
+ console.log(` ║ 3. Enable "Override OpenAI Base URL" ║`)
2000
+ console.log(` ║ 4. Set URL to: ${(url + '/v1').padEnd(49)}║`)
2001
+ console.log(` ║ 5. Disable all built-in Cursor models ║`)
2002
+ console.log(` ║ 6. Add a custom model pointing to the same URL ║`)
2003
+ console.log(` ║ ║`)
2004
+ console.log(` ║ CONTINUE EXTENSION (VS Code / JetBrains) ║`)
2005
+ console.log(` ║ No tunnel needed use http://localhost:${port} directly ${' '.repeat(Math.max(0, 17 - String(port).length))}║`)
2006
+ console.log(` ║ ║`)
2007
+ console.log(` ║ Press Ctrl+C to stop the tunnel ║`)
2008
+ console.log(` ╚══════════════════════════════════════════════════════════════════╝\n`)
2009
+ }
2010
+
2011
+ const parseUrl = (line) => {
2012
+ const m = line.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)
2013
+ return m ? m[0] : null
2014
+ }
2015
+
2016
+ child.stdout.on('data', (chunk) => {
2017
+ const text = chunk.toString()
2018
+ if (!tunnelUrl) {
2019
+ const found = parseUrl(text)
2020
+ if (found) { tunnelUrl = found; printInstructions(tunnelUrl) }
2021
+ }
2022
+ process.stdout.write(text)
2023
+ })
2024
+
2025
+ child.stderr.on('data', (chunk) => {
2026
+ const text = chunk.toString()
2027
+ if (!tunnelUrl) {
2028
+ const found = parseUrl(text)
2029
+ if (found) { tunnelUrl = found; printInstructions(tunnelUrl) }
2030
+ }
2031
+ // Only show cloudflared logs if no URL yet (suppress verbose after)
2032
+ if (!tunnelUrl) process.stderr.write(text)
2033
+ })
2034
+
2035
+ child.on('error', (err) => {
2036
+ if (err.code === 'ENOENT') {
2037
+ console.error(`Could not start tunnel. Install cloudflared: https://developers.cloudflare.com/cloudflared/downloads`)
2038
+ } else {
2039
+ console.error(`Tunnel error: ${err.message}`)
2040
+ }
2041
+ process.exit(1)
2042
+ })
2043
+
2044
+ child.on('exit', (code) => {
2045
+ if (code !== 0) console.log(`\nTunnel stopped (exit ${code})`)
2046
+ process.exit(0)
2047
+ })
2048
+
2049
+ process.on('SIGINT', () => { child.kill(); process.exit(0) })
2050
+ process.on('SIGTERM', () => { child.kill(); process.exit(0) })
2051
+ }
2052
+
2053
+ // ── squeezr zest — guided install wizard ─────────────────────────────────────
2054
+
2055
+ async function installZest() {
2056
+ const { createInterface } = await import('readline')
2057
+ const { get } = await import('https')
2058
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
2059
+ const ask = (q) => new Promise(res => rl.question(q, res))
2060
+ const ZEST_DIR = path.join(os.homedir(), '.squeezr', 'zest')
2061
+ const GGUF_PATH = path.join(ZEST_DIR, 'zest-Q4_K_M.gguf')
2062
+ const MODELFILE_PATH = path.join(ZEST_DIR, 'Modelfile.zest')
2063
+ // GGUF download URL — hosted on HuggingFace (replace with actual URL when published)
2064
+ const GGUF_URL = 'https://huggingface.co/ramosvs/zest/resolve/main/zest-Q4_K_M.gguf'
2065
+ const COMPRESS_SYSTEM = 'You are compressing a coding tool output to save tokens. Extract ONLY what is essential: errors, file paths, function names, test failures, key values, warnings. Be extremely concise, target under 150 tokens. Output only the compressed content, nothing else.'
2066
+
2067
+ console.log('\n╔══════════════════════════════════════════════════════╗')
2068
+ console.log('║ Zest Local AI Compression Model for Squeezr ║')
2069
+ console.log('╚══════════════════════════════════════════════════════╝')
2070
+ console.log('\nZest is a fine-tuned 0.8B model that compresses coding tool')
2071
+ console.log('outputs locally zero cost, zero API calls, zero latency added.')
2072
+ console.log('Runs via Ollama on your machine.\n')
2073
+
2074
+ // ── Step 1: Check / install Ollama + verify compatible version ────────────
2075
+ // Zest is based on Qwen3.5 which requires Ollama ≥ 0.6.0 (added qwen3.5 arch)
2076
+ const OLLAMA_MIN_MAJOR = 0
2077
+ const OLLAMA_MIN_MINOR = 6
2078
+ console.log('[ 1 / 4 ] Checking Ollama...')
2079
+ let ollamaOk = false
2080
+ let ollamaVersion = null
2081
+ try {
2082
+ const vOut = execSync('ollama --version', { stdio: 'pipe', encoding: 'utf-8' })
2083
+ const m = vOut.match(/(\d+)\.(\d+)\.(\d+)/)
2084
+ if (m) {
2085
+ ollamaVersion = `${m[1]}.${m[2]}.${m[3]}`
2086
+ const major = parseInt(m[1]), minor = parseInt(m[2])
2087
+ ollamaOk = major > OLLAMA_MIN_MAJOR || (major === OLLAMA_MIN_MAJOR && minor >= OLLAMA_MIN_MINOR)
2088
+ if (!ollamaOk) {
2089
+ console.log(`\n Ollama ${ollamaVersion} is too old. Zest requires Ollama ${OLLAMA_MIN_MAJOR}.${OLLAMA_MIN_MINOR}.0`)
2090
+ console.log(' (Qwen3.5 architecture support was added in 0.6.0)\n')
2091
+ const ans = await ask(' Update Ollama now? [Y/n] ')
2092
+ if (ans.toLowerCase() === 'n') {
2093
+ console.log('\n Download the latest from https://ollama.com/download then run: squeezr zest\n')
2094
+ rl.close(); return
2095
+ }
2096
+ // Update Ollama
2097
+ if (process.platform === 'win32') {
2098
+ console.log('\n Downloading latest Ollama installer...')
2099
+ const installerPath = path.join(os.tmpdir(), 'OllamaSetup.exe')
2100
+ await new Promise((resolve, reject) => {
2101
+ const file = require('fs').createWriteStream(installerPath)
2102
+ require('https').get('https://ollama.com/download/OllamaSetup.exe', { headers: { 'User-Agent': 'squeezr-zest-installer' } }, res => {
2103
+ if (res.statusCode === 302 || res.statusCode === 301) {
2104
+ require('https').get(res.headers.location, { headers: { 'User-Agent': 'squeezr-zest-installer' } }, res2 => { res2.pipe(file); file.on('finish', resolve) }).on('error', reject)
2105
+ } else { res.pipe(file); file.on('finish', resolve) }
2106
+ }).on('error', reject)
2107
+ })
2108
+ console.log(' Installing (may show a UAC prompt)...')
2109
+ try {
2110
+ execSync(`"${installerPath}" /S`, { stdio: 'inherit' })
2111
+ ollamaOk = true
2112
+ console.log(' Ollama updated')
2113
+ } catch {
2114
+ console.log('\n Automatic update failed. Download manually: https://ollama.com/download/windows')
2115
+ const wait = await ask(' Press Enter once Ollama is updated... ')
2116
+ }
2117
+ } else {
2118
+ console.log(' → Updating via official script...')
2119
+ try { execSync('curl -fsSL https://ollama.com/install.sh | sh', { stdio: 'inherit' }); ollamaOk = true } catch {}
2120
+ }
2121
+ // Re-check version
2122
+ try {
2123
+ const vOut2 = execSync('ollama --version', { stdio: 'pipe', encoding: 'utf-8' })
2124
+ const m2 = vOut2.match(/(\d+)\.(\d+)/)
2125
+ if (m2) {
2126
+ ollamaOk = parseInt(m2[1]) > OLLAMA_MIN_MAJOR || (parseInt(m2[1]) === OLLAMA_MIN_MAJOR && parseInt(m2[2]) >= OLLAMA_MIN_MINOR)
2127
+ }
2128
+ } catch {}
2129
+ if (!ollamaOk) {
2130
+ console.log('\n Please update Ollama manually from https://ollama.com/download then run: squeezr zest\n')
2131
+ rl.close(); return
2132
+ }
2133
+ }
2134
+ } else {
2135
+ ollamaOk = true // version string not parseable — assume ok
2136
+ }
2137
+ } catch {}
2138
+
2139
+ if (!ollamaOk && !ollamaVersion) {
2140
+ console.log('\n Ollama is not installed.')
2141
+ console.log(' Ollama is a free, open-source local model runner.\n')
2142
+ const ans = await ask(' Install Ollama now? [Y/n] ')
2143
+ if (ans.toLowerCase() === 'n') {
2144
+ console.log('\n Skipping. Install Ollama manually from https://ollama.com/download')
2145
+ console.log(' Then run: squeezr zest\n')
2146
+ rl.close(); return
2147
+ }
2148
+ console.log('\n Installing Ollama...')
2149
+ if (process.platform === 'win32') {
2150
+ // winget first, fallback to direct download guidance
2151
+ try {
2152
+ execSync('winget install -e --id Ollama.Ollama --accept-package-agreements --accept-source-agreements', { stdio: 'inherit' })
2153
+ ollamaOk = true
2154
+ } catch {
2155
+ console.log('\n winget install failed. Please download Ollama manually:')
2156
+ console.log(' https://ollama.com/download/windows')
2157
+ const wait = await ask('\n Press Enter once Ollama is installed... ')
2158
+ try { execSync('ollama --version', { stdio: 'pipe' }); ollamaOk = true } catch {}
2159
+ }
2160
+ } else if (process.platform === 'darwin') {
2161
+ console.log(' brew install ollama')
2162
+ try { execSync('brew install ollama', { stdio: 'inherit' }); ollamaOk = true } catch {}
2163
+ } else {
2164
+ // Linux
2165
+ try {
2166
+ execSync('curl -fsSL https://ollama.com/install.sh | sh', { stdio: 'inherit' })
2167
+ ollamaOk = true
2168
+ } catch {}
2169
+ }
2170
+ if (!ollamaOk) {
2171
+ console.log('\n Could not install Ollama automatically.')
2172
+ console.log(' Install manually from https://ollama.com/download then run: squeezr zest\n')
2173
+ rl.close(); return
2174
+ }
2175
+ // Start ollama service
2176
+ try { execSync('ollama serve &', { stdio: 'pipe', shell: true }) } catch {}
2177
+ await new Promise(r => setTimeout(r, 2000))
2178
+ }
2179
+ try { execSync('ollama --version', { stdio: 'pipe' }) } catch {
2180
+ console.log('\n Ollama installed but not in PATH. Open a new terminal and run: squeezr zest\n')
2181
+ rl.close(); return
2182
+ }
2183
+ console.log(' ✓ Ollama ready')
2184
+
2185
+ // ── Step 2: Check if zest model already exists in Ollama ──────────────────
2186
+ console.log('\n[ 2 / 4 ] Checking Zest model in Ollama...')
2187
+ let modelExists = false
2188
+ try {
2189
+ const list = execSync('ollama list', { stdio: 'pipe', encoding: 'utf-8' })
2190
+ modelExists = list.includes('zest')
2191
+ } catch {}
2192
+
2193
+ if (modelExists) {
2194
+ console.log(' ✓ Zest model already installed in Ollama')
2195
+ } else {
2196
+ // Download GGUF if not present
2197
+ if (!fs.existsSync(GGUF_PATH)) {
2198
+ console.log(`\n Downloading Zest model (~500 MB)...`)
2199
+ console.log(` From: ${GGUF_URL}`)
2200
+ console.log(` To: ${GGUF_PATH}\n`)
2201
+ fs.mkdirSync(ZEST_DIR, { recursive: true })
2202
+ // Stream download with progress
2203
+ await new Promise((resolve, reject) => {
2204
+ const file = fs.createWriteStream(GGUF_PATH)
2205
+ const request = (url, redirects = 0) => {
2206
+ if (redirects > 5) return reject(new Error('Too many redirects'))
2207
+ const urlObj = new URL(url)
2208
+ const mod = urlObj.protocol === 'https:' ? require('https') : require('http')
2209
+ mod.get(url, { headers: { 'User-Agent': 'squeezr-zest-installer' } }, res => {
2210
+ if (res.statusCode === 301 || res.statusCode === 302) {
2211
+ return request(res.headers.location, redirects + 1)
2212
+ }
2213
+ if (res.statusCode !== 200) {
2214
+ file.close()
2215
+ fs.unlinkSync(GGUF_PATH)
2216
+ return reject(new Error(`HTTP ${res.statusCode}`))
2217
+ }
2218
+ const total = parseInt(res.headers['content-length'] || '0')
2219
+ let downloaded = 0
2220
+ res.on('data', chunk => {
2221
+ downloaded += chunk.length
2222
+ if (total > 0) {
2223
+ const pct = Math.round(downloaded / total * 100)
2224
+ process.stdout.write(`\r Downloading... ${pct}% (${Math.round(downloaded/1024/1024)}/${Math.round(total/1024/1024)} MB)`)
2225
+ }
2226
+ })
2227
+ res.pipe(file)
2228
+ file.on('finish', () => { file.close(); console.log('\n ✓ Download complete'); resolve() })
2229
+ }).on('error', err => { file.close(); fs.unlinkSync(GGUF_PATH); reject(err) })
2230
+ }
2231
+ request(GGUF_URL)
2232
+ }).catch(err => {
2233
+ console.log(`\n Download failed: ${err.message}`)
2234
+ console.log(' The model will be available at https://huggingface.co/ramosvs/zest')
2235
+ console.log(' Download the GGUF manually and place it at:')
2236
+ console.log(` ${GGUF_PATH}`)
2237
+ console.log(' Then run: squeezr zest\n')
2238
+ rl.close(); process.exit(1)
2239
+ })
2240
+ } else {
2241
+ console.log(' GGUF already downloaded')
2242
+ }
2243
+ // Write Modelfile
2244
+ const modelfileContent = `FROM ${GGUF_PATH}\nSYSTEM """${COMPRESS_SYSTEM}"""\nPARAMETER temperature 0\nPARAMETER top_p 1\nPARAMETER top_k 1\nPARAMETER num_predict 300\nPARAMETER num_ctx 2048\n`
2245
+ fs.writeFileSync(MODELFILE_PATH, modelfileContent)
2246
+ // Create Ollama model
2247
+ console.log('\n Creating Zest model in Ollama...')
2248
+ try {
2249
+ execSync(`ollama create zest -f "${MODELFILE_PATH}"`, { stdio: 'inherit' })
2250
+ console.log(' ✓ Zest model created')
2251
+ } catch (e) {
2252
+ console.log(`\n ✗ Failed to create Ollama model: ${e.message}`)
2253
+ rl.close(); return
2254
+ }
2255
+ }
2256
+
2257
+ // ── Step 3: Smoke test ────────────────────────────────────────────────────
2258
+ console.log('\n[ 3 / 4 ] Smoke test...')
2259
+ const testInput = 'npm warn deprecated inflight@1.0.6: not supported, leaks memory. added 847 packages in 14s. 3 vulnerabilities (1 moderate, 2 high).'
2260
+ let testOutput = ''
2261
+ try {
2262
+ testOutput = execSync(`ollama run zest "${testInput}"`, { stdio: 'pipe', encoding: 'utf-8', timeout: 30000 }).trim()
2263
+ const ratio = Math.round((1 - testOutput.length / testInput.length) * 100)
2264
+ console.log(`\n Input (${testInput.length} chars): ${testInput}`)
2265
+ console.log(` Output (${testOutput.length} chars): ${testOutput}`)
2266
+ if (ratio > 0) {
2267
+ console.log(`\n ✓ Compression working — ${ratio}% savings on test input`)
2268
+ } else {
2269
+ console.log(`\n ⚠ Output is larger than input on this test — this is normal for very short inputs`)
2270
+ console.log(' Zest shines on real tool outputs (≥1500 chars). Short test inputs may expand.')
2271
+ }
2272
+ } catch (e) {
2273
+ console.log(`\n ✗ Smoke test failed: ${e.message}`)
2274
+ console.log(' The model may still work. Continuing setup.')
2275
+ }
2276
+
2277
+ // ── Step 4: Configure Squeezr ─────────────────────────────────────────────
2278
+ console.log('\n[ 4 / 4 ] Configuring Squeezr...')
2279
+ const userToml = path.join(os.homedir(), '.squeezr', 'squeezr.toml')
2280
+ let tomlContent = ''
2281
+ try { tomlContent = fs.readFileSync(userToml, 'utf-8') } catch {}
2282
+
2283
+ const alreadyConfigured = tomlContent.includes('compression_model') && tomlContent.includes('zest')
2284
+ if (alreadyConfigured) {
2285
+ console.log(' ✓ Squeezr already configured to use Zest')
2286
+ } else {
2287
+ const ans = await ask('\n Configure Squeezr to use Zest as AI compression backend? [Y/n] ')
2288
+ if (ans.toLowerCase() !== 'n') {
2289
+ // Add [local] section and enable ai_compression
2290
+ let newToml = tomlContent
2291
+ if (!newToml.includes('[local]')) {
2292
+ newToml += '\n[local]\nenabled = true\nupstream_url = "http://localhost:11434"\ncompression_model = "zest"\n'
2293
+ }
2294
+ if (!newToml.includes('ai_compression')) {
2295
+ // Add under [compression] or create section
2296
+ if (newToml.includes('[compression]')) {
2297
+ newToml = newToml.replace('[compression]', '[compression]\nai_compression = true\nai_min_chars = 1500')
2298
+ } else {
2299
+ newToml += '\n[compression]\nai_compression = true\nai_min_chars = 1500\n'
2300
+ }
2301
+ }
2302
+ fs.writeFileSync(userToml, newToml)
2303
+ console.log(' ✓ Squeezr configured')
2304
+ // Restart proxy
2305
+ console.log('\n Restarting Squeezr to apply changes...')
2306
+ try {
2307
+ execSync('squeezr restart', { stdio: 'inherit' })
2308
+ } catch {}
2309
+ }
2310
+ }
2311
+
2312
+ rl.close()
2313
+ console.log('\n╔══════════════════════════════════════════════════════╗')
2314
+ console.log('║ ✓ Zest is ready! ║')
2315
+ console.log('╚══════════════════════════════════════════════════════╝')
2316
+ console.log('\nSqueezr will now use Zest for AI compression locally.')
2317
+ console.log('No API calls, no cost — everything runs on your machine.')
2318
+ console.log('\nDashboard: http://localhost:8080/squeezr/dashboard')
2319
+ console.log('To disable: set ai_compression = false in ~/.squeezr/squeezr.toml\n')
2320
+ }
2321
+
2322
+ // ── CLI router ────────────────────────────────────────────────────────────────
2323
+
2324
+ switch (command) {
2325
+ case undefined:
2326
+ case 'start':
2327
+ startDaemon()
2328
+ break
2329
+
2330
+ case 'setup':
2331
+ if (process.platform === 'win32') setupWindows()
2332
+ else if (isWSL()) setupWSL()
2333
+ else setupUnix()
2334
+ break
2335
+
2336
+ case 'update':
2337
+ await (async () => {
2338
+ console.log('Stopping Squeezr...')
2339
+ stopProxy() // also kills MCP via killMcpProcesses()
2340
+ console.log('Releasing file locks...')
2341
+ killMcpProcesses() // double-kill in case stopProxy was too fast
2342
+ await new Promise(r => setTimeout(r, 2000))
2343
+
2344
+ console.log('Installing latest version...')
2345
+ const cleanEnv = { ...process.env, HTTPS_PROXY: '', https_proxy: '', HTTP_PROXY: '', http_proxy: '' }
2346
+ let installed = false
2347
+ for (let attempt = 1; attempt <= 4; attempt++) {
2348
+ try {
2349
+ execSync('npm install -g squeezr-ai@latest', { stdio: 'inherit', env: cleanEnv })
2350
+ installed = true
2351
+ break
2352
+ } catch (err) {
2353
+ const msg = String(err?.stderr || err?.message || '')
2354
+ if ((msg.includes('EBUSY') || msg.includes('EPERM')) && attempt < 4) {
2355
+ console.log(` Files still locked, retrying in 3s (attempt ${attempt}/4)...`)
2356
+ // Try harder to release locks on retry
2357
+ killMcpProcesses()
2358
+ await new Promise(r => setTimeout(r, 3000))
2359
+ } else if (!msg.includes('EBUSY') && !msg.includes('EPERM') && process.platform !== 'win32') {
2360
+ // On Unix, try sudo as fallback (not useful on Windows)
2361
+ try {
2362
+ execSync('sudo npm install -g squeezr-ai@latest', { stdio: 'inherit', env: cleanEnv })
2363
+ installed = true
2364
+ } catch {}
2365
+ break
2366
+ } else {
2367
+ break
2368
+ }
2369
+ }
2370
+ }
2371
+ if (!installed) {
2372
+ console.error('\nUpdate failed: files are still locked.')
2373
+ console.error('Fix: close Claude Code completely (this releases the MCP server lock), then run "squeezr update" again.')
2374
+ process.exit(1)
2375
+ }
2376
+
2377
+ // Clear update check cache
2378
+ try { fs.unlinkSync(UPDATE_CHECK_FILE) } catch {}
2379
+
2380
+ // Resolve the NEW package root from npm global modules
2381
+ let newRoot = ROOT
2382
+ try {
2383
+ const npmRoot = execSync('npm root -g', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
2384
+ const candidate = path.join(npmRoot, 'squeezr-ai')
2385
+ if (fs.existsSync(path.join(candidate, 'package.json'))) newRoot = candidate
2386
+ } catch {}
2387
+
2388
+ // Read the new version and write cache so no stale banner appears
2389
+ try {
2390
+ const newPkg = JSON.parse(fs.readFileSync(path.join(newRoot, 'package.json'), 'utf-8'))
2391
+ const dir = path.dirname(UPDATE_CHECK_FILE)
2392
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
2393
+ fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ latest: newPkg.version, checkedAt: Date.now() }))
2394
+ console.log(`\nUpdated to v${newPkg.version}`)
2395
+ } catch {}
2396
+
2397
+ // Start the daemon directly from the new dist/index.js (no re-exec of old binary)
2398
+ console.log('Starting Squeezr...')
2399
+ const newDistIndex = path.join(newRoot, 'dist', 'index.js')
2400
+ const startPort = getPort()
2401
+ const startMitmPort = getMitmPort(startPort)
2402
+ const logDir = path.join(os.homedir(), '.squeezr')
2403
+ const logFile = path.join(logDir, 'squeezr.log')
2404
+ fs.mkdirSync(logDir, { recursive: true })
2405
+ const logFd = fs.openSync(logFile, 'a')
2406
+ const child = spawn(process.execPath, [newDistIndex], {
2407
+ detached: true,
2408
+ stdio: ['ignore', logFd, logFd],
2409
+ cwd: newRoot,
2410
+ env: { ...process.env, SQUEEZR_DAEMON: '1' },
2411
+ })
2412
+ child.unref()
2413
+ fs.closeSync(logFd)
2414
+ console.log(`Squeezr started (pid ${child.pid})`)
2415
+ console.log(` HTTP proxy (Claude/Aider/Gemini): http://localhost:${startPort}`)
2416
+ console.log(` MITM proxy (Codex): http://localhost:${startMitmPort}`)
2417
+ console.log(` Dashboard: http://localhost:${startPort}/squeezr/dashboard`)
2418
+ console.log(` Logs: ${logFile}`)
2419
+
2420
+ // Ensure PowerShell wrapper is installed (so env vars refresh automatically)
2421
+ installShellWrapper()
2422
+ })()
2423
+ break
2424
+ case 'restart':
2425
+ await (async () => {
2426
+ console.log('Stopping Squeezr...')
2427
+ stopProxy()
2428
+ await new Promise(r => setTimeout(r, 1500))
2429
+ await startDaemon()
2430
+ })()
2431
+ break
2432
+
2433
+ case 'stop':
2434
+ stopProxy()
2435
+ break
2436
+
2437
+ case 'logs':
2438
+ showLogs()
2439
+ break
2440
+
2441
+ case 'gain':
2442
+ runNode('gain.js', args.slice(1))
2443
+ break
2444
+
2445
+ case 'discover':
2446
+ runNode('discover.js', args.slice(1))
2447
+ break
2448
+
2449
+ case 'status':
2450
+ checkStatus()
2451
+ break
2452
+
2453
+ case 'ports':
2454
+ await configurePorts()
2455
+ break
2456
+
2457
+ case 'tunnel':
2458
+ await startTunnel()
2459
+ break
2460
+
2461
+ case 'bypass':
2462
+ await (async () => {
2463
+ const port = getPort()
2464
+ const body = args[1] === '--on' ? JSON.stringify({ enabled: true })
2465
+ : args[1] === '--off' ? JSON.stringify({ enabled: false })
2466
+ : '{}'
2467
+ try {
2468
+ const res = await fetch(`http://localhost:${port}/squeezr/bypass`, {
2469
+ method: 'POST',
2470
+ headers: { 'content-type': 'application/json' },
2471
+ body,
2472
+ })
2473
+ const json = await res.json()
2474
+ if (json.bypassed) {
2475
+ console.log('⏸️ Bypass mode ON — compression disabled')
2476
+ console.log(' Requests pass through uncompressed but are still logged.')
2477
+ console.log(' Turn off: squeezr bypass --off')
2478
+ } else {
2479
+ console.log('▶️ Bypass mode OFF — compression active')
2480
+ }
2481
+ } catch {
2482
+ console.log('Squeezr is NOT running')
2483
+ console.log('Start it with: squeezr start')
2484
+ }
2485
+ })()
2486
+ break
2487
+
2488
+ case 'uninstall':
2489
+ await uninstall()
2490
+ break
2491
+ case 'enable-claude-desktop':
2492
+ case 'disable-claude-desktop':
2493
+ await toggleClaudeDesktopIntercept(command === 'enable-claude-desktop')
2494
+ break
2495
+ case 'desktop': {
2496
+ const sub = args[1] ?? 'status'
2497
+ if (sub === 'start') await desktopProxyStart()
2498
+ else if (sub === 'stop') await desktopProxyStop()
2499
+ else if (sub === 'status') await desktopProxyStatus()
2500
+ else { console.error(`Unknown desktop subcommand: ${sub}. Use start|stop|status`); process.exit(1) }
2501
+ break
2502
+ }
2503
+ case 'config':
2504
+ showConfig()
2505
+ break
2506
+
2507
+ case 'mcp': {
2508
+ const subCmd = args[1] ?? 'install'
2509
+ if (subCmd === 'uninstall') await mcpUninstall()
2510
+ else await mcpInstall()
2511
+ break
2512
+ }
2513
+ case 'zest':
2514
+ await installZest()
2515
+ break
2516
+
2517
+ case 'version':
2518
+ case '--version':
2519
+ case '-v':
2520
+ console.log(pkg.version)
2521
+ break
2522
+
2523
+ case '--help':
2524
+ case '-h':
2525
+ case 'help':
2526
+ console.log(HELP)
2527
+ break
2528
+
2529
+ default:
2530
+ console.error(`Unknown command: ${command}`)
2531
+ console.log(HELP)
2532
+ process.exit(1)
2533
+ }
2534
+
2535
+ if (command !== 'update') await showUpdateBanner()