@openagents-org/agent-launcher 0.2.185 → 0.2.187

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,7 +4,17 @@ 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, aiderBinDirs, resolveBinaryInKnownDirs } = require('./paths');
7
+ const {
8
+ whichBinary,
9
+ getEnhancedEnv,
10
+ getRuntimePrefix,
11
+ clearBinaryLookupCache,
12
+ aiderBinDirs,
13
+ resolveBinaryInKnownDirs,
14
+ resolveManagedNpmBinary,
15
+ resolveManagedNpmPackageBin,
16
+ isNodeShebangScript,
17
+ } = require('./paths');
8
18
  const { isWslBinary, bridgedCommandString, wslHomeUnc } = require('./wsl');
9
19
  const { canBlock } = require('./probe-mode');
10
20
  const { EnvManager } = require('./env');
@@ -610,6 +620,14 @@ class Installer {
610
620
  if (/\.(mjs|cjs|js)$/i.test(binary)) {
611
621
  return `"${this._nodeBinary()}" "${binary}" --version`;
612
622
  }
623
+ // Same problem, no extension to spot it by: npm's package bin is often an
624
+ // extensionless `#!/usr/bin/env node` script (node_modules/cline/bin/cline),
625
+ // which is what we resolve whenever npm wrote no .cmd shim. cmd.exe cannot
626
+ // run it — the probe dies with "is not recognized as an internal or external
627
+ // command" — and the agent then shows up in the app with no version at all.
628
+ if (isNodeShebangScript(binary)) {
629
+ return `"${this._nodeBinary()}" "${binary}" --version`;
630
+ }
613
631
  return `"${binary}" --version`;
614
632
  }
615
633
 
@@ -1083,6 +1101,19 @@ class Installer {
1083
1101
  throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
1084
1102
  }
1085
1103
 
1104
+ let bootstrapEnv = this._buildShellEnv();
1105
+ let installEnv;
1106
+ if (agentType === 'hermes' && process.platform === 'win32') {
1107
+ const prepared = this._prepareWindowsPowerShellInstall(cmd, bootstrapEnv);
1108
+ cmd = prepared.command;
1109
+ bootstrapEnv = prepared.env;
1110
+ installEnv = prepared.env;
1111
+ }
1112
+
1113
+ // Same uv bootstrap the streaming path does — `agn install hermes` and the
1114
+ // workspace's remote create must not behave differently from the button.
1115
+ await this._bootstrapManagedUv(agentType, bootstrapEnv, null);
1116
+
1086
1117
  // Use bundled node/npm if system npm not available
1087
1118
  if (cmd.startsWith('npm install')) {
1088
1119
  const prefixDir = getRuntimePrefix(agentType);
@@ -1091,7 +1122,7 @@ class Installer {
1091
1122
  cmd = this._resolveNpmCommand(args);
1092
1123
  }
1093
1124
 
1094
- const output = await this._execShell(cmd);
1125
+ const output = await this._execShell(cmd, 300000, installEnv);
1095
1126
 
1096
1127
  // Aider-only: the curl/uv/pipx installer can exit 0 without landing a
1097
1128
  // runnable (or genuine) binary, so verify the real CLI exists BEFORE
@@ -1159,9 +1190,23 @@ class Installer {
1159
1190
  await this.installNodejs(onData);
1160
1191
  }
1161
1192
 
1162
- const env = this._buildShellEnv();
1193
+ let env = this._buildShellEnv();
1163
1194
  const isWin = process.platform === 'win32';
1164
1195
 
1196
+ // Hermes's registry command names the inbox Windows PowerShell host for
1197
+ // compatibility, but some Windows environments expose only PowerShell 7.
1198
+ // Resolve the host that actually exists and keep module paths from the
1199
+ // launcher's own PowerShell runtime out of both installer processes.
1200
+ if (agentType === 'hermes' && isWin) {
1201
+ const prepared = this._prepareWindowsPowerShellInstall(rawCmd, env);
1202
+ rawCmd = prepared.command;
1203
+ env = prepared.env;
1204
+ }
1205
+
1206
+ // Hand the installer a uv it can use, so its own fragile bootstrap is
1207
+ // never reached. No-op for every agent but hermes on Windows.
1208
+ await this._bootstrapManagedUv(agentType, env, onData);
1209
+
1165
1210
  // Build the spawn invocation. Three shapes:
1166
1211
  // 1. npm install via bundled `node npm-cli.js` — argv array, no shell.
1167
1212
  // Path-safe under non-ASCII home dirs on Windows (preferred).
@@ -1297,15 +1342,27 @@ class Installer {
1297
1342
  // The watchdog already rejected; this close is the kill it performed.
1298
1343
  if (stalled) return;
1299
1344
  if (code === 0) {
1345
+ // An installer that exits 0 without leaving a runnable binary is
1346
+ // the class below (aider/amp/hermes/cursor). The "…could not be
1347
+ // found" sentence alone says nothing about WHY, and the reason is
1348
+ // sitting in the output we just captured — hermes's install.ps1, for
1349
+ // one, prints "[X] Installation failed: uv installation failed" and
1350
+ // exits 0, and without the tail the launcher could only shrug
1351
+ // ("The installer stopped before it could finish"). Carry the tail
1352
+ // into the rejection so the message the user sees, and the copy that
1353
+ // classifies it, both have the real error in them.
1354
+ const failVerify = (message) => {
1355
+ const tail = outputTail.trim();
1356
+ if (onData) onData(`\n${message}\n`);
1357
+ reject(new Error(tail ? `${message}\n\nInstaller output:\n${tail}` : message));
1358
+ };
1300
1359
  // Aider-only: confirm a real, genuine binary exists before recording
1301
1360
  // the install (verify-before-mark; never writes a marker on
1302
1361
  // failure). Other agent types are unaffected.
1303
1362
  if (agentType === 'aider') {
1304
1363
  const aider = this._verifyAiderBinary();
1305
1364
  if (!aider) {
1306
- const msg = this._aiderBinaryNotFoundMessage();
1307
- if (onData) onData(`\n${msg}\n`);
1308
- reject(new Error(msg));
1365
+ failVerify(this._aiderBinaryNotFoundMessage());
1309
1366
  return;
1310
1367
  }
1311
1368
  if (onData) onData(`\nAider CLI resolved: ${aider.path}${aider.version ? ` (${aider.version})` : ''}\n`);
@@ -1315,9 +1372,7 @@ class Installer {
1315
1372
  if (agentType === 'amp') {
1316
1373
  const amp = this._verifyAmpBinary();
1317
1374
  if (!amp) {
1318
- const msg = this._ampBinaryNotFoundMessage();
1319
- if (onData) onData(`\n${msg}\n`);
1320
- reject(new Error(msg));
1375
+ failVerify(this._ampBinaryNotFoundMessage());
1321
1376
  return;
1322
1377
  }
1323
1378
  if (onData) onData(`\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}\n`);
@@ -1327,9 +1382,7 @@ class Installer {
1327
1382
  if (agentType === 'hermes') {
1328
1383
  const hermes = this._verifyHermesBinary();
1329
1384
  if (!hermes) {
1330
- const msg = this._hermesBinaryNotFoundMessage();
1331
- if (onData) onData(`\n${msg}\n`);
1332
- reject(new Error(msg));
1385
+ failVerify(this._hermesBinaryNotFoundMessage());
1333
1386
  return;
1334
1387
  }
1335
1388
  if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
@@ -1346,9 +1399,7 @@ class Installer {
1346
1399
  if (agentType === 'cursor') {
1347
1400
  const cursor = this._verifyCursorBinary();
1348
1401
  if (!cursor) {
1349
- const msg = this._cursorBinaryNotFoundMessage();
1350
- if (onData) onData(`\n${msg}\n`);
1351
- reject(new Error(msg));
1402
+ failVerify(this._cursorBinaryNotFoundMessage());
1352
1403
  return;
1353
1404
  }
1354
1405
  if (onData) onData(`\nCursor CLI resolved: ${cursor.path}\n`);
@@ -1683,29 +1734,7 @@ class Installer {
1683
1734
  const pkgName = npmPkg || npmPkgFromCmd;
1684
1735
  if (!pkgName) return null; // script-installed agent: nothing of ours to prefer
1685
1736
 
1686
- const prefixes = [
1687
- path.join(getRuntimePrefix(agentType), 'node_modules'),
1688
- path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
1689
- ];
1690
- const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
1691
- for (const modules of prefixes) {
1692
- try {
1693
- if (!fs.existsSync(path.join(modules, pkgName, 'package.json'))) continue;
1694
- } catch { continue; }
1695
- for (const name of [binary, ...aliases]) {
1696
- for (const ext of exts) {
1697
- const candidate = path.join(modules, '.bin', `${name}${ext}`);
1698
- try {
1699
- if (fs.existsSync(candidate)) return candidate;
1700
- } catch {}
1701
- }
1702
- }
1703
- // npm does not always link the root package's own bin — same case
1704
- // _resolvePackageBin exists for.
1705
- const own = this._resolvePackageBin(agentType, entry, binary);
1706
- if (own) return own;
1707
- }
1708
- return null;
1737
+ return resolveManagedNpmBinary(agentType, pkgName, [binary, ...aliases]);
1709
1738
  }
1710
1739
 
1711
1740
  /**
@@ -1723,25 +1752,7 @@ class Installer {
1723
1752
  if (m) npmPkgFromCmd = m[1];
1724
1753
  }
1725
1754
  const pkgName = npmPkg || npmPkgFromCmd || binary;
1726
- const prefixes = [
1727
- path.join(getRuntimePrefix(agentType), 'node_modules'),
1728
- path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
1729
- ];
1730
- for (const modules of prefixes) {
1731
- const pkgDir = path.join(modules, pkgName);
1732
- const pkgJsonPath = path.join(pkgDir, 'package.json');
1733
- try {
1734
- if (!fs.existsSync(pkgJsonPath)) continue;
1735
- const bin = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).bin;
1736
- let rel = null;
1737
- if (typeof bin === 'string') rel = bin;
1738
- else if (bin && typeof bin === 'object') rel = bin[binary] || bin[pkgName] || Object.values(bin)[0];
1739
- if (!rel) continue;
1740
- const abs = path.join(pkgDir, rel);
1741
- if (fs.existsSync(abs)) return abs;
1742
- } catch {}
1743
- }
1744
- return null;
1755
+ return resolveManagedNpmPackageBin(agentType, pkgName, binary);
1745
1756
  }
1746
1757
 
1747
1758
  // -- Markers --
@@ -1907,6 +1918,168 @@ class Installer {
1907
1918
  return env;
1908
1919
  }
1909
1920
 
1921
+ /**
1922
+ * Build an isolated environment for a PowerShell child process.
1923
+ *
1924
+ * PowerShell constructs PSModulePath for the host that is starting. Passing
1925
+ * through a value created by another host (for example pwsh -> powershell)
1926
+ * can make it load binary modules from the wrong runtime. Remove every
1927
+ * casing of the key and let the child rebuild its own defaults.
1928
+ */
1929
+ _powerShellChildEnv(env) {
1930
+ const childEnv = { ...env };
1931
+ for (const key of Object.keys(childEnv)) {
1932
+ if (key.toLowerCase() === 'psmodulepath') delete childEnv[key];
1933
+ }
1934
+ return childEnv;
1935
+ }
1936
+
1937
+ /**
1938
+ * Resolve a PowerShell host without assuming Windows PowerShell 5.1 exists.
1939
+ * Prefer the Windows inbox host for compatibility with existing installer
1940
+ * scripts, then fall back to pwsh from PATH or its standard install folder.
1941
+ *
1942
+ * `platform` and `exists` are seams so the Windows rules run on every CI
1943
+ * platform without touching the real machine.
1944
+ */
1945
+ _resolveWindowsPowerShellHost(
1946
+ env,
1947
+ platform = process.platform,
1948
+ exists = fs.existsSync,
1949
+ ) {
1950
+ if (platform !== 'win32') return null;
1951
+ const value = (name) => {
1952
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
1953
+ return key ? env[key] : '';
1954
+ };
1955
+ const systemRoot = value('SystemRoot') || 'C:\\Windows';
1956
+ const windowsPowerShell = path.win32.join(
1957
+ systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe',
1958
+ );
1959
+ if (exists(windowsPowerShell)) return windowsPowerShell;
1960
+
1961
+ for (const rawDir of (value('Path') || '').split(';')) {
1962
+ const dir = rawDir.trim().replace(/^"|"$/g, '');
1963
+ if (!dir) continue;
1964
+ const candidate = path.win32.join(dir, 'pwsh.exe');
1965
+ if (exists(candidate)) return candidate;
1966
+ }
1967
+
1968
+ const programFiles = value('ProgramFiles') || 'C:\\Program Files';
1969
+ const standardPwsh = path.win32.join(programFiles, 'PowerShell', '7', 'pwsh.exe');
1970
+ return exists(standardPwsh) ? standardPwsh : null;
1971
+ }
1972
+
1973
+ /** Replace only the leading PowerShell executable in an install command. */
1974
+ _retargetPowerShellCommand(command, executable) {
1975
+ const hostPrefix = /^(\s*)(?:"[^"]*\\(?:powershell|pwsh)(?:\.exe)?"|(?:powershell|pwsh)(?:\.exe)?)\s+/i;
1976
+ return command.replace(hostPrefix, `$1"${executable}" `);
1977
+ }
1978
+
1979
+ /** Prepare a Windows PowerShell install without leaking another host's modules. */
1980
+ _prepareWindowsPowerShellInstall(
1981
+ command,
1982
+ env,
1983
+ platform = process.platform,
1984
+ exists = fs.existsSync,
1985
+ ) {
1986
+ const host = this._resolveWindowsPowerShellHost(env, platform, exists);
1987
+ if (!host) {
1988
+ throw new Error('Windows PowerShell or PowerShell 7 is required for this installer.');
1989
+ }
1990
+ return {
1991
+ command: this._retargetPowerShellCommand(command, host),
1992
+ env: this._powerShellChildEnv(env),
1993
+ host,
1994
+ };
1995
+ }
1996
+
1997
+ /**
1998
+ * Put uv where hermes's installer looks for it, before that installer runs.
1999
+ *
2000
+ * hermes's Install-Uv bootstraps its OWN copy at <HERMES_HOME>\bin\uv.exe
2001
+ * ("no PATH probing, no conda guards" — a uv already on the machine is not
2002
+ * used), and it does that by spawning a child PowerShell for the astral
2003
+ * installer WITHOUT -NoProfile. When that child cannot run — a profile that
2004
+ * throws, a PSModulePath that cannot load built-in modules — uv never lands,
2005
+ * and hermes prints "Installation failed: uv installation failed" and exits
2006
+ * 0 anyway, leaving a half-installed agent.
2007
+ *
2008
+ * Doing the same download ourselves, from an environment we control, takes
2009
+ * that fragile hop out of the install. Its early
2010
+ * `if (Test-Path $managedUv) { return $true }` then short-circuits the whole
2011
+ * step. Best-effort by design: any failure here is logged and the install
2012
+ * proceeds exactly as it does today, so this can only add successes.
2013
+ *
2014
+ * Windows-only. The Unix install.sh runs the uv installer through `sh`,
2015
+ * which reads no profile and has never shown this failure.
2016
+ *
2017
+ * `platform` and `exists` are seams for tests; production passes neither.
2018
+ * They must be threaded into the host lookup below — without them it falls
2019
+ * back to the real filesystem and can never resolve a Windows path on a
2020
+ * Linux or macOS CI runner, which silently turned every assertion about the
2021
+ * spawn into a no-op.
2022
+ */
2023
+ async _bootstrapManagedUv(
2024
+ agentType,
2025
+ env,
2026
+ onData,
2027
+ platform = process.platform,
2028
+ exists = fs.existsSync,
2029
+ ) {
2030
+ if (agentType !== 'hermes' || platform !== 'win32') return;
2031
+ const home = os.homedir();
2032
+ const hermesHome = env.HERMES_HOME
2033
+ || path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'hermes');
2034
+ const binDir = path.join(hermesHome, 'bin');
2035
+ const managedUv = path.join(binDir, 'uv.exe');
2036
+ try { if (exists(managedUv)) return; } catch { return; }
2037
+
2038
+ if (onData) onData(`\nProvisioning uv for Hermes into ${binDir} ...\n`);
2039
+ try {
2040
+ const psExe = this._resolveWindowsPowerShellHost(env, platform, exists);
2041
+ if (!psExe) throw new Error('no Windows PowerShell or PowerShell 7 host found');
2042
+ fs.mkdirSync(binDir, { recursive: true });
2043
+ await new Promise((resolve, reject) => {
2044
+ // `_spawnForTest` is a seam so a test can inspect the environment this
2045
+ // child is given; production never sets it.
2046
+ const spawnFn = this._spawnForTest || require('child_process').spawn;
2047
+ const child = spawnFn(
2048
+ psExe,
2049
+ [
2050
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
2051
+ // Force UTF-8 out of the child before it prints anything. A
2052
+ // Chinese-locale Windows emits GBK, which reaches the install log
2053
+ // as mojibake — the user saw a wall of "??" where the error was.
2054
+ '-Command',
2055
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8; irm https://astral.sh/uv/install.ps1 | iex',
2056
+ ],
2057
+ {
2058
+ // UV_INSTALL_DIR is what the astral script honours; it is the same
2059
+ // variable hermes sets for its own attempt.
2060
+ env: this._powerShellChildEnv({ ...env, UV_INSTALL_DIR: binDir }),
2061
+ cwd: home,
2062
+ stdio: ['ignore', 'pipe', 'pipe'],
2063
+ windowsHide: true,
2064
+ },
2065
+ );
2066
+ const relay = (d) => { if (onData) onData(String(d)); };
2067
+ if (child.stdout) child.stdout.on('data', relay);
2068
+ if (child.stderr) child.stderr.on('data', relay);
2069
+ child.on('error', reject);
2070
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit code ${code}`))));
2071
+ });
2072
+ } catch (e) {
2073
+ if (onData) onData(`\nCould not provision uv ahead of the installer (${e.message}); letting Hermes try its own.\n`);
2074
+ return;
2075
+ }
2076
+ if (onData) {
2077
+ onData(fs.existsSync(managedUv)
2078
+ ? `uv ready at ${managedUv}\n`
2079
+ : `uv installer finished but ${managedUv} is absent; letting Hermes try its own.\n`);
2080
+ }
2081
+ }
2082
+
1910
2083
  /**
1911
2084
  * True if `node` exists outside the bundled launcher directory. Uses raw
1912
2085
  * process.env.PATH (no enhancement) so we don't accidentally detect the
@@ -2276,9 +2449,9 @@ class Installer {
2276
2449
  return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${escaped}"`;
2277
2450
  }
2278
2451
 
2279
- _execShell(cmd, timeoutMs = 300000) {
2452
+ _execShell(cmd, timeoutMs = 300000, envOverride = null) {
2280
2453
  return new Promise((resolve, reject) => {
2281
- const env = this._buildShellEnv();
2454
+ const env = envOverride || this._buildShellEnv();
2282
2455
 
2283
2456
  let shell = true;
2284
2457
  if (process.platform === 'win32') {
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Ask an OpenAI- or Anthropic-compatible endpoint which models it serves.
5
+ *
6
+ * The daemon answers the workspace's `list_models` command with this, so the
7
+ * model picker offers what the agent's relay actually has, and the key the
8
+ * request needs never leaves the device. Mirrors the launcher's
9
+ * model-catalog listOpenAiModels / listAnthropicModels.
10
+ */
11
+
12
+ const MAX_MODELS = 300;
13
+
14
+ /** The one line worth showing from an error body (relays nest it in JSON). */
15
+ function briefError(text) {
16
+ try {
17
+ const j = JSON.parse(text);
18
+ const msg = typeof j.error === 'string' ? j.error : j.error && j.error.message;
19
+ const line = String(msg || j.message || '').trim();
20
+ if (line) return line.slice(0, 160);
21
+ } catch {}
22
+ return String(text || '').trim().slice(0, 160);
23
+ }
24
+
25
+ function isOfficialAnthropic(url) {
26
+ return url.hostname.toLowerCase() === 'api.anthropic.com';
27
+ }
28
+
29
+ /** `{data: [...]}`, a bare array, or a relay's `{models: [...]}`; ids deduped and sorted. */
30
+ function parseModels(text) {
31
+ let parsed;
32
+ try { parsed = JSON.parse(text); } catch { return []; }
33
+ const list = Array.isArray(parsed)
34
+ ? parsed
35
+ : (parsed && (parsed.data || parsed.models)) || [];
36
+ if (!Array.isArray(list)) return [];
37
+ const seen = new Map();
38
+ for (const entry of list) {
39
+ const id = String(typeof entry === 'string' ? entry : (entry && entry.id) || '').trim();
40
+ if (!id || seen.has(id)) continue;
41
+ const label = entry && typeof entry === 'object' && typeof entry.display_name === 'string'
42
+ ? entry.display_name.trim()
43
+ : '';
44
+ seen.set(id, { id, label: label || id });
45
+ }
46
+ return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id)).slice(0, MAX_MODELS);
47
+ }
48
+
49
+ /**
50
+ * @param {object} opts
51
+ * @param {string} opts.baseUrl The base URL the agent's CLI is pointed at.
52
+ * @param {string} opts.apiKey
53
+ * @param {string} [opts.protocol] 'anthropic', or OpenAI-compatible (default).
54
+ * @returns {Promise<{models: {id: string, label: string}[], error?: string}>}
55
+ */
56
+ async function listEndpointModels({ baseUrl, apiKey, protocol = 'openai', fetchImpl = globalThis.fetch, timeoutMs = 15000 }) {
57
+ let url;
58
+ try {
59
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(baseUrl) ? baseUrl : `http://${baseUrl}`);
60
+ } catch {
61
+ return { models: [], error: 'The configured base URL is not a valid URL.' };
62
+ }
63
+ if (!apiKey) return { models: [], error: 'No API key is configured for this agent.' };
64
+
65
+ const headers = { Accept: 'application/json' };
66
+ let path = url.pathname.replace(/\/+$/, '');
67
+ if (protocol === 'anthropic') {
68
+ // Official takes x-api-key; relays only ever honor a bearer token.
69
+ if (isOfficialAnthropic(url)) headers['x-api-key'] = apiKey;
70
+ else headers.Authorization = `Bearer ${apiKey}`;
71
+ headers['anthropic-version'] = '2023-06-01';
72
+ path = `${path.replace(/\/v1$/, '')}/v1/models`;
73
+ url.search = '?limit=100';
74
+ } else {
75
+ headers.Authorization = `Bearer ${apiKey}`;
76
+ // A bare host means the /v1 API; a path (…/v1, …/v1beta/openai) is used as given.
77
+ path = `${path || '/v1'}/models`;
78
+ url.search = '';
79
+ }
80
+ url.pathname = path;
81
+ url.username = '';
82
+ url.password = '';
83
+
84
+ let res;
85
+ let text;
86
+ try {
87
+ res = await fetchImpl(url.toString(), { headers, signal: AbortSignal.timeout(timeoutMs) });
88
+ text = await res.text();
89
+ } catch (e) {
90
+ const reason = e && e.name === 'TimeoutError' ? 'the endpoint did not answer in time' : (e && e.message) || String(e);
91
+ return { models: [], error: `Could not reach ${url.host}: ${reason}` };
92
+ }
93
+ if (res.status >= 400) {
94
+ return { models: [], error: `HTTP ${res.status}: ${briefError(text)}` };
95
+ }
96
+ const models = parseModels(text);
97
+ return models.length ? { models } : { models: [], error: 'The endpoint listed no models.' };
98
+ }
99
+
100
+ module.exports = { listEndpointModels, parseModels };
package/src/paths.js CHANGED
@@ -1132,6 +1132,105 @@ function aiderBinDirs() {
1132
1132
  /** Executable suffixes to try for a bare binary name, per platform. */
1133
1133
  const BIN_EXTS = IS_WINDOWS ? ['.cmd', '.exe', '.bat', ''] : [''];
1134
1134
 
1135
+ /**
1136
+ * A `#!...node` shebang line, as the Windows launcher matches it (win-exec.ts).
1137
+ * `env -S`/flag forms are allowed; `#!/bin/sh -c node` is NOT a node script.
1138
+ */
1139
+ const NODE_SHEBANG_RE =
1140
+ /^#!\s*(?:(?:\S*[\\/])?env(?:\.exe)?\s+(?:-\S+\s+)*)?(?:\S*[\\/])?node(?:\.exe)?(?:\s|$)/i;
1141
+
1142
+ /**
1143
+ * True when a file is an extensionless Node script — npm's package bin shape
1144
+ * (e.g. node_modules/cline/bin/cline). Windows cannot execute one: handed to
1145
+ * cmd.exe it fails with "is not recognized as an internal or external command",
1146
+ * so every caller that shells out to a resolved binary must prefix `node`.
1147
+ */
1148
+ function isNodeShebangScript(filePath) {
1149
+ if (!filePath) return false;
1150
+ try {
1151
+ const fd = fs.openSync(filePath, 'r');
1152
+ try {
1153
+ const buf = Buffer.alloc(64);
1154
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
1155
+ const firstLine = buf.subarray(0, n).toString('utf-8').split('\n', 1)[0];
1156
+ return NODE_SHEBANG_RE.test(firstLine);
1157
+ } finally {
1158
+ fs.closeSync(fd);
1159
+ }
1160
+ } catch {
1161
+ return false;
1162
+ }
1163
+ }
1164
+
1165
+ /** Managed npm module roots, in the same priority order used by Installer. */
1166
+ function managedNpmModuleDirs(agentType) {
1167
+ const dirs = [
1168
+ path.join(getRuntimePrefix(agentType), 'node_modules'),
1169
+ path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
1170
+ ];
1171
+ return [...new Set(dirs)];
1172
+ }
1173
+
1174
+ /**
1175
+ * Resolve a package's declared `bin` target from an OpenAgents-managed npm
1176
+ * prefix. This covers packages such as Cline whose root package is installed
1177
+ * successfully but npm does not create a node_modules/.bin shim for it.
1178
+ */
1179
+ function resolveManagedNpmPackageBin(agentType, packageName, binaryName) {
1180
+ if (!agentType || !packageName || !binaryName) return null;
1181
+
1182
+ for (const modules of managedNpmModuleDirs(agentType)) {
1183
+ const pkgDir = path.join(modules, packageName);
1184
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
1185
+ try {
1186
+ if (!fs.existsSync(pkgJsonPath)) continue;
1187
+ const bin = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).bin;
1188
+ let rel = null;
1189
+ if (typeof bin === 'string') rel = bin;
1190
+ else if (bin && typeof bin === 'object') {
1191
+ rel = bin[binaryName] || bin[packageName] || Object.values(bin)[0];
1192
+ }
1193
+ if (!rel) continue;
1194
+ const abs = path.join(pkgDir, rel);
1195
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
1196
+ } catch {
1197
+ /* malformed or unreadable package — keep looking */
1198
+ }
1199
+ }
1200
+ return null;
1201
+ }
1202
+
1203
+ /**
1204
+ * Resolve the copy of an npm CLI installed in an OpenAgents-managed prefix.
1205
+ * Package presence gates the lookup so an orphaned shim cannot shadow a
1206
+ * working global install. The package's own bin is the final managed fallback.
1207
+ */
1208
+ function resolveManagedNpmBinary(agentType, packageName, names) {
1209
+ const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
1210
+ if (!agentType || !packageName || !list.length) return null;
1211
+
1212
+ for (const modules of managedNpmModuleDirs(agentType)) {
1213
+ try {
1214
+ if (!fs.existsSync(path.join(modules, packageName, 'package.json'))) continue;
1215
+ } catch {
1216
+ continue;
1217
+ }
1218
+ for (const name of list) {
1219
+ for (const ext of BIN_EXTS) {
1220
+ const candidate = path.join(modules, '.bin', `${name}${ext}`);
1221
+ try {
1222
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
1223
+ } catch {
1224
+ /* unreadable shim — keep looking */
1225
+ }
1226
+ }
1227
+ }
1228
+ const own = resolveManagedNpmPackageBin(agentType, packageName, list[0]);
1229
+ if (own) return own;
1230
+ }
1231
+ return null;
1232
+ }
1233
+
1135
1234
  /**
1136
1235
  * Last-resort binary lookup: walk the directories we already know agent CLIs
1137
1236
  * land in and check the filesystem directly, instead of asking the shell.
@@ -1205,6 +1304,9 @@ module.exports = {
1205
1304
  getEnhancedEnv,
1206
1305
  whichBinary,
1207
1306
  whereBinary,
1307
+ resolveManagedNpmBinary,
1308
+ resolveManagedNpmPackageBin,
1309
+ isNodeShebangScript,
1208
1310
  resolveBinaryInKnownDirs,
1209
1311
  clearBinaryLookupCache,
1210
1312
  primeBinaryLookup,
package/src/probe.js CHANGED
@@ -58,6 +58,24 @@ const CODE = {
58
58
  CLI_ERROR: 'cli_error',
59
59
  };
60
60
 
61
+ /**
62
+ * The install command for THIS device's platform.
63
+ *
64
+ * `entry.install_command` comes off the catalog API, which picks macOS → Linux
65
+ * → Windows with no idea who is asking — so a Windows user was told to fix a
66
+ * missing CLI by running a `curl … | bash` one-liner that cannot work there.
67
+ * The entry's own per-platform `install` block is the authority (same rule as
68
+ * Installer._getInstallCommand); the flat field stays as the fallback for
69
+ * catalog entries that carry nothing else.
70
+ */
71
+ function installCommandFor(entry) {
72
+ const inst = (entry && entry.install) || {};
73
+ const plat = process.platform === 'darwin' ? 'macos'
74
+ : process.platform === 'win32' ? 'windows'
75
+ : 'linux';
76
+ return inst[plat] || inst.command || inst.npm || (entry && entry.install_command) || null;
77
+ }
78
+
61
79
  /** Redact anything that looks like a secret before output leaves the device. */
62
80
  function scrub(text) {
63
81
  if (!text) return '';
@@ -129,7 +147,10 @@ function buildGuidance(code, entry, agentEnv, extra = {}) {
129
147
  switch (code) {
130
148
  case CODE.NOT_INSTALLED:
131
149
  lines.push(`${label} is not installed on this device. Install it from the launcher's Install page.`);
132
- if (entry && entry.install_command) lines.push(`Or install manually: ${entry.install_command}`);
150
+ {
151
+ const cmd = installCommandFor(entry);
152
+ if (cmd) lines.push(`Or install manually: ${cmd}`);
153
+ }
133
154
  break;
134
155
  case CODE.INVALID_API_KEY:
135
156
  case CODE.MISSING_API_KEY:
@@ -388,4 +409,4 @@ async function probeAgentType(connector, type, opts = {}) {
388
409
  });
389
410
  }
390
411
 
391
- module.exports = { probeAgentType, classifyFailure, buildGuidance, authFlavor, scrub, CODE, DEFAULT_TIMEOUT_MS };
412
+ module.exports = { probeAgentType, classifyFailure, buildGuidance, authFlavor, installCommandFor, scrub, CODE, DEFAULT_TIMEOUT_MS };