@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/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 +108 -7
- 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 +10 -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/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +24 -7
- package/src/config.js +8 -2
- package/src/daemon.js +86 -5
- package/src/installer.js +509 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
return
|
|
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 };
|