local-mcp 3.0.385 → 3.0.409

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 (2) hide show
  1. package/package.json +1 -1
  2. package/setup.js +218 -297
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-mcp",
3
- "version": "3.0.385",
3
+ "version": "3.0.409",
4
4
  "description": "Let ChatGPT, Claude, Cursor & any MCP client actually use your Mac — read & reply to email, manage your calendar, text over iMessage, find files, work with Teams, Slack & Office. On your Mac, no API keys, free.",
5
5
  "main": "index.js",
6
6
  "bin": {
package/setup.js CHANGED
@@ -81,7 +81,6 @@ function _writeLaunchScript(npxAbsPath, cacheDir, isWin = _IS_WIN) {
81
81
  return null
82
82
  }
83
83
  }
84
- const STABLE_LINK = path.join(os.homedir(), '.local', 'share', 'local-mcp', 'bin', 'local-mcp-server')
85
84
  const BACKEND_HOST = 'office-mcp-production.up.railway.app'
86
85
 
87
86
  // ── Platform-aware config paths for AI clients ─────────────────────────────
@@ -93,58 +92,6 @@ const _IS_WIN = process.platform === 'win32'
93
92
  const _IS_MAC = process.platform === 'darwin'
94
93
  const _APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming')
95
94
 
96
- // Windows MSIX (Microsoft Store / WinGet) installs read config from a
97
- // virtualized path inside %LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude.
98
- // The non-MSIX (.exe) install reads from %APPDATA%\Claude.
99
- // We detect which one exists and write to both if needed.
100
- function _findMsixClaudePath() {
101
- if (!_IS_WIN) return null
102
- const localAppData = process.env.LOCALAPPDATA || path.join(HOME, 'AppData', 'Local')
103
- const packagesDir = path.join(localAppData, 'Packages')
104
- try {
105
- const entries = fs.readdirSync(packagesDir)
106
- const claudePkg = entries.find(e => e.startsWith('Claude_'))
107
- if (claudePkg) {
108
- return path.join(packagesDir, claudePkg, 'LocalCache', 'Roaming', 'Claude', 'claude_desktop_config.json')
109
- }
110
- } catch {}
111
- return null
112
- }
113
-
114
- function _claudeDesktopPath() {
115
- if (_IS_WIN) {
116
- // Prefer MSIX path if the package exists
117
- const msix = _findMsixClaudePath()
118
- if (msix) return msix
119
- return path.join(_APPDATA, 'Claude', 'claude_desktop_config.json')
120
- }
121
- if (_IS_MAC) return path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
122
- return path.join(HOME, '.config', 'Claude', 'claude_desktop_config.json')
123
- }
124
-
125
- // All paths where we should write the config (MSIX + non-MSIX on Windows)
126
- function _claudeDesktopAllPaths() {
127
- if (!_IS_WIN) return [_claudeDesktopPath()]
128
- const paths = [path.join(_APPDATA, 'Claude', 'claude_desktop_config.json')]
129
- const msix = _findMsixClaudePath()
130
- if (msix && !paths.includes(msix)) paths.push(msix)
131
- return paths
132
- }
133
-
134
- function _claudeDesktopDetect() {
135
- if (_IS_WIN) {
136
- // Check both MSIX and non-MSIX paths
137
- return _claudeDesktopAllPaths().some(p => fs.existsSync(path.dirname(p)))
138
- }
139
- return fs.existsSync(path.dirname(_claudeDesktopPath()))
140
- }
141
-
142
- function _rooClinePath() {
143
- if (_IS_WIN) return path.join(_APPDATA, 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json')
144
- if (_IS_MAC) return path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json')
145
- return path.join(HOME, '.config', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'mcp_settings.json')
146
- }
147
-
148
95
  // Where LMCP's own config.json (license_email, cloud_token, activation_checklist_pending)
149
96
  // lives. Used to hardcode the macOS "Library/Application Support" path unconditionally, so
150
97
  // on Windows it resolved to a nonsense C:\Users\X\Library\...\Local MCP directory that
@@ -156,75 +103,8 @@ function _localMcpConfigDir(platform = process.platform, home = HOME, appData =
156
103
  return path.join(home, '.config', 'local-mcp')
157
104
  }
158
105
 
159
- const CLIENTS = [
160
- {
161
- id: 'claude-desktop',
162
- name: 'Claude Desktop',
163
- cfgPath: _claudeDesktopPath(),
164
- detect: _claudeDesktopDetect,
165
- },
166
- {
167
- id: 'cursor',
168
- name: 'Cursor',
169
- cfgPath: path.join(HOME, '.cursor', 'mcp.json'),
170
- detect: () => fs.existsSync(path.join(HOME, '.cursor')) || _appExists('Cursor'),
171
- },
172
- {
173
- id: 'windsurf',
174
- name: 'Windsurf',
175
- cfgPath: path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json'),
176
- detect: () => fs.existsSync(path.join(HOME, '.codeium', 'windsurf')) || _appExists('Windsurf'),
177
- },
178
- {
179
- id: 'vscode',
180
- name: 'VS Code',
181
- cfgPath: path.join(HOME, '.vscode', 'mcp.json'),
182
- detect: () => _cmdExists('code') || _appExists('Visual Studio Code') || fs.existsSync(path.join(HOME, '.vscode')),
183
- vscode: true, // native MCP uses "servers" key + type:"stdio"
184
- },
185
- {
186
- id: 'roo-cline',
187
- name: 'Roo-Cline',
188
- cfgPath: _rooClinePath(),
189
- detect: () => fs.existsSync(path.dirname(_rooClinePath())),
190
- },
191
- {
192
- id: 'zed',
193
- name: 'Zed',
194
- cfgPath: path.join(HOME, '.config', 'zed', 'settings.json'),
195
- detect: () => fs.existsSync(path.join(HOME, '.config', 'zed')) || _appExists('Zed'),
196
- zed: true, // Zed usa formato diferente dentro de settings.json
197
- },
198
- {
199
- id: 'claude-code',
200
- name: 'Claude Code',
201
- // Claude Code reads MCP servers from ~/.claude.json (user-scope top-level
202
- // mcpServers), not ~/.claude/settings.json. Writing settings.json never
203
- // actually configured Claude Code.
204
- cfgPath: path.join(HOME, '.claude.json'),
205
- detect: () => fs.existsSync(path.join(HOME, '.claude')) || fs.existsSync(path.join(HOME, '.claude.json')) || _cmdExists('claude'),
206
- },
207
- ]
208
-
209
106
  // ── Helpers ───────────────────────────────────────────────────────────────────
210
107
 
211
- function _appExists(name) {
212
- if (_IS_MAC) {
213
- return fs.existsSync(`/Applications/${name}.app`) ||
214
- fs.existsSync(path.join(HOME, `Applications/${name}.app`))
215
- }
216
- if (_IS_WIN) {
217
- // Check common Windows install locations
218
- const pf = process.env.ProgramFiles || 'C:\\Program Files'
219
- const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
220
- const localApp = process.env.LOCALAPPDATA || path.join(HOME, 'AppData', 'Local')
221
- return fs.existsSync(path.join(pf, name)) ||
222
- fs.existsSync(path.join(pf86, name)) ||
223
- fs.existsSync(path.join(localApp, name))
224
- }
225
- return false // Linux: rely on _cmdExists or config path detection
226
- }
227
-
228
108
  function _cmdExists(cmd) {
229
109
  const which = _IS_WIN ? 'where' : 'which'
230
110
  try { execSync(`${which} ${cmd}`, { stdio: 'pipe' }); return true } catch { return false }
@@ -297,9 +177,8 @@ function _atomicWriteConfig(filePath, data) {
297
177
  // Node-based MCP host — Cursor, VS Code, Windsurf, Cline, Roo — got `spawn EINVAL`
298
178
  // and showed local-mcp as failed, on the very platform this release re-launches.
299
179
  //
300
- // The fix is to spawn the interpreter and pass the script as an ARGUMENT, which is
301
- // exactly what the claude-desktop branch of injectMcpConfig already did. A batch file
302
- // is never the executable; cmd.exe is.
180
+ // The fix is to spawn the interpreter and pass the script as an ARGUMENT. A batch
181
+ // file is never the executable; cmd.exe is.
303
182
  //
304
183
  // The invariant a test can check without knowing this function's shape: `command`
305
184
  // must never be a batch file. That is the precise condition Node refuses, not a
@@ -324,100 +203,99 @@ function _writeLaunchSpec(npxAbsPath, cacheDir, isWin = _IS_WIN) {
324
203
  return _launcherSpawnSpec(_writeLaunchScript(npxAbsPath, cacheDir, isWin), isWin)
325
204
  }
326
205
 
327
- // Merges the local-mcp entry into a SINGLE config object, mutating it in place.
328
- // Split out of injectMcpConfig so the same merge logic runs independently per path —
329
- // see the comment on injectMcpConfig for why that independence matters.
330
- function _mergeLocalMcpEntry(cfg, client, command, args) {
331
- const existingServers = Object.keys(
332
- cfg.mcpServers || cfg.servers || cfg.context_servers || {}
333
- )
334
- const otherServers = existingServers.filter(k => k !== 'local-mcp' && k !== 'office-mcp')
335
-
336
- if (client.zed) {
337
- cfg.context_servers = cfg.context_servers || {}
338
- cfg.context_servers['local-mcp'] = { command: { path: command, args: args ?? [] } }
339
- } else if (client.vscode) {
340
- // VS Code native MCP (1.99+) uses "servers" key with type:"stdio"
341
- cfg.servers = cfg.servers || {}
342
- cfg.servers['local-mcp'] = { type: 'stdio', command, args: args ?? [] }
343
- } else {
344
- cfg.mcpServers = cfg.mcpServers || {}
345
- delete cfg.mcpServers['office-mcp']
346
- const entry = { command }
347
- if (args !== undefined) entry.args = args
348
- cfg.mcpServers['local-mcp'] = entry
349
- }
350
- return { existingCount: existingServers.length, otherServers }
351
- }
206
+ // ── Main ──────────────────────────────────────────────────────────────────────
352
207
 
353
- // ── Inyectar config MCP en un cliente ────────────────────────────────────────
354
- // Returns { ok, stage, error, existingMcpCount, preservedServers, perPath }.
355
- // Never overwrites a file whose JSON cannot be parsed.
356
- //
357
- // Windows Claude Desktop can have TWO install types (MSIX + .exe) reading from TWO
358
- // different config files (_claudeDesktopAllPaths()). This used to read only the
359
- // PREFERRED path, merge local-mcp into that single object, then write that SAME
360
- // object to both paths silently erasing any mcpServers that existed only in the
361
- // non-preferred file, and reporting only the last path's write result because `write`
362
- // was reassigned each loop iteration (#1390 finding 4). Each path is now read,
363
- // merged, and written independently, and the JSON-parse-error backup guard in
364
- // _safeReadConfig now protects every path, not just the preferred one.
365
- //
366
- // explicitPaths lets a test exercise the multi-path merge deterministically without
367
- // running on Windows with two real Claude Desktop installs (default null preserves
368
- // the real path-selection logic below).
369
- function injectMcpConfig(client, command = NPX_COMMAND, args = NPX_ARGS, explicitPaths = null) {
370
- // On Windows, Claude Desktop needs cmd /c wrapper to find npx
371
- if (_IS_WIN && client.id === 'claude-desktop') {
372
- command = 'cmd'
373
- args = ['/c', 'npx', '-y', 'local-mcp@latest']
374
- }
208
+ // ── #3212 · Mac: las apps de IA las configura el binario, con el modelo del producto ──────────
209
+ // El binario decide y escribe con el mismo escritor que el tray (JSONC que no toca, YAML, TOML,
210
+ // el lock compartido) y devuelve un reporte versionado. Acá sólo se lee. En Windows y Linux sigue
211
+ // el camino JS de abajo hasta que go-server tenga el mismo flag: #3212 no se cierra antes.
212
+ const CLIENTS_REPORT_SCHEMA = 1
213
+
214
+ // platform is a PARAMETER, not a read of _IS_WIN, for the same reason
215
+ // _writeLaunchScript's isWin parameter exists (#1394 §C): a mutation to the
216
+ // win32 branch has to be exercisable on any machine running this suite, not
217
+ // only on a real Windows box.
218
+ // `home` is a parameter for the same reason `platform` is: the third candidate is the stable
219
+ // link under the user's home, so without it the lookup READS THE MACHINE and a test asserting
220
+ // "finds nothing" passes only where LMCP is not installed. Same seam as _localMcpConfigDir.
221
+ // Every home-derived candidate must come from THIS argument; one that reaches for os.homedir()
222
+ // again puts the machine back in the answer.
223
+ function _findServerBinary(embedded, cacheDir, platform = process.platform, home = HOME) {
224
+ const binName = platform === 'win32' ? 'lmcp-server.exe' : 'lmcp-server'
225
+ const stableLink = path.join(home, '.local', 'share', 'local-mcp', 'bin', 'local-mcp-server')
226
+ const cands = [embedded, cacheDir && path.join(cacheDir, binName), stableLink].filter(Boolean)
227
+ return cands.find(p => { try { return fs.statSync(p).isFile() } catch { return false } }) || null
228
+ }
375
229
 
376
- const allPaths = explicitPaths ||
377
- ((client.id === 'claude-desktop' && _IS_WIN) ? _claudeDesktopAllPaths() : [client.cfgPath])
230
+ // --command=/--arg= point the binary at the npx-launcher indirection darwin
231
+ // uses to reach the local-mcp-server binary. go-server on Windows has no
232
+ // such indirection and REJECTS these flags outright with exit 2 (contract
233
+ // §3.4) — passing them there would fail every Windows install. Extracted so
234
+ // this platform rule is unit-testable without invoking runSetup's full
235
+ // side-effect chain.
236
+ function _launcherArgsForConfigureClients(platform, command, args) {
237
+ return platform === 'darwin' ? { command, args } : { command: null, args: [] }
238
+ }
378
239
 
379
- const perPath = []
380
- let existingMcpCount = 0
381
- const preservedSet = new Set()
240
+ // §3.8 Linux's fixed result shape: no capability, no report, exit 0 (not
241
+ // having a writer yet is not a failure). Extracted so the contract itself is
242
+ // directly testable instead of only reachable by reading runSetup's source.
243
+ function _notSupportedOnPlatformResult() {
244
+ return { runtime: 'not_supported_on_platform', exitCode: 0, report: null }
245
+ }
382
246
 
383
- for (const p of allPaths) {
384
- const read = _safeReadConfig(p)
385
- if (read.hadParseError) {
386
- perPath.push({ path: p, ok: false, stage: 'config_read', error: read.error, hadParseError: true })
387
- continue
388
- }
389
- const cfg = read.data || {}
390
- const { existingCount, otherServers } = _mergeLocalMcpEntry(cfg, client, command, args)
391
- existingMcpCount = Math.max(existingMcpCount, existingCount)
392
- otherServers.forEach((s) => preservedSet.add(s))
393
-
394
- fs.mkdirSync(path.dirname(p), { recursive: true })
395
- const write = _atomicWriteConfig(p, cfg)
396
- perPath.push({ path: p, ok: write.ok, stage: write.ok ? 'config_write' : 'config_write_failed', error: write.error, hadParseError: false })
247
+ // Corre `--configure-clients --json`. Nunca cuelga el instalador: un binario que no responde
248
+ // termina en failed(timeout); uno viejo, que no conoce el flag, no imprime el reporte y queda en
249
+ // runtime_too_old. exitCode: 0 bien · 1 algún cliente falló · 2 no se pudo configurar.
250
+ function runConfigureClients(bin, command, args, opts = {}) {
251
+ const spawn = opts.spawnSync || require('child_process').spawnSync
252
+ const timeoutMs = opts.timeoutMs || 90000
253
+ if (!bin) return { runtime: 'no_runtime', exitCode: 2, report: null }
254
+ const argv = ['--configure-clients', '--json']
255
+ if (command) argv.push('--command=' + command, ...(args || []).map(a => '--arg=' + a))
256
+ const r = spawn(bin, argv, { input: '', timeout: timeoutMs, encoding: 'utf8' })
257
+ if (r.error && r.error.code === 'ETIMEDOUT') return { runtime: 'timeout', exitCode: 2, report: null }
258
+ if (r.error) return { runtime: 'failed', reason: r.error.message, exitCode: 2, report: null }
259
+ let report = null
260
+ try { report = JSON.parse(String(r.stdout || '').trim().split('\n').pop()) } catch { /* sin reporte */ }
261
+ if (!report || typeof report !== 'object' || report.schema === undefined) {
262
+ return { runtime: 'runtime_too_old', exitCode: 2, report: null }
397
263
  }
398
-
399
- const anyOk = perPath.some((r) => r.ok)
400
- const failed = perPath.filter((r) => !r.ok)
401
- if (failed.length > 0) {
402
- for (const f of failed) {
403
- process.stderr.write(` ⚠ ${client.name}: could not update ${f.path} (${f.error}) — other MCP servers configured only there may be affected\n`)
404
- }
264
+ if (report.schema !== CLIENTS_REPORT_SCHEMA) {
265
+ return { runtime: 'unsupported_report_schema', reason: String(report.schema), exitCode: 2, report: null }
405
266
  }
267
+ const failedAny = (report.clients || []).some(c => c.status === 'failed')
268
+ return { runtime: 'ok', exitCode: failedAny ? 1 : 0, report }
269
+ }
406
270
 
407
- return {
408
- ok: anyOk,
409
- stage: anyOk ? 'config_write' : (perPath[0] ? perPath[0].stage : 'config_read'),
410
- error: failed.map((f) => `${f.path}: ${f.error}`).join('; ') || (perPath[0] ? perPath[0].error : 'no paths'),
411
- hadParseError: !anyOk && perPath.some((r) => r.hadParseError),
412
- existingMcpCount,
413
- preservedServers: Array.from(preservedSet),
414
- perPath,
271
+ // Los permisos pendientes los mide el tray (GET /permissions): medidos desde este proceso
272
+ // describirían a la terminal. Se reintenta un rato porque el tray acaba de arrancar.
273
+ async function _fetchTrayPermissions(totalMs = 10000) {
274
+ const http = require('http')
275
+ const once = () => new Promise(resolve => {
276
+ const req = http.get({ host: '127.0.0.1', port: 8765, path: '/permissions', timeout: 1500 }, res => {
277
+ let body = ''
278
+ res.on('data', d => { body += d })
279
+ res.on('end', () => {
280
+ try { const o = JSON.parse(body); resolve(o && o.source === 'tray' ? o : null) } catch { resolve(null) }
281
+ })
282
+ })
283
+ req.on('timeout', () => { req.destroy(); resolve(null) })
284
+ req.on('error', () => resolve(null))
285
+ })
286
+ const end = Date.now() + totalMs
287
+ while (Date.now() < end) {
288
+ const o = await once()
289
+ if (o) return o
290
+ await new Promise(r => setTimeout(r, 1000))
415
291
  }
292
+ return null
416
293
  }
417
294
 
418
- // ── Main ──────────────────────────────────────────────────────────────────────
419
-
420
295
  async function runSetup(opts = {}) {
296
+ // #3212 — `--json`: el reporte estructurado va solo por stdout; todo lo demás, a stderr.
297
+ const jsonMode = !!opts.json || process.argv.includes('--json')
298
+ if (jsonMode) console.log = (...a) => { process.stderr.write(a.join(' ') + '\n') }
421
299
  // Cross-platform: macOS configures the Swift tray + embedded server; Windows/Linux
422
300
  // download the go-server standalone binary (download.js) and write the MCP client
423
301
  // config (the config writer already handles Windows paths). The Mac-only waitlist
@@ -551,71 +429,56 @@ async function runSetup(opts = {}) {
551
429
  // failed it falls back to plain 'npx'.
552
430
 
553
431
  // Detectar clientes
554
- const detected = CLIENTS.filter(c => c.detect())
555
- const notDetected = CLIENTS.filter(c => !c.detect())
556
432
  const _npmPkgVersion = (() => { try { return require('./package.json').version } catch { return '' } })()
557
- _trackSetupStep('detect', detected.length > 0 ? 'ok' : 'no_clients', '', {
558
- clientsFound: detected.map(c => c.id),
559
- clientsNotFound: notDetected.map(c => c.id),
560
- npmVersion: _npmPkgVersion,
561
- })
562
-
563
- if (detected.length === 0) {
564
- const entry = stableArgs !== undefined
565
- ? { command: stableCommand, args: stableArgs }
566
- : { command: stableCommand }
567
- const snippet = JSON.stringify({ mcpServers: { 'local-mcp': entry } }, null, 2)
568
- .split('\n').map(l => ' ' + l).join('\n')
569
-
570
- console.log('╔══════════════════════════════════════════════════════════╗')
571
- console.log('║ LMCP installed — no AI client detected yet ║')
572
- console.log('╚══════════════════════════════════════════════════════════╝\n')
573
- console.log(' To get started, add LMCP to your AI client config:\n')
574
- console.log(' ── Claude Desktop ────────────────────────────────────────')
575
- console.log(` File: ${_claudeDesktopPath()}\n`)
576
- console.log(snippet)
577
- console.log('\n ── Cursor ────────────────────────────────────────────────')
578
- console.log(' File: ~/.cursor/mcp.json\n')
579
- console.log(snippet)
580
- console.log('\n ── VS Code (Cline / GitHub Copilot) ──────────────────────')
581
- console.log(' File: ~/.vscode/mcp.json\n')
582
- console.log(snippet)
583
- console.log('\n Then restart your AI client and try:')
584
- console.log(' "Summarize my unread emails"\n')
585
- console.log(' ── Already installed an AI client? ───────────────────────')
586
- console.log(' Run this to auto-configure it:')
587
- console.log(' npx local-mcp setup\n')
588
- console.log(' Full setup guide: https://local-mcp.com/setup')
589
- console.log('─────────────────────────────────────────────────────────────\n')
590
- return
591
- }
592
-
593
- console.log(`Detected: ${detected.map(c => c.name).join(', ')}\n`)
594
-
595
433
  const configured = []
596
434
  const failed = []
597
-
598
- for (const client of detected) {
599
- const result = injectMcpConfig(client, stableCommand, stableArgs)
600
- if (result.ok) {
601
- _trackSetupStep('config_write', 'ok', client.id, {
602
- existingMcpCount: result.existingMcpCount,
603
- preservedServers: result.preservedServers,
604
- })
605
- _trackConfigWritten(client.id || client.name, client.name)
606
- configured.push(client.name)
607
- console.log(`✓ ${client.name} configured`)
608
- } else {
609
- const status = result.hadParseError ? 'parse_error' : 'write_error'
610
- _trackSetupStep('config_write', status, client.id, { error: result.error })
611
- failed.push({ name: client.name, reason: status, error: result.error })
612
- if (result.hadParseError) {
613
- console.error(`✗ ${client.name}: existing config has invalid JSON — backed up to ${client.cfgPath}.lmcp-backup`)
614
- console.error(` Edit or remove the backup to fix: ${result.error}`)
615
- } else {
616
- console.error(`✗ ${client.name}: ${result.error}`)
435
+ let clientsRun = null // #3212: en la Mac, el reporte del binario
436
+ // #3212 Windows now shares the exact darwin path: the go-server binary
437
+ // decides and writes (lmcp-server --configure-clients --json), never a
438
+ // second JS-side client list. Linux has no runtime-native writer yet
439
+ // (§3.8): a generic message, no per-client guesswork, no files written.
440
+ if (process.platform === 'darwin' || _IS_WIN) {
441
+ const { CACHE_DIR } = require('./download')
442
+ const launcher = _launcherArgsForConfigureClients(process.platform, stableCommand, stableArgs)
443
+ clientsRun = runConfigureClients(_findServerBinary(_embeddedServer, CACHE_DIR), launcher.command, launcher.args)
444
+ const clientes = (clientsRun.report && clientsRun.report.clients) || []
445
+ _trackSetupStep('detect', clientsRun.runtime === 'ok' ? 'ok' : clientsRun.runtime, '', {
446
+ clientsFound: clientes.filter(c => c.status !== 'not_installed').map(c => c.id),
447
+ clientsNotFound: clientes.filter(c => c.status === 'not_installed').map(c => c.id),
448
+ npmVersion: _npmPkgVersion,
449
+ })
450
+ for (const c of clientes) {
451
+ if (c.status === 'configured') {
452
+ configured.push(c.name)
453
+ _trackConfigWritten(c.id, c.name)
454
+ console.log(`✓ ${c.name} configured`)
455
+ } else if (c.status === 'already') {
456
+ console.log(`✓ ${c.name} already configured`)
457
+ } else if (c.status === 'failed') {
458
+ failed.push({ name: c.name, reason: 'failed', error: c.reason })
459
+ console.error(`✗ ${c.name}: ${c.reason || 'failed'}`)
460
+ } else if (c.status !== 'not_installed') {
461
+ console.log(`· ${c.name}: ${c.status.replace(/_/g, ' ')}${c.reason ? ' — ' + c.reason : ''}`)
617
462
  }
618
463
  }
464
+ if (clientsRun.runtime !== 'ok') {
465
+ console.error(`⚠ AI apps not configured yet (${clientsRun.runtime}) — open LMCP from the system tray and use Connect.`)
466
+ } else if (clientes.length > 0 && clientes.every(c => c.status === 'not_installed')) {
467
+ console.log('\n No AI client detected yet — LMCP is installed and ready.')
468
+ console.log(' Install one (Claude Desktop, Cursor, VS Code...), then run:')
469
+ console.log(' npx local-mcp setup\n')
470
+ console.log(' Full setup guide: https://local-mcp.com/setup')
471
+ }
472
+ } else {
473
+ // Linux (§3.8): no runtime-native config writer exists yet. Say so plainly,
474
+ // point at the manual guide, and touch nothing — no per-client guessing,
475
+ // no files written, no client identity hardcoded here.
476
+ clientsRun = _notSupportedOnPlatformResult()
477
+ _trackSetupStep('detect', 'not_supported_on_platform', '', { npmVersion: _npmPkgVersion })
478
+ console.log('\n LMCP is installed and running.')
479
+ console.log(' Automatic AI-client configuration is not available on Linux yet.')
480
+ console.log(' Add LMCP to your AI client\'s MCP config manually — see:')
481
+ console.log(' https://local-mcp.com/setup\n')
619
482
  }
620
483
 
621
484
  const cfgDir = _localMcpConfigDir()
@@ -713,7 +576,9 @@ async function runSetup(opts = {}) {
713
576
  // JSON files we write above — so we don't auto-configure it (a JSON write would be
714
577
  // useless and a blind YAML merge could corrupt the user's config). If Hermes is
715
578
  // present, print the exact CLI one-liner instead.
716
- if (fs.existsSync(path.join(HOME, '.hermes')) || _cmdExists('hermes')) {
579
+ const _hermes = clientsRun && clientsRun.report && (clientsRun.report.clients || []).find(c => c.id === 'hermes')
580
+ const _hermesListo = !!_hermes && ['configured', 'already'].includes(_hermes.status)
581
+ if (!_hermesListo && (fs.existsSync(path.join(HOME, '.hermes')) || _cmdExists('hermes'))) {
717
582
  console.log('┌─────────────────────────────────────────────────────┐')
718
583
  console.log('│ HERMES AGENT detected — add LMCP with its CLI: │')
719
584
  console.log('│ │')
@@ -733,11 +598,17 @@ async function runSetup(opts = {}) {
733
598
 
734
599
  // Instalar y lanzar el tray (menu bar app) — arm64 only, non-fatal
735
600
  await _installTray()
601
+ // Tray permissions are a macOS-only concept (contract §3.1: Windows/Linux
602
+ // report "not_applicable", not "unknown_until_tray_runs") — polling
603
+ // 127.0.0.1:8765/permissions off darwin would just cost up to 10s waiting
604
+ // on an endpoint that was never going to answer.
605
+ const trayPermissions = _IS_MAC ? (clientsRun ? await _fetchTrayPermissions() : null) : 'not_applicable'
736
606
  await _installTeamsProxy()
737
607
  await _installSlackProxy()
738
608
 
739
- // Auto-launch primary configured AI client + show "Try this now" banner
740
- _autoLaunchClient(configured)
609
+ // Auto-launch the primary configured AI client (macOS only win32/linux
610
+ // just name the next_step) + show "Try this now" banner.
611
+ await _autoLaunchClient(clientsRun)
741
612
 
742
613
  // Anon-first cloud relay activation (replaces the interactive email prompt
743
614
  // removed 2026-04-19). When no email is set, register an anonymous tunnel
@@ -804,6 +675,23 @@ async function runSetup(opts = {}) {
804
675
  // LMCP_METHOD lets install.sh pass 'curl' so we can distinguish it from
805
676
  // a direct 'npx local-mcp setup' invocation in the analytics.
806
677
  _pingInstall(configured, process.env.LMCP_METHOD || opts.method || 'npx-setup', email, binaryVersion)
678
+
679
+ // #3212 — el reporte para quien instaló sin mirar (un agente): qué quedó hecho y qué espera
680
+ // a la persona. Los permisos, sólo si los midió el tray. clientsRun is always set by this
681
+ // point — darwin/win32 from runConfigureClients, linux from its own not_supported sentinel.
682
+ if (jsonMode) {
683
+ const rep = clientsRun.report || {}
684
+ const out = {
685
+ schema: CLIENTS_REPORT_SCHEMA,
686
+ installer_version: _npmPkgVersion,
687
+ runtime: clientsRun.runtime,
688
+ catalog: rep.catalog || null,
689
+ clients: rep.clients || [],
690
+ permissions: trayPermissions || 'unknown_until_tray_runs',
691
+ }
692
+ process.stdout.write(JSON.stringify(out) + '\n')
693
+ }
694
+ process.exitCode = clientsRun.exitCode
807
695
  }
808
696
 
809
697
  async function _installTeamsProxy() {
@@ -1212,46 +1100,79 @@ function _pingInstall(clients, method, email = '', binaryVersion = '') {
1212
1100
  } catch { /* no bloquear */ }
1213
1101
  }
1214
1102
 
1103
+ // _fetchClientsCatalog fetches the served catalog (the SAME endpoint the
1104
+ // go-server's RefreshFromRemote uses) so _autoLaunchClient can find a
1105
+ // client's macOS .app path without ever typing a client's identity a
1106
+ // second time in this file. 4s budget (#3212 §3.9): a slow/unreachable
1107
+ // backend must never block the rest of setup. Returns null on any failure —
1108
+ // callers treat that as "don't launch anything," never as an error.
1109
+ function _fetchClientsCatalog(totalMs = 4000) {
1110
+ return new Promise((resolve) => {
1111
+ const https = require('https')
1112
+ const req = https.request({
1113
+ hostname: BACKEND_HOST, path: '/clients/catalog', method: 'GET', timeout: totalMs,
1114
+ }, (res) => {
1115
+ let body = ''
1116
+ res.on('data', (d) => { body += d })
1117
+ res.on('end', () => { try { resolve(JSON.parse(body)) } catch { resolve(null) } })
1118
+ })
1119
+ req.on('error', () => resolve(null))
1120
+ req.on('timeout', () => { try { req.destroy() } catch {} resolve(null) })
1121
+ req.end()
1122
+ })
1123
+ }
1124
+
1215
1125
  /**
1216
- * Auto-launch the primary configured AI client and print a "Try this now" banner.
1217
- * Non-fatal install succeeds even if open() fails.
1126
+ * Auto-launch the primary configured AI client and print a "Try this now" banner
1127
+ * derived entirely from ConfigureAll's report and the served catalog (#3212 item 23):
1128
+ * no client name is ever typed here. macOS actually opens the app (via the
1129
+ * catalog's own .app detectPath); win32/linux only name the next_step, since
1130
+ * neither platform has an "open this app" primitive LMCP can safely use.
1131
+ * Non-fatal at every step — install succeeds even if the catalog fetch or
1132
+ * open() fails or times out.
1218
1133
  */
1219
- function _autoLaunchClient(configured) {
1220
- if (!configured || configured.length === 0) return
1221
- const appMap = {
1222
- 'Claude Desktop': 'Claude',
1223
- 'Cursor': 'Cursor',
1224
- 'Windsurf': 'Windsurf',
1225
- 'Zed': 'Zed',
1226
- 'VS Code (Cline)': 'Visual Studio Code',
1227
- }
1228
- // Claude Code is a CLI tool no app bundle to open
1229
- const launchOrder = ['Claude Desktop', 'Cursor', 'Windsurf', 'Zed', 'VS Code (Cline)']
1230
- let appName = null
1231
- let clientName = null
1232
- for (const name of launchOrder) {
1233
- if (configured.includes(name) && appMap[name]) {
1234
- appName = appMap[name]
1235
- clientName = name
1236
- break
1237
- }
1134
+ async function _autoLaunchClient(clientsRun, opts = {}) {
1135
+ const platform = opts.platform || process.platform
1136
+ const exec = opts.execFileSync || execFileSync
1137
+ const fetchCatalog = opts.fetchCatalog || _fetchClientsCatalog
1138
+
1139
+ const clientes = (clientsRun && clientsRun.report && clientsRun.report.clients) || []
1140
+ // "restart"/"restart_agent_mode" are the only next_steps that name reopening
1141
+ // an app; "new_session" (e.g. Claude Code, a CLI) and "open_mcp_page" have no
1142
+ // app to relaunch, and the catalog lookup below naturally no-ops for them too
1143
+ // (a CLI-only entry has no macOS .app detectPath to find).
1144
+ const entry = clientes.find(c => c.status === 'configured' && c.next_step && c.next_step.startsWith('restart'))
1145
+ if (!entry) return
1146
+
1147
+ if (platform !== 'darwin') {
1148
+ const verb = entry.next_step === 'restart_agent_mode'
1149
+ ? `Toggle agent mode off and back on in ${entry.name}`
1150
+ : `Restart ${entry.name}`
1151
+ console.log(`\n✅ LMCP is ready! ${verb} to activate it.\n`)
1152
+ return
1238
1153
  }
1239
- if (!appName) return
1154
+
1155
+ const catalog = await fetchCatalog()
1156
+ const found = catalog && (catalog.clients || []).find(c => c.id === entry.id)
1157
+ const macos = found && found.platforms && found.platforms.macos
1158
+ const appPath = ((macos && macos.detectPaths) || []).find(p => p.endsWith('.app'))
1159
+ if (!appPath) return
1240
1160
 
1241
1161
  try {
1242
- execFileSync('open', ['-a', appName], { stdio: 'pipe', timeout: 5000 })
1243
- console.log(`\n✅ LMCP is ready! ${clientName} is opening now.\n`)
1244
- console.log('Try asking Claude: "Read my last 3 emails and summarize them"\n')
1162
+ exec('open', [appPath.replace(/^~/, HOME)], { stdio: 'pipe', timeout: 5000 })
1163
+ console.log(`\n✅ LMCP is ready! ${entry.name} is opening now.\n`)
1164
+ console.log('Try asking: "Read my last 3 emails and summarize them"\n')
1245
1165
  } catch {
1246
1166
  // open failed (app not installed or already open) — non-fatal
1247
- return
1248
1167
  }
1249
-
1250
1168
  }
1251
1169
 
1252
1170
  module.exports = {
1253
- runSetup, injectMcpConfig, CLIENTS,
1171
+ runSetup, runConfigureClients, _findServerBinary, _launcherArgsForConfigureClients,
1172
+ CLIENTS_REPORT_SCHEMA,
1173
+ _notSupportedOnPlatformResult,
1254
1174
  _launcherSpawnSpec, _writeLaunchScript, _writeLaunchSpec,
1255
1175
  _getMachineId, _healthCheckSpec, _localMcpConfigDir,
1256
1176
  _cloudTokenPath, _readCachedCloudToken, _writeCloudToken, _registerAnonToken,
1177
+ _autoLaunchClient, _fetchClientsCatalog,
1257
1178
  }