@openagents-org/agent-launcher 0.2.143 → 0.2.145

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,170 @@ 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
+
147
359
  /**
148
360
  * Find the binary path for an agent type.
149
361
  */
@@ -164,18 +376,26 @@ class Installer {
164
376
  installed: false,
165
377
  binary: null,
166
378
  version: null,
379
+ compatible: null,
380
+ min_version: null,
167
381
  ready: false,
168
382
  auth_mode: null,
383
+ auth_status: null,
169
384
  execution_mode: 'unavailable',
385
+ reason: REASON.NOT_INSTALLED,
170
386
  message: 'Not installed',
171
387
  };
172
388
  }
173
389
 
390
+ const apiReadiness = this._evaluateReadiness(agentType, entry, null);
174
391
  return {
175
392
  installed: true,
176
393
  binary: null,
177
394
  version: null,
178
- ...this._evaluateReadiness(agentType, entry, null),
395
+ compatible: true,
396
+ min_version: null,
397
+ reason: readinessReason(true, apiReadiness.ready),
398
+ ...apiReadiness,
179
399
  };
180
400
  }
181
401
 
@@ -185,15 +405,61 @@ class Installer {
185
405
  installed: false,
186
406
  binary: null,
187
407
  version: null,
408
+ compatible: null,
409
+ min_version: null,
188
410
  ready: false,
189
411
  auth_mode: null,
412
+ auth_status: null,
190
413
  execution_mode: 'unavailable',
414
+ reason: REASON.NOT_INSTALLED,
191
415
  message: 'Not installed',
192
416
  };
193
417
  }
194
418
 
195
419
  const checkCmd = entry && entry.install ? entry.install.check_command : null;
196
- const versionCmd = checkCmd || `${entry && entry.install && entry.install.binary || agentType} --version`;
420
+ // Prefer the resolved absolute binary path (quoted) over the bare name:
421
+ // it's unambiguous and lets `--version` succeed for binaries resolved via
422
+ // the package-bin fallback (which are not on PATH).
423
+ const versionCmd = checkCmd || `"${binary}" --version`;
424
+
425
+ const version = this._detectVersion(binary, versionCmd);
426
+ const readiness = this._evaluateReadiness(agentType, entry, binary);
427
+
428
+ // Generic minimum-version gate (no per-agent special-casing). An entry opts
429
+ // in by declaring `install.min_version`. Below the floor we keep
430
+ // installed=true but force compatible=false + ready=false so the launcher
431
+ // blocks the run and prompts an upgrade (never shows "not installed"). When
432
+ // the version can't be parsed we report compatible=null ("unknown") rather
433
+ // than falsely passing the gate, and do NOT hard-block readiness.
434
+ const gate = this._evaluateCompatibility(entry, version);
435
+ if (gate.compatible === false) {
436
+ readiness.ready = false;
437
+ readiness.message = gate.message;
438
+ }
439
+
440
+ return {
441
+ installed: true,
442
+ binary,
443
+ version,
444
+ compatible: gate.compatible,
445
+ min_version: gate.minVersion,
446
+ // Installed is proven (binary resolved). A not-ready installed agent is a
447
+ // login/config state, never "not installed" — version mismatch aside.
448
+ reason:
449
+ gate.compatible === false
450
+ ? REASON.VERSION_INCOMPATIBLE
451
+ : readinessReason(true, readiness.ready),
452
+ ...readiness,
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Detect a binary's version string, cached briefly. Returns null on failure.
458
+ */
459
+ _detectVersion(binary, versionCmd) {
460
+ const key = `${binary}\0${versionCmd}`;
461
+ const cached = versionCache.get(key);
462
+ if (cached && (Date.now() - cached.ts) < VERSION_CACHE_TTL_MS) return cached.version;
197
463
 
198
464
  let version = null;
199
465
  try {
@@ -203,13 +469,42 @@ class Installer {
203
469
  env: getEnhancedEnv(),
204
470
  timeout: 10000,
205
471
  }).trim();
206
- // Extract version number (e.g. "openclaw 2024.1.5" → "2024.1.5")
207
472
  const match = raw.match(/(\d+[\d.]+\d+)/);
208
- version = match ? match[1] : raw.split('\n')[0];
473
+ version = match ? match[1] : (raw.split('\n')[0] || null);
209
474
  } catch {}
210
475
 
211
- const readiness = this._evaluateReadiness(agentType, entry, binary);
212
- return { installed: true, binary, version, ...readiness };
476
+ versionCache.set(key, { version, ts: Date.now() });
477
+ return version;
478
+ }
479
+
480
+ /**
481
+ * Evaluate `install.min_version` against a detected version. Generic for any
482
+ * agent. Returns { compatible: true|false|null, minVersion, message }.
483
+ * • no min_version declared → compatible:true (gate disabled)
484
+ * • version unparseable → compatible:null ("unknown", don't false-pass)
485
+ * • version < min_version → compatible:false (block + upgrade hint)
486
+ * • version >= min_version → compatible:true
487
+ */
488
+ _evaluateCompatibility(entry, version) {
489
+ // Opt-in floor declared under either install.min_version or
490
+ // check_ready.min_version (e.g. cline pins check_ready.min_version).
491
+ const minVersion = (entry && (
492
+ (entry.install && entry.install.min_version) ||
493
+ (entry.check_ready && entry.check_ready.min_version)
494
+ )) || null;
495
+ if (!minVersion) return { compatible: true, minVersion: null, message: null };
496
+ const cmp = compareVersions(version, minVersion);
497
+ if (cmp === null) {
498
+ return { compatible: null, minVersion, message: `Version unknown (requires >= ${minVersion})` };
499
+ }
500
+ if (cmp < 0) {
501
+ return {
502
+ compatible: false,
503
+ minVersion,
504
+ message: `${(entry.label || entry.name || 'This agent')} ${version} is too old — upgrade to ${minVersion} or newer.`,
505
+ };
506
+ }
507
+ return { compatible: true, minVersion, message: null };
213
508
  }
214
509
 
215
510
  _evaluateReadiness(agentType, entry, binary) {
@@ -218,6 +513,7 @@ class Installer {
218
513
  return {
219
514
  ready: true,
220
515
  auth_mode: null,
516
+ auth_status: 'ready',
221
517
  execution_mode: 'unavailable',
222
518
  message: 'Ready',
223
519
  };
@@ -230,6 +526,13 @@ class Installer {
230
526
  const envAnyReady = this._hasAnyValue(process.env, checkReady.env_vars);
231
527
  const savedAnyReady = !!(checkReady.saved_env_key && savedEnv[checkReady.saved_env_key]);
232
528
  const credsReady = this._checkCredsReady(checkReady);
529
+ // Content-free credential probes (opt-in via check_ready). Detect agents
530
+ // whose sign-in is a local OAuth/credential FILE (e.g. Gemini's
531
+ // ~/.gemini/oauth_creds.json) or a service-account file path, WITHOUT
532
+ // parsing or logging the token. Additive: agents that don't declare
533
+ // creds_no_parse / creds_path_env get identical behavior to before.
534
+ const credsFile = this._evaluateCredsFile(checkReady);
535
+ const credsPathReady = this._checkCredsPathEnv(checkReady, savedEnv);
233
536
 
234
537
  let cliReady = false;
235
538
  if (checkReady.status_command && binary) {
@@ -240,6 +543,7 @@ class Installer {
240
543
  return {
241
544
  ready: true,
242
545
  auth_mode: 'api_key',
546
+ auth_status: 'ready',
243
547
  execution_mode: 'direct',
244
548
  message: 'Ready',
245
549
  };
@@ -249,6 +553,7 @@ class Installer {
249
553
  return {
250
554
  ready: true,
251
555
  auth_mode: 'cli_login',
556
+ auth_status: 'ready',
252
557
  execution_mode: 'subprocess',
253
558
  message: 'Ready',
254
559
  };
@@ -261,23 +566,45 @@ class Installer {
261
566
  return {
262
567
  ready: true,
263
568
  auth_mode: 'api_key',
569
+ auth_status: 'ready',
264
570
  execution_mode: 'direct',
265
571
  message: 'Ready',
266
572
  };
267
573
  }
268
574
 
269
- if (credsReady) {
575
+ if (credsReady || credsFile.ready || credsPathReady) {
270
576
  return {
271
577
  ready: true,
272
578
  auth_mode: 'cli_login',
579
+ auth_status: 'ready',
273
580
  execution_mode: 'subprocess',
274
581
  message: 'Ready',
275
582
  };
276
583
  }
277
584
 
585
+ // A credential file was found but could not be read (e.g. permissions).
586
+ // That is NOT "no credentials" — report it as 'unknown' with a distinct
587
+ // message so the UI never mislabels a real (if unreadable) login as
588
+ // "Not logged in". Only reachable for agents that opt into creds_no_parse.
589
+ if (credsFile.unreadable) {
590
+ return {
591
+ ready: false,
592
+ auth_mode: null,
593
+ auth_status: 'unknown',
594
+ execution_mode: 'unavailable',
595
+ message: checkReady.unreadable_message || 'Credentials were found but could not be read.',
596
+ };
597
+ }
598
+
599
+ // No positive signal. For agents whose auth cannot be reliably probed
600
+ // without running them (generic `check_ready.unverifiable`), this is
601
+ // 'unknown' — they may already be signed in via a CLI login / keychain /
602
+ // gh — NOT a definitive 'no_credentials'. The launcher can show "unknown"
603
+ // and still allow a launch attempt; the real run is the final authority.
278
604
  return {
279
605
  ready: false,
280
606
  auth_mode: null,
607
+ auth_status: checkReady.unverifiable ? 'unknown' : 'no_credentials',
281
608
  execution_mode: 'unavailable',
282
609
  message: checkReady.not_ready_message || 'Not configured',
283
610
  };
@@ -316,6 +643,74 @@ class Installer {
316
643
  return false;
317
644
  }
318
645
 
646
+ /** Expand a leading "~" to the running user's home directory. */
647
+ _expandHome(p) {
648
+ if (typeof p !== 'string' || !p) return p;
649
+ if (p === '~') return os.homedir();
650
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
651
+ return p;
652
+ }
653
+
654
+ /**
655
+ * Existence/readability of a credential file WITHOUT reading its contents —
656
+ * never loads a token/key into memory. Returns 'present' | 'unreadable' |
657
+ * 'absent'. A non-empty readable file (or a non-empty directory) is 'present';
658
+ * a path that exists but cannot be statted/read is 'unreadable'. Resolves "~"
659
+ * against the running user's home (the daemon/launcher user), not the
660
+ * renderer/browser user.
661
+ */
662
+ _credsFileState(checkReady) {
663
+ if (!checkReady || !checkReady.creds_file) return 'absent';
664
+ const p = this._expandHome(checkReady.creds_file);
665
+ let exists = false;
666
+ try { exists = fs.existsSync(p); } catch { return 'unreadable'; }
667
+ if (!exists) return 'absent';
668
+ try {
669
+ const st = fs.statSync(p);
670
+ if (st.isDirectory()) return fs.readdirSync(p).length > 0 ? 'present' : 'absent';
671
+ fs.accessSync(p, fs.constants.R_OK);
672
+ return st.size > 0 ? 'present' : 'absent';
673
+ } catch {
674
+ return 'unreadable';
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Opt-in, content-free creds-file readiness. Active ONLY when
680
+ * `check_ready.creds_no_parse` is set, so existing creds_file agents (which
681
+ * parse + match `creds_key` via _checkCredsReady) keep their exact behavior.
682
+ * Returns { ready, unreadable }.
683
+ */
684
+ _evaluateCredsFile(checkReady) {
685
+ if (!checkReady || !checkReady.creds_file || !checkReady.creds_no_parse) {
686
+ return { ready: false, unreadable: false };
687
+ }
688
+ const state = this._credsFileState(checkReady);
689
+ return { ready: state === 'present', unreadable: state === 'unreadable' };
690
+ }
691
+
692
+ /**
693
+ * Treat the named env vars as paths to a credential FILE (e.g.
694
+ * GOOGLE_APPLICATION_CREDENTIALS) and count one as ready only when its target
695
+ * exists and is a readable file — a non-empty value alone is NOT enough.
696
+ * Checks both the process env and the agent's saved env.
697
+ */
698
+ _checkCredsPathEnv(checkReady, savedEnv) {
699
+ const names = checkReady && checkReady.creds_path_env;
700
+ if (!Array.isArray(names) || !names.length) return false;
701
+ for (const name of names) {
702
+ const raw = ((process.env[name] || (savedEnv && savedEnv[name]) || '') + '').trim();
703
+ if (!raw) continue;
704
+ try {
705
+ const p = this._expandHome(raw);
706
+ if (!fs.existsSync(p)) continue;
707
+ fs.accessSync(p, fs.constants.R_OK);
708
+ if (fs.statSync(p).isFile()) return true;
709
+ } catch { /* unreadable / bad path → not a usable credential */ }
710
+ }
711
+ return false;
712
+ }
713
+
319
714
  /**
320
715
  * Check a macOS Keychain generic-password entry. If creds_key is provided the
321
716
  * stored value is parsed as JSON and required to contain that key; otherwise any
@@ -385,6 +780,37 @@ class Installer {
385
780
  }
386
781
 
387
782
  const output = await this._execShell(cmd);
783
+
784
+ // Aider-only: the curl/uv/pipx installer can exit 0 without landing a
785
+ // runnable (or genuine) binary, so verify the real CLI exists BEFORE
786
+ // recording the install. Other agent types keep the original
787
+ // "exit 0 → mark installed" behavior.
788
+ if (agentType === 'aider') {
789
+ const aider = this._verifyAiderBinary();
790
+ if (!aider) {
791
+ throw new Error(this._aiderBinaryNotFoundMessage());
792
+ }
793
+ this._markInstalled(agentType);
794
+ return {
795
+ success: true,
796
+ output: `${output}\nAider CLI resolved: ${aider.path}${aider.version ? ` (${aider.version})` : ''}`,
797
+ };
798
+ }
799
+
800
+ // Amp-only: the install script can exit 0 without landing a runnable
801
+ // binary, so verify the real CLI exists BEFORE recording the install.
802
+ if (agentType === 'amp') {
803
+ const amp = this._verifyAmpBinary();
804
+ if (!amp) {
805
+ throw new Error(this._ampBinaryNotFoundMessage());
806
+ }
807
+ this._markInstalled(agentType);
808
+ return {
809
+ success: true,
810
+ output: `${output}\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}`,
811
+ };
812
+ }
813
+
388
814
  this._markInstalled(agentType);
389
815
  return { success: true, output };
390
816
  }
@@ -502,6 +928,31 @@ class Installer {
502
928
  proc.on('error', (err) => reject(err));
503
929
  proc.on('close', (code) => {
504
930
  if (code === 0) {
931
+ // Aider-only: confirm a real, genuine binary exists before recording
932
+ // the install (verify-before-mark; never writes a marker on
933
+ // failure). Other agent types are unaffected.
934
+ if (agentType === 'aider') {
935
+ const aider = this._verifyAiderBinary();
936
+ if (!aider) {
937
+ const msg = this._aiderBinaryNotFoundMessage();
938
+ if (onData) onData(`\n${msg}\n`);
939
+ reject(new Error(msg));
940
+ return;
941
+ }
942
+ if (onData) onData(`\nAider CLI resolved: ${aider.path}${aider.version ? ` (${aider.version})` : ''}\n`);
943
+ }
944
+ // Amp-only: confirm a real binary exists before recording the
945
+ // install (verify-before-mark; never writes a marker on failure).
946
+ if (agentType === 'amp') {
947
+ const amp = this._verifyAmpBinary();
948
+ if (!amp) {
949
+ const msg = this._ampBinaryNotFoundMessage();
950
+ if (onData) onData(`\n${msg}\n`);
951
+ reject(new Error(msg));
952
+ return;
953
+ }
954
+ if (onData) onData(`\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}\n`);
955
+ }
505
956
  this._markInstalled(agentType);
506
957
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
507
958
  resolve({ success: true, command: displayCmd });
@@ -723,6 +1174,51 @@ class Installer {
723
1174
  const found = whichBinary(candidate);
724
1175
  if (found) return found;
725
1176
  }
1177
+ // Fallback: resolve the installed package's OWN bin from its package.json
1178
+ // when npm did not create a node_modules/.bin/<binary> shim for the
1179
+ // top-level package. This happens for packages whose `bin` path is
1180
+ // declared as "./bin/x" (e.g. Cline): a local `npm install --prefix DIR`
1181
+ // links dependency bins but not the explicitly-installed root package's,
1182
+ // so a PATH lookup finds nothing even though the CLI is present and
1183
+ // runnable. Generic and backward-compatible — only runs after PATH misses.
1184
+ const pkgBin = this._resolvePackageBin(agentType, entry, binary);
1185
+ if (pkgBin) return pkgBin;
1186
+ return null;
1187
+ }
1188
+
1189
+ /**
1190
+ * Resolve the absolute path to a package's own executable from its
1191
+ * package.json `bin` field, searched in the runtime and legacy prefixes.
1192
+ * Returns null when not installed there or the bin file is missing.
1193
+ */
1194
+ _resolvePackageBin(agentType, entry, binary) {
1195
+ // Derive the npm package name the same way getInstallInfo does.
1196
+ const npmPkg = entry && entry.install ? entry.install.npm_package : null;
1197
+ const installCmd = entry && entry.install ? this._getInstallCommand(entry.install) : null;
1198
+ let npmPkgFromCmd = null;
1199
+ if (!npmPkg && installCmd && installCmd.includes('npm install')) {
1200
+ const m = installCmd.match(/npm install\s+(?:-g\s+)?(@?[\w-]+(?:\/[\w-]+)?)(?:@\S*)?$/);
1201
+ if (m) npmPkgFromCmd = m[1];
1202
+ }
1203
+ const pkgName = npmPkg || npmPkgFromCmd || binary;
1204
+ const prefixes = [
1205
+ path.join(getRuntimePrefix(agentType), 'node_modules'),
1206
+ path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
1207
+ ];
1208
+ for (const modules of prefixes) {
1209
+ const pkgDir = path.join(modules, pkgName);
1210
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
1211
+ try {
1212
+ if (!fs.existsSync(pkgJsonPath)) continue;
1213
+ const bin = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).bin;
1214
+ let rel = null;
1215
+ if (typeof bin === 'string') rel = bin;
1216
+ else if (bin && typeof bin === 'object') rel = bin[binary] || bin[pkgName] || Object.values(bin)[0];
1217
+ if (!rel) continue;
1218
+ const abs = path.join(pkgDir, rel);
1219
+ if (fs.existsSync(abs)) return abs;
1220
+ } catch {}
1221
+ }
726
1222
  return null;
727
1223
  }
728
1224
 
@@ -770,8 +1266,10 @@ class Installer {
770
1266
  // Drop the 30s PATH/whichBinary caches so the very next getInstallInfo
771
1267
  // sees the freshly-created bin dir (e.g. ~/.cursor/bin) instead of the
772
1268
  // pre-install snapshot. Without this, install completes but UI keeps
773
- // showing "not installed" until the cache expires.
1269
+ // showing "not installed" until the cache expires. Also drop the version
1270
+ // cache so an upgrade's new version is reflected immediately.
774
1271
  try { clearBinaryLookupCache(); } catch {}
1272
+ try { clearVersionCache(); } catch {}
775
1273
  }
776
1274
 
777
1275
  _markUninstalled(agentType) {
@@ -794,6 +1292,7 @@ class Installer {
794
1292
 
795
1293
  // Symmetric with _markInstalled: invalidate so detection re-runs cleanly.
796
1294
  try { clearBinaryLookupCache(); } catch {}
1295
+ try { clearVersionCache(); } catch {}
797
1296
  }
798
1297
 
799
1298
  // -- Shell env + exec --
@@ -1207,4 +1706,4 @@ class Installer {
1207
1706
  }
1208
1707
  }
1209
1708
 
1210
- module.exports = { Installer };
1709
+ module.exports = { Installer, compareVersions, clearVersionCache };