@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/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +112 -115
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +44 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/adapters/workspace-prompt.js +0 -79
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +36 -7
- package/src/config.js +8 -2
- package/src/daemon.js +165 -51
- package/src/installer.js +572 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
- package/src/update-check.js +26 -14
- package/src/workspace-client.js +31 -29
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
return
|
|
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 };
|