@openagents-org/agent-launcher 0.2.143 → 0.2.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/installer.js CHANGED
@@ -4,12 +4,46 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { execSync, exec } = require('child_process');
7
- const { whichBinary, getEnhancedEnv, getRuntimePrefix, clearBinaryLookupCache } = require('./paths');
7
+ const { whichBinary, getEnhancedEnv, getRuntimePrefix, clearBinaryLookupCache, aiderBinDirs } = require('./paths');
8
8
  const { EnvManager } = require('./env');
9
+ const { readinessReason, REASON } = require('./adapters/health-status');
9
10
 
10
11
  const STATUS_CACHE_TTL_MS = 10000;
11
12
  const statusCache = new Map();
12
13
 
14
+ // Version detection is comparatively expensive (spawns `<bin> --version`), so
15
+ // results are cached briefly and invalidated on install/uninstall.
16
+ const VERSION_CACHE_TTL_MS = 60000;
17
+ const versionCache = new Map();
18
+
19
+ /**
20
+ * Compare two dotted version strings numerically, ignoring any pre-release /
21
+ * build suffix (e.g. "1.2.3-beta.1" → 1.2.3). Returns -1, 0, or 1.
22
+ * Returns null when either side has no extractable numeric version.
23
+ */
24
+ function compareVersions(a, b) {
25
+ const norm = (v) => {
26
+ const m = String(v == null ? '' : v).match(/\d+(?:\.\d+)*/);
27
+ return m ? m[0].split('.').map((n) => parseInt(n, 10)) : null;
28
+ };
29
+ const pa = norm(a);
30
+ const pb = norm(b);
31
+ if (!pa || !pb) return null;
32
+ const len = Math.max(pa.length, pb.length);
33
+ for (let i = 0; i < len; i++) {
34
+ const x = pa[i] || 0;
35
+ const y = pb[i] || 0;
36
+ if (x > y) return 1;
37
+ if (x < y) return -1;
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ /** Drop the version cache (called on install/uninstall). */
43
+ function clearVersionCache() {
44
+ versionCache.clear();
45
+ }
46
+
13
47
  /**
14
48
  * Manages installation and uninstallation of agent runtimes.
15
49
  *
@@ -90,6 +124,20 @@ class Installer {
90
124
  // Fallback: check if binary exists on PATH (system install)
91
125
  const binaryPath = this._whichBinary(agentType);
92
126
  if (!binaryPath) {
127
+ // Aider/Amp-only: a marker alone is NOT sufficient evidence of an install.
128
+ // Their CLIs live outside any npm package (aider: curl/uv/pipx → ~/.local/bin;
129
+ // amp: curl/ps1 installer → ~/.amp/bin), so a historical "installed" marker
130
+ // can outlive a missing/never-landed binary. Require a real resolvable
131
+ // binary; when only a stale marker remains, report not-installed with a
132
+ // 'cli-missing' diagnostic (carried by the existing `location` field — no
133
+ // new field/enum). The marker itself is left untouched.
134
+ if (agentType === 'aider' || agentType === 'amp') {
135
+ return {
136
+ installed: false,
137
+ managed: false,
138
+ location: this._hasMarker(agentType) ? 'cli-missing' : null,
139
+ };
140
+ }
93
141
  // If a successful install wrote a marker, surface installed=true even
94
142
  // when binary detection can't (yet) see the freshly-installed CLI —
95
143
  // e.g. PATH caches not yet picking up a brand-new ~/.cursor/bin. The
@@ -144,6 +192,221 @@ class Installer {
144
192
  return this.isInstalled(agentType);
145
193
  }
146
194
 
195
+ /**
196
+ * Aider-only post-install verification.
197
+ *
198
+ * Confirms a REAL aider binary exists on disk — AND that it is actually the
199
+ * Aider CLI rather than a same-named unrelated executable — before the
200
+ * install is recorded. The curl/uv/pipx installer can exit 0 without landing
201
+ * a runnable binary (blocked download, wrong $HOME, custom bin dir), which
202
+ * would otherwise leave a "marker says installed but CLI missing" state.
203
+ * Deliberately does NOT consult the install marker, so it can't be satisfied
204
+ * by its own about-to-be-written record.
205
+ *
206
+ * Hard condition: an absolute path resolves AND the file exists. `aider
207
+ * --version` is best-effort: a flaky/blocked probe does NOT fail the install,
208
+ * but a version string that is clearly NOT Aider (wrong same-named package)
209
+ * does — that is the whole point of the check.
210
+ *
211
+ * Scoped to Aider; never called for any other agent type.
212
+ *
213
+ * @returns {{ path: string, version: string|null } | null}
214
+ */
215
+ _verifyAiderBinary() {
216
+ try { clearBinaryLookupCache(); } catch {}
217
+
218
+ const isWin = process.platform === 'win32';
219
+ const names = isWin ? ['aider.exe', 'aider.cmd', 'aider'] : ['aider'];
220
+ const candidates = [];
221
+ // 1) Whatever the enhanced-PATH resolver finds (now includes the XDG and
222
+ // uv-tools dirs added to paths.js).
223
+ const resolved = this._whichBinary('aider');
224
+ if (resolved) candidates.push(resolved);
225
+ // 2) Every real install dir the official installer can target — XDG bin,
226
+ // XDG_DATA_HOME/../bin, ~/.local/bin, and the uv tools venv. This is the
227
+ // fix for "install succeeded but landed outside ~/.local/bin" (e.g. the
228
+ // user has XDG_* set, or only the uv-tools venv copy exists).
229
+ for (const dir of aiderBinDirs()) {
230
+ for (const name of names) candidates.push(path.join(dir, name));
231
+ }
232
+
233
+ let found = null;
234
+ for (const c of candidates) {
235
+ try { if (c && fs.existsSync(c)) { found = c; break; } } catch {}
236
+ }
237
+ if (!found) return null;
238
+
239
+ let version = null;
240
+ try {
241
+ const raw = require('child_process').execSync(`"${found}" --version`, {
242
+ stdio: ['ignore', 'pipe', 'ignore'],
243
+ timeout: 8000,
244
+ env: getEnhancedEnv(),
245
+ windowsHide: true,
246
+ encoding: 'utf-8',
247
+ }).trim();
248
+ if (raw) {
249
+ // A confident mismatch means this `aider` is the wrong package.
250
+ if (!/aider/i.test(raw)) return null;
251
+ version = raw;
252
+ }
253
+ } catch {
254
+ // Best-effort only — file existence above is the hard condition.
255
+ }
256
+ return { path: found, version };
257
+ }
258
+
259
+ /**
260
+ * Aider-only: actionable message when the install command exits 0 but no
261
+ * runnable (and verified) aider binary can be found anywhere we look. The most
262
+ * common real cause is the underlying `uv` step failing to download a Python
263
+ * runtime on a restricted network — so we point at both the PATH/new-terminal
264
+ * case and a pip fallback.
265
+ */
266
+ _aiderBinaryNotFoundMessage() {
267
+ const isWin = process.platform === 'win32';
268
+ const name = isWin ? 'aider.exe' : 'aider';
269
+ const looked = aiderBinDirs().map((d) => ` ${path.join(d, name)}`).join('\n');
270
+ return (
271
+ 'Aider install command completed, but the Aider CLI could not be located ' +
272
+ 'afterward. The underlying installer (uv) most likely could not finish — ' +
273
+ 'often it cannot download a Python runtime on a restricted network/proxy.\n\n' +
274
+ `Looked in:\n${looked}\n\n` +
275
+ 'Try one of:\n' +
276
+ ' 1) Open a NEW terminal and run: aider --version\n' +
277
+ ' (the install dir may simply not be on this process’s PATH yet)\n' +
278
+ ' 2) Install with pip instead, then re-run detection:\n' +
279
+ ' python -m pip install --upgrade aider-chat'
280
+ );
281
+ }
282
+
283
+ /**
284
+ * Amp-only post-install verification.
285
+ *
286
+ * Confirms a REAL amp binary exists on disk before the install is recorded.
287
+ * The Amp install script can exit 0 without landing a runnable binary (wrong
288
+ * $HOME, blocked download, custom AMP_HOME), which would otherwise leave a
289
+ * "marker says installed but CLI missing" state. Deliberately does NOT consult
290
+ * the installed marker (unlike verifyInstalled()->isInstalled()), so it can't
291
+ * be satisfied by its own about-to-be-written record.
292
+ *
293
+ * Hard condition for success: an absolute path resolves AND the file exists.
294
+ * `amp --version` is a best-effort enhancement used only for logging — a flaky
295
+ * or non-interactive `--version` failure does NOT fail the install.
296
+ *
297
+ * Scoped to Amp; never called for any other agent type.
298
+ *
299
+ * @returns {{ path: string, version: string|null } | null}
300
+ */
301
+ _verifyAmpBinary() {
302
+ // Drop the 30s PATH/whichBinary cache so we see the freshly-installed dir
303
+ // (e.g. ~/.amp/bin) instead of the pre-install snapshot.
304
+ try { clearBinaryLookupCache(); } catch {}
305
+
306
+ const isWin = process.platform === 'win32';
307
+ const names = isWin ? ['amp.exe', 'amp.cmd', 'amp'] : ['amp'];
308
+ const candidates = [];
309
+ // 1) Whatever the shared cross-platform resolver finds on the enhanced PATH
310
+ // (already includes ~/.amp/bin).
311
+ const resolved = this._whichBinary('amp');
312
+ if (resolved) candidates.push(resolved);
313
+ // 2) Honor a custom AMP_HOME, then the installer's default ~/.amp/bin —
314
+ // covers a binary that exists but whose dir isn't on PATH yet.
315
+ const binDirs = [];
316
+ if (process.env.AMP_HOME) binDirs.push(path.join(process.env.AMP_HOME, 'bin'));
317
+ binDirs.push(path.join(os.homedir(), '.amp', 'bin'));
318
+ for (const dir of binDirs) {
319
+ for (const name of names) candidates.push(path.join(dir, name));
320
+ }
321
+
322
+ let found = null;
323
+ for (const c of candidates) {
324
+ try { if (c && fs.existsSync(c)) { found = c; break; } } catch {}
325
+ }
326
+ if (!found) return null;
327
+
328
+ let version = null;
329
+ try {
330
+ version = require('child_process').execSync(`"${found}" --version`, {
331
+ stdio: ['ignore', 'pipe', 'ignore'],
332
+ timeout: 8000,
333
+ env: getEnhancedEnv(),
334
+ windowsHide: true,
335
+ encoding: 'utf-8',
336
+ }).trim() || null;
337
+ } catch {
338
+ // Best-effort only — file existence above is the hard condition.
339
+ }
340
+ return { path: found, version };
341
+ }
342
+
343
+ /**
344
+ * Amp-only: message shown when the install command exits 0 but no runnable
345
+ * amp binary can be found. Points at the canonical (or AMP_HOME) location.
346
+ */
347
+ _ampBinaryNotFoundMessage() {
348
+ const isWin = process.platform === 'win32';
349
+ const baseDir = process.env.AMP_HOME
350
+ ? path.join(process.env.AMP_HOME, 'bin')
351
+ : path.join(os.homedir(), '.amp', 'bin');
352
+ const expected = path.join(baseDir, isWin ? 'amp.exe' : 'amp');
353
+ return (
354
+ 'Amp install command completed, but the Amp CLI binary could not be found.\n\n' +
355
+ `Expected path:\n${expected}`
356
+ );
357
+ }
358
+
359
+ // Hermes's install.ps1 can print "[X] Installation failed" (e.g. its bundled
360
+ // `uv` step fails) yet still exit 0, so exit-code-only success is unreliable.
361
+ // Confirm a real hermes binary exists before recording the install. Mirrors
362
+ // the paths the HermesAdapter._findHermesBinary() searches.
363
+ _verifyHermesBinary() {
364
+ try { clearBinaryLookupCache(); } catch {}
365
+ const isWin = process.platform === 'win32';
366
+ const home = os.homedir();
367
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
368
+ const candidates = [];
369
+ const resolved = this._whichBinary('hermes');
370
+ if (resolved) candidates.push(resolved);
371
+ if (isWin) {
372
+ candidates.push(
373
+ path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.exe'),
374
+ path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.cmd'),
375
+ path.join(localAppData, 'hermes', 'bin', 'hermes.exe'),
376
+ path.join(localAppData, 'hermes', 'bin', 'hermes.cmd'),
377
+ path.join(home, '.hermes', 'bin', 'hermes.exe'),
378
+ path.join(home, '.hermes', 'bin', 'hermes.cmd'),
379
+ path.join(home, '.local', 'bin', 'hermes.exe'),
380
+ path.join(home, '.local', 'bin', 'hermes.cmd'),
381
+ );
382
+ } else {
383
+ candidates.push(
384
+ path.join(home, '.hermes', 'bin', 'hermes'),
385
+ path.join(home, '.local', 'bin', 'hermes'),
386
+ '/opt/homebrew/bin/hermes',
387
+ '/usr/local/bin/hermes',
388
+ );
389
+ }
390
+ let found = null;
391
+ for (const c of candidates) {
392
+ try { if (c && fs.existsSync(c)) { found = c; break; } } catch {}
393
+ }
394
+ return found ? { path: found } : null;
395
+ }
396
+
397
+ _hermesBinaryNotFoundMessage() {
398
+ const isWin = process.platform === 'win32';
399
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
400
+ const expected = isWin
401
+ ? path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.exe')
402
+ : path.join(os.homedir(), '.hermes', 'bin', 'hermes');
403
+ return (
404
+ 'Hermes install command completed, but the Hermes CLI binary could not be found\n' +
405
+ "(its installer can report a uv/setup failure yet still exit 0).\n\n" +
406
+ `Expected path:\n${expected}`
407
+ );
408
+ }
409
+
147
410
  /**
148
411
  * Find the binary path for an agent type.
149
412
  */
@@ -164,18 +427,26 @@ class Installer {
164
427
  installed: false,
165
428
  binary: null,
166
429
  version: null,
430
+ compatible: null,
431
+ min_version: null,
167
432
  ready: false,
168
433
  auth_mode: null,
434
+ auth_status: null,
169
435
  execution_mode: 'unavailable',
436
+ reason: REASON.NOT_INSTALLED,
170
437
  message: 'Not installed',
171
438
  };
172
439
  }
173
440
 
441
+ const apiReadiness = this._evaluateReadiness(agentType, entry, null);
174
442
  return {
175
443
  installed: true,
176
444
  binary: null,
177
445
  version: null,
178
- ...this._evaluateReadiness(agentType, entry, null),
446
+ compatible: true,
447
+ min_version: null,
448
+ reason: readinessReason(true, apiReadiness.ready),
449
+ ...apiReadiness,
179
450
  };
180
451
  }
181
452
 
@@ -185,15 +456,61 @@ class Installer {
185
456
  installed: false,
186
457
  binary: null,
187
458
  version: null,
459
+ compatible: null,
460
+ min_version: null,
188
461
  ready: false,
189
462
  auth_mode: null,
463
+ auth_status: null,
190
464
  execution_mode: 'unavailable',
465
+ reason: REASON.NOT_INSTALLED,
191
466
  message: 'Not installed',
192
467
  };
193
468
  }
194
469
 
195
470
  const checkCmd = entry && entry.install ? entry.install.check_command : null;
196
- const versionCmd = checkCmd || `${entry && entry.install && entry.install.binary || agentType} --version`;
471
+ // Prefer the resolved absolute binary path (quoted) over the bare name:
472
+ // it's unambiguous and lets `--version` succeed for binaries resolved via
473
+ // the package-bin fallback (which are not on PATH).
474
+ const versionCmd = checkCmd || `"${binary}" --version`;
475
+
476
+ const version = this._detectVersion(binary, versionCmd);
477
+ const readiness = this._evaluateReadiness(agentType, entry, binary);
478
+
479
+ // Generic minimum-version gate (no per-agent special-casing). An entry opts
480
+ // in by declaring `install.min_version`. Below the floor we keep
481
+ // installed=true but force compatible=false + ready=false so the launcher
482
+ // blocks the run and prompts an upgrade (never shows "not installed"). When
483
+ // the version can't be parsed we report compatible=null ("unknown") rather
484
+ // than falsely passing the gate, and do NOT hard-block readiness.
485
+ const gate = this._evaluateCompatibility(entry, version);
486
+ if (gate.compatible === false) {
487
+ readiness.ready = false;
488
+ readiness.message = gate.message;
489
+ }
490
+
491
+ return {
492
+ installed: true,
493
+ binary,
494
+ version,
495
+ compatible: gate.compatible,
496
+ min_version: gate.minVersion,
497
+ // Installed is proven (binary resolved). A not-ready installed agent is a
498
+ // login/config state, never "not installed" — version mismatch aside.
499
+ reason:
500
+ gate.compatible === false
501
+ ? REASON.VERSION_INCOMPATIBLE
502
+ : readinessReason(true, readiness.ready),
503
+ ...readiness,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Detect a binary's version string, cached briefly. Returns null on failure.
509
+ */
510
+ _detectVersion(binary, versionCmd) {
511
+ const key = `${binary}\0${versionCmd}`;
512
+ const cached = versionCache.get(key);
513
+ if (cached && (Date.now() - cached.ts) < VERSION_CACHE_TTL_MS) return cached.version;
197
514
 
198
515
  let version = null;
199
516
  try {
@@ -203,13 +520,42 @@ class Installer {
203
520
  env: getEnhancedEnv(),
204
521
  timeout: 10000,
205
522
  }).trim();
206
- // Extract version number (e.g. "openclaw 2024.1.5" → "2024.1.5")
207
523
  const match = raw.match(/(\d+[\d.]+\d+)/);
208
- version = match ? match[1] : raw.split('\n')[0];
524
+ version = match ? match[1] : (raw.split('\n')[0] || null);
209
525
  } catch {}
210
526
 
211
- const readiness = this._evaluateReadiness(agentType, entry, binary);
212
- return { installed: true, binary, version, ...readiness };
527
+ versionCache.set(key, { version, ts: Date.now() });
528
+ return version;
529
+ }
530
+
531
+ /**
532
+ * Evaluate `install.min_version` against a detected version. Generic for any
533
+ * agent. Returns { compatible: true|false|null, minVersion, message }.
534
+ * • no min_version declared → compatible:true (gate disabled)
535
+ * • version unparseable → compatible:null ("unknown", don't false-pass)
536
+ * • version < min_version → compatible:false (block + upgrade hint)
537
+ * • version >= min_version → compatible:true
538
+ */
539
+ _evaluateCompatibility(entry, version) {
540
+ // Opt-in floor declared under either install.min_version or
541
+ // check_ready.min_version (e.g. cline pins check_ready.min_version).
542
+ const minVersion = (entry && (
543
+ (entry.install && entry.install.min_version) ||
544
+ (entry.check_ready && entry.check_ready.min_version)
545
+ )) || null;
546
+ if (!minVersion) return { compatible: true, minVersion: null, message: null };
547
+ const cmp = compareVersions(version, minVersion);
548
+ if (cmp === null) {
549
+ return { compatible: null, minVersion, message: `Version unknown (requires >= ${minVersion})` };
550
+ }
551
+ if (cmp < 0) {
552
+ return {
553
+ compatible: false,
554
+ minVersion,
555
+ message: `${(entry.label || entry.name || 'This agent')} ${version} is too old — upgrade to ${minVersion} or newer.`,
556
+ };
557
+ }
558
+ return { compatible: true, minVersion, message: null };
213
559
  }
214
560
 
215
561
  _evaluateReadiness(agentType, entry, binary) {
@@ -218,6 +564,7 @@ class Installer {
218
564
  return {
219
565
  ready: true,
220
566
  auth_mode: null,
567
+ auth_status: 'ready',
221
568
  execution_mode: 'unavailable',
222
569
  message: 'Ready',
223
570
  };
@@ -230,6 +577,13 @@ class Installer {
230
577
  const envAnyReady = this._hasAnyValue(process.env, checkReady.env_vars);
231
578
  const savedAnyReady = !!(checkReady.saved_env_key && savedEnv[checkReady.saved_env_key]);
232
579
  const credsReady = this._checkCredsReady(checkReady);
580
+ // Content-free credential probes (opt-in via check_ready). Detect agents
581
+ // whose sign-in is a local OAuth/credential FILE (e.g. Gemini's
582
+ // ~/.gemini/oauth_creds.json) or a service-account file path, WITHOUT
583
+ // parsing or logging the token. Additive: agents that don't declare
584
+ // creds_no_parse / creds_path_env get identical behavior to before.
585
+ const credsFile = this._evaluateCredsFile(checkReady);
586
+ const credsPathReady = this._checkCredsPathEnv(checkReady, savedEnv);
233
587
 
234
588
  let cliReady = false;
235
589
  if (checkReady.status_command && binary) {
@@ -240,6 +594,7 @@ class Installer {
240
594
  return {
241
595
  ready: true,
242
596
  auth_mode: 'api_key',
597
+ auth_status: 'ready',
243
598
  execution_mode: 'direct',
244
599
  message: 'Ready',
245
600
  };
@@ -249,6 +604,7 @@ class Installer {
249
604
  return {
250
605
  ready: true,
251
606
  auth_mode: 'cli_login',
607
+ auth_status: 'ready',
252
608
  execution_mode: 'subprocess',
253
609
  message: 'Ready',
254
610
  };
@@ -261,23 +617,45 @@ class Installer {
261
617
  return {
262
618
  ready: true,
263
619
  auth_mode: 'api_key',
620
+ auth_status: 'ready',
264
621
  execution_mode: 'direct',
265
622
  message: 'Ready',
266
623
  };
267
624
  }
268
625
 
269
- if (credsReady) {
626
+ if (credsReady || credsFile.ready || credsPathReady) {
270
627
  return {
271
628
  ready: true,
272
629
  auth_mode: 'cli_login',
630
+ auth_status: 'ready',
273
631
  execution_mode: 'subprocess',
274
632
  message: 'Ready',
275
633
  };
276
634
  }
277
635
 
636
+ // A credential file was found but could not be read (e.g. permissions).
637
+ // That is NOT "no credentials" — report it as 'unknown' with a distinct
638
+ // message so the UI never mislabels a real (if unreadable) login as
639
+ // "Not logged in". Only reachable for agents that opt into creds_no_parse.
640
+ if (credsFile.unreadable) {
641
+ return {
642
+ ready: false,
643
+ auth_mode: null,
644
+ auth_status: 'unknown',
645
+ execution_mode: 'unavailable',
646
+ message: checkReady.unreadable_message || 'Credentials were found but could not be read.',
647
+ };
648
+ }
649
+
650
+ // No positive signal. For agents whose auth cannot be reliably probed
651
+ // without running them (generic `check_ready.unverifiable`), this is
652
+ // 'unknown' — they may already be signed in via a CLI login / keychain /
653
+ // gh — NOT a definitive 'no_credentials'. The launcher can show "unknown"
654
+ // and still allow a launch attempt; the real run is the final authority.
278
655
  return {
279
656
  ready: false,
280
657
  auth_mode: null,
658
+ auth_status: checkReady.unverifiable ? 'unknown' : 'no_credentials',
281
659
  execution_mode: 'unavailable',
282
660
  message: checkReady.not_ready_message || 'Not configured',
283
661
  };
@@ -316,6 +694,74 @@ class Installer {
316
694
  return false;
317
695
  }
318
696
 
697
+ /** Expand a leading "~" to the running user's home directory. */
698
+ _expandHome(p) {
699
+ if (typeof p !== 'string' || !p) return p;
700
+ if (p === '~') return os.homedir();
701
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
702
+ return p;
703
+ }
704
+
705
+ /**
706
+ * Existence/readability of a credential file WITHOUT reading its contents —
707
+ * never loads a token/key into memory. Returns 'present' | 'unreadable' |
708
+ * 'absent'. A non-empty readable file (or a non-empty directory) is 'present';
709
+ * a path that exists but cannot be statted/read is 'unreadable'. Resolves "~"
710
+ * against the running user's home (the daemon/launcher user), not the
711
+ * renderer/browser user.
712
+ */
713
+ _credsFileState(checkReady) {
714
+ if (!checkReady || !checkReady.creds_file) return 'absent';
715
+ const p = this._expandHome(checkReady.creds_file);
716
+ let exists = false;
717
+ try { exists = fs.existsSync(p); } catch { return 'unreadable'; }
718
+ if (!exists) return 'absent';
719
+ try {
720
+ const st = fs.statSync(p);
721
+ if (st.isDirectory()) return fs.readdirSync(p).length > 0 ? 'present' : 'absent';
722
+ fs.accessSync(p, fs.constants.R_OK);
723
+ return st.size > 0 ? 'present' : 'absent';
724
+ } catch {
725
+ return 'unreadable';
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Opt-in, content-free creds-file readiness. Active ONLY when
731
+ * `check_ready.creds_no_parse` is set, so existing creds_file agents (which
732
+ * parse + match `creds_key` via _checkCredsReady) keep their exact behavior.
733
+ * Returns { ready, unreadable }.
734
+ */
735
+ _evaluateCredsFile(checkReady) {
736
+ if (!checkReady || !checkReady.creds_file || !checkReady.creds_no_parse) {
737
+ return { ready: false, unreadable: false };
738
+ }
739
+ const state = this._credsFileState(checkReady);
740
+ return { ready: state === 'present', unreadable: state === 'unreadable' };
741
+ }
742
+
743
+ /**
744
+ * Treat the named env vars as paths to a credential FILE (e.g.
745
+ * GOOGLE_APPLICATION_CREDENTIALS) and count one as ready only when its target
746
+ * exists and is a readable file — a non-empty value alone is NOT enough.
747
+ * Checks both the process env and the agent's saved env.
748
+ */
749
+ _checkCredsPathEnv(checkReady, savedEnv) {
750
+ const names = checkReady && checkReady.creds_path_env;
751
+ if (!Array.isArray(names) || !names.length) return false;
752
+ for (const name of names) {
753
+ const raw = ((process.env[name] || (savedEnv && savedEnv[name]) || '') + '').trim();
754
+ if (!raw) continue;
755
+ try {
756
+ const p = this._expandHome(raw);
757
+ if (!fs.existsSync(p)) continue;
758
+ fs.accessSync(p, fs.constants.R_OK);
759
+ if (fs.statSync(p).isFile()) return true;
760
+ } catch { /* unreadable / bad path → not a usable credential */ }
761
+ }
762
+ return false;
763
+ }
764
+
319
765
  /**
320
766
  * Check a macOS Keychain generic-password entry. If creds_key is provided the
321
767
  * stored value is parsed as JSON and required to contain that key; otherwise any
@@ -385,6 +831,37 @@ class Installer {
385
831
  }
386
832
 
387
833
  const output = await this._execShell(cmd);
834
+
835
+ // Aider-only: the curl/uv/pipx installer can exit 0 without landing a
836
+ // runnable (or genuine) binary, so verify the real CLI exists BEFORE
837
+ // recording the install. Other agent types keep the original
838
+ // "exit 0 → mark installed" behavior.
839
+ if (agentType === 'aider') {
840
+ const aider = this._verifyAiderBinary();
841
+ if (!aider) {
842
+ throw new Error(this._aiderBinaryNotFoundMessage());
843
+ }
844
+ this._markInstalled(agentType);
845
+ return {
846
+ success: true,
847
+ output: `${output}\nAider CLI resolved: ${aider.path}${aider.version ? ` (${aider.version})` : ''}`,
848
+ };
849
+ }
850
+
851
+ // Amp-only: the install script can exit 0 without landing a runnable
852
+ // binary, so verify the real CLI exists BEFORE recording the install.
853
+ if (agentType === 'amp') {
854
+ const amp = this._verifyAmpBinary();
855
+ if (!amp) {
856
+ throw new Error(this._ampBinaryNotFoundMessage());
857
+ }
858
+ this._markInstalled(agentType);
859
+ return {
860
+ success: true,
861
+ output: `${output}\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}`,
862
+ };
863
+ }
864
+
388
865
  this._markInstalled(agentType);
389
866
  return { success: true, output };
390
867
  }
@@ -502,6 +979,43 @@ class Installer {
502
979
  proc.on('error', (err) => reject(err));
503
980
  proc.on('close', (code) => {
504
981
  if (code === 0) {
982
+ // Aider-only: confirm a real, genuine binary exists before recording
983
+ // the install (verify-before-mark; never writes a marker on
984
+ // failure). Other agent types are unaffected.
985
+ if (agentType === 'aider') {
986
+ const aider = this._verifyAiderBinary();
987
+ if (!aider) {
988
+ const msg = this._aiderBinaryNotFoundMessage();
989
+ if (onData) onData(`\n${msg}\n`);
990
+ reject(new Error(msg));
991
+ return;
992
+ }
993
+ if (onData) onData(`\nAider CLI resolved: ${aider.path}${aider.version ? ` (${aider.version})` : ''}\n`);
994
+ }
995
+ // Amp-only: confirm a real binary exists before recording the
996
+ // install (verify-before-mark; never writes a marker on failure).
997
+ if (agentType === 'amp') {
998
+ const amp = this._verifyAmpBinary();
999
+ if (!amp) {
1000
+ const msg = this._ampBinaryNotFoundMessage();
1001
+ if (onData) onData(`\n${msg}\n`);
1002
+ reject(new Error(msg));
1003
+ return;
1004
+ }
1005
+ if (onData) onData(`\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}\n`);
1006
+ }
1007
+ // Hermes-only: its install.ps1 can exit 0 after a failed uv/setup step,
1008
+ // so verify a real binary exists before recording the install.
1009
+ if (agentType === 'hermes') {
1010
+ const hermes = this._verifyHermesBinary();
1011
+ if (!hermes) {
1012
+ const msg = this._hermesBinaryNotFoundMessage();
1013
+ if (onData) onData(`\n${msg}\n`);
1014
+ reject(new Error(msg));
1015
+ return;
1016
+ }
1017
+ if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
1018
+ }
505
1019
  this._markInstalled(agentType);
506
1020
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
507
1021
  resolve({ success: true, command: displayCmd });
@@ -723,6 +1237,51 @@ class Installer {
723
1237
  const found = whichBinary(candidate);
724
1238
  if (found) return found;
725
1239
  }
1240
+ // Fallback: resolve the installed package's OWN bin from its package.json
1241
+ // when npm did not create a node_modules/.bin/<binary> shim for the
1242
+ // top-level package. This happens for packages whose `bin` path is
1243
+ // declared as "./bin/x" (e.g. Cline): a local `npm install --prefix DIR`
1244
+ // links dependency bins but not the explicitly-installed root package's,
1245
+ // so a PATH lookup finds nothing even though the CLI is present and
1246
+ // runnable. Generic and backward-compatible — only runs after PATH misses.
1247
+ const pkgBin = this._resolvePackageBin(agentType, entry, binary);
1248
+ if (pkgBin) return pkgBin;
1249
+ return null;
1250
+ }
1251
+
1252
+ /**
1253
+ * Resolve the absolute path to a package's own executable from its
1254
+ * package.json `bin` field, searched in the runtime and legacy prefixes.
1255
+ * Returns null when not installed there or the bin file is missing.
1256
+ */
1257
+ _resolvePackageBin(agentType, entry, binary) {
1258
+ // Derive the npm package name the same way getInstallInfo does.
1259
+ const npmPkg = entry && entry.install ? entry.install.npm_package : null;
1260
+ const installCmd = entry && entry.install ? this._getInstallCommand(entry.install) : null;
1261
+ let npmPkgFromCmd = null;
1262
+ if (!npmPkg && installCmd && installCmd.includes('npm install')) {
1263
+ const m = installCmd.match(/npm install\s+(?:-g\s+)?(@?[\w-]+(?:\/[\w-]+)?)(?:@\S*)?$/);
1264
+ if (m) npmPkgFromCmd = m[1];
1265
+ }
1266
+ const pkgName = npmPkg || npmPkgFromCmd || binary;
1267
+ const prefixes = [
1268
+ path.join(getRuntimePrefix(agentType), 'node_modules'),
1269
+ path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
1270
+ ];
1271
+ for (const modules of prefixes) {
1272
+ const pkgDir = path.join(modules, pkgName);
1273
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
1274
+ try {
1275
+ if (!fs.existsSync(pkgJsonPath)) continue;
1276
+ const bin = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).bin;
1277
+ let rel = null;
1278
+ if (typeof bin === 'string') rel = bin;
1279
+ else if (bin && typeof bin === 'object') rel = bin[binary] || bin[pkgName] || Object.values(bin)[0];
1280
+ if (!rel) continue;
1281
+ const abs = path.join(pkgDir, rel);
1282
+ if (fs.existsSync(abs)) return abs;
1283
+ } catch {}
1284
+ }
726
1285
  return null;
727
1286
  }
728
1287
 
@@ -770,8 +1329,10 @@ class Installer {
770
1329
  // Drop the 30s PATH/whichBinary caches so the very next getInstallInfo
771
1330
  // sees the freshly-created bin dir (e.g. ~/.cursor/bin) instead of the
772
1331
  // pre-install snapshot. Without this, install completes but UI keeps
773
- // showing "not installed" until the cache expires.
1332
+ // showing "not installed" until the cache expires. Also drop the version
1333
+ // cache so an upgrade's new version is reflected immediately.
774
1334
  try { clearBinaryLookupCache(); } catch {}
1335
+ try { clearVersionCache(); } catch {}
775
1336
  }
776
1337
 
777
1338
  _markUninstalled(agentType) {
@@ -794,6 +1355,7 @@ class Installer {
794
1355
 
795
1356
  // Symmetric with _markInstalled: invalidate so detection re-runs cleanly.
796
1357
  try { clearBinaryLookupCache(); } catch {}
1358
+ try { clearVersionCache(); } catch {}
797
1359
  }
798
1360
 
799
1361
  // -- Shell env + exec --
@@ -1207,4 +1769,4 @@ class Installer {
1207
1769
  }
1208
1770
  }
1209
1771
 
1210
- module.exports = { Installer };
1772
+ module.exports = { Installer, compareVersions, clearVersionCache };