@openagents-org/agent-launcher 0.2.193 → 0.2.194

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
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const { execSync, exec } = require('child_process');
6
+ const { execSync, exec, execFile, spawnSync } = require('child_process');
7
7
  const {
8
8
  whichBinary,
9
9
  getEnhancedEnv,
@@ -21,12 +21,54 @@ const { canBlock } = require('./probe-mode');
21
21
  const { EnvManager } = require('./env');
22
22
  const { nodeDistUrls, installRegistry } = require('./mirrors');
23
23
  const { readinessReason, REASON } = require('./adapters/health-status');
24
+ const { resolveCursorNodeEntry } = require('./adapters/cursor-node-entry');
24
25
  const { checkInstallPrereqs, missingPrereqError } = require('./install-preflight');
25
26
  const { detectShadowedNodeShims, shadowedNodeWarning } = require('./node-shims');
26
27
  const { fixSpawnHelpers } = require('./node-pty-perms');
28
+ const { isPostinstallStub } = require('./postinstall-stub');
29
+
30
+ /**
31
+ * OPENAGENTS_NO_SCRIPT_REPAIR=1: never run an install script npm skipped
32
+ * because of ignore-scripts — fail the install with the explanation instead.
33
+ */
34
+ function scriptRepairDisabled() {
35
+ const v = String(process.env.OPENAGENTS_NO_SCRIPT_REPAIR || '').trim().toLowerCase();
36
+ return v !== '' && v !== '0' && v !== 'false' && v !== 'no';
37
+ }
27
38
 
28
39
  const STATUS_CACHE_TTL_MS = 10000;
29
40
  const statusCache = new Map();
41
+ /** Sign-in verdicts from a CLI's own status command, by command line. */
42
+ const loginStatusCache = new Map();
43
+ const LOGIN_STATUS_TIMEOUT_MS = 8000;
44
+ /** Last `cmdkey /list` dump (Windows Credential Manager targets, no secrets). */
45
+ let credentialListCache = null;
46
+ /** True while a background (non-blocking) `cmdkey /list` refresh is running. */
47
+ let credentialListRefreshing = false;
48
+
49
+ /**
50
+ * Read a CLI's status output for its sign-in state: true (signed in), false
51
+ * (signed out), or null when the output does not say.
52
+ *
53
+ * `check_ready.logged_out_pattern` / `logged_in_pattern` are the desktop app's
54
+ * loggedOutPattern / loggedInPattern, and this is its loginVerdict: one is
55
+ * enough for a CLI that exits 0 either way, because a clean run that matched
56
+ * nothing then stands for the pattern's opposite.
57
+ */
58
+ function loginVerdict(checkReady, out, code) {
59
+ const compile = (source) => {
60
+ try { return source ? new RegExp(source, 'i') : null; } catch { return null; }
61
+ };
62
+ const signedIn = compile(checkReady && checkReady.logged_in_pattern);
63
+ const signedOut = compile(checkReady && checkReady.logged_out_pattern);
64
+ const text = String(out || '');
65
+ if (signedIn && signedIn.test(text)) return true;
66
+ if (signedOut && signedOut.test(text)) return false;
67
+ if (!text.trim() || code !== 0) return null;
68
+ if (signedIn) return false;
69
+ if (signedOut) return true;
70
+ return null;
71
+ }
30
72
 
31
73
  /**
32
74
  * How long a streaming install may produce NO output before it is treated as
@@ -89,9 +131,10 @@ function compareVersions(a, b) {
89
131
  return 0;
90
132
  }
91
133
 
92
- /** Drop the version cache (called on install/uninstall). */
134
+ /** Drop the version and sign-in caches (called on install/uninstall). */
93
135
  function clearVersionCache() {
94
136
  versionCache.clear();
137
+ loginStatusCache.clear();
95
138
  }
96
139
 
97
140
  /**
@@ -622,13 +665,19 @@ class Installer {
622
665
  * shebang), so it's applied on every platform for consistency.
623
666
  */
624
667
  _versionProbeCommand(binary) {
668
+ return this._cliCommand(binary, ['--version']);
669
+ }
670
+
671
+ /** `<binary> <args>` as a command line, for any short probe of a resolved CLI. */
672
+ _cliCommand(binary, args) {
673
+ const tail = args.join(' ');
625
674
  // A binary inside WSL has to be asked through wsl.exe; running the Linux
626
675
  // path as a Windows command yields "not recognized" and the marketplace
627
676
  // then shows an installed agent with no version at all.
628
- const bridged = bridgedCommandString(binary, ['--version']);
677
+ const bridged = bridgedCommandString(binary, args);
629
678
  if (bridged) return bridged;
630
679
  if (/\.(mjs|cjs|js)$/i.test(binary)) {
631
- return `"${this._nodeBinary()}" "${binary}" --version`;
680
+ return `"${this._nodeBinary()}" "${binary}" ${tail}`;
632
681
  }
633
682
  // Same problem, no extension to spot it by: npm's package bin is often an
634
683
  // extensionless `#!/usr/bin/env node` script (node_modules/cline/bin/cline),
@@ -636,9 +685,9 @@ class Installer {
636
685
  // run it — the probe dies with "is not recognized as an internal or external
637
686
  // command" — and the agent then shows up in the app with no version at all.
638
687
  if (isNodeShebangScript(binary)) {
639
- return `"${this._nodeBinary()}" "${binary}" --version`;
688
+ return `"${this._nodeBinary()}" "${binary}" ${tail}`;
640
689
  }
641
- return `"${binary}" --version`;
690
+ return `"${binary}" ${tail}`;
642
691
  }
643
692
 
644
693
  /**
@@ -821,9 +870,6 @@ class Installer {
821
870
  // A registry entry may require a non-empty JSON object rather than mere
822
871
  // parseability. In that mode the stricter _evaluateCredsFile path below is
823
872
  // authoritative; the legacy parser treats an empty {} as ready.
824
- const credsReady = checkReady.creds_json_has_entries
825
- ? false
826
- : this._checkCredsReady(checkReady, agentType);
827
873
  // Content-free credential probes (opt-in via check_ready). Detect agents
828
874
  // whose sign-in is a local OAuth/credential FILE (e.g. Gemini's
829
875
  // ~/.gemini/oauth_creds.json) or a service-account file path, WITHOUT
@@ -870,6 +916,12 @@ class Installer {
870
916
  };
871
917
  }
872
918
 
919
+ // Evaluated only after every key/env path above has had its say: it may
920
+ // shell out (macOS keychain, Windows `cmdkey`), which a key-configured
921
+ // agent never needs.
922
+ const credsReady = checkReady.creds_json_has_entries
923
+ ? false
924
+ : this._checkCredsReady(checkReady, agentType);
873
925
  if (credsReady || credsFile.ready || credsPathReady) {
874
926
  return {
875
927
  ready: true,
@@ -880,11 +932,27 @@ class Installer {
880
932
  };
881
933
  }
882
934
 
935
+ // Nothing in the env or on disk says so, but a CLI may keep its sign-in
936
+ // where only it looks. Cursor's is in the OS keychain on macOS, under
937
+ // %APPDATA% on Windows and ~/.config on Linux, and the `authInfo` read
938
+ // above is only a profile it caches beside them — so a signed-in user
939
+ // whose config lacked it read "installed but not signed in" for good.
940
+ const signedIn = this._checkLoginStatus(checkReady, binary);
941
+ if (signedIn === true) {
942
+ return {
943
+ ready: true,
944
+ auth_mode: 'cli_login',
945
+ auth_status: 'ready',
946
+ execution_mode: 'subprocess',
947
+ message: 'Ready',
948
+ };
949
+ }
950
+
883
951
  // A credential file was found but could not be read (e.g. permissions).
884
952
  // That is NOT "no credentials" — report it as 'unknown' with a distinct
885
953
  // message so the UI never mislabels a real (if unreadable) login as
886
954
  // "Not logged in". Only reachable for agents that opt into creds_no_parse.
887
- if (credsFile.unreadable) {
955
+ if (credsFile.unreadable && signedIn !== false) {
888
956
  return {
889
957
  ready: false,
890
958
  auth_mode: null,
@@ -902,7 +970,8 @@ class Installer {
902
970
  return {
903
971
  ready: false,
904
972
  auth_mode: null,
905
- auth_status: checkReady.unverifiable ? 'unknown' : 'no_credentials',
973
+ // The CLI's own "not logged in" settles it; only its silence is unknown.
974
+ auth_status: checkReady.unverifiable && signedIn !== false ? 'unknown' : 'no_credentials',
906
975
  execution_mode: 'unavailable',
907
976
  message: checkReady.not_ready_message || 'Not configured',
908
977
  };
@@ -938,6 +1007,12 @@ class Installer {
938
1007
  }
939
1008
  }
940
1009
 
1010
+ if (checkReady.credential_target && process.platform === 'win32') {
1011
+ if (this._checkWindowsCredential(checkReady.credential_target)) {
1012
+ return true;
1013
+ }
1014
+ }
1015
+
941
1016
  return false;
942
1017
  }
943
1018
 
@@ -1075,6 +1150,127 @@ class Installer {
1075
1150
  }
1076
1151
  }
1077
1152
 
1153
+ /**
1154
+ * Sign-in as the agent's own CLI reports it: `check_ready.status_args` run
1155
+ * against the resolved binary and read by loginVerdict. Returns true, false,
1156
+ * or null when there is no answer (no such check, a timeout, a crash).
1157
+ *
1158
+ * Unlike status_command this reads the OUTPUT, for a CLI that exits 0
1159
+ * whether or not anyone is signed in.
1160
+ */
1161
+ _checkLoginStatus(checkReady, binary) {
1162
+ const args = checkReady.status_args;
1163
+ if (!binary || !Array.isArray(args) || !args.length) return null;
1164
+ if (!checkReady.logged_out_pattern && !checkReady.logged_in_pattern) return null;
1165
+ // These can still land on a shell command line (a WSL binary, a Windows
1166
+ // .cmd launcher); a flag or a subcommand is all a status check needs.
1167
+ if (!args.every((a) => typeof a === 'string' && /^[\w.=:-]+$/.test(a))) return null;
1168
+
1169
+ const command = this._cliCommand(binary, args);
1170
+ const cached = loginStatusCache.get(command);
1171
+ if (cached && (Date.now() - cached.ts) < STATUS_CACHE_TTL_MS) return cached.verdict;
1172
+ // A second or more of CLI startup — see probe-mode.js.
1173
+ if (!canBlock()) return cached ? cached.verdict : null;
1174
+
1175
+ let verdict = null;
1176
+ try {
1177
+ const run = this._spawnLoginStatus(binary, args, command);
1178
+ if (!run.error) {
1179
+ verdict = loginVerdict(checkReady, `${run.stdout || ''}\n${run.stderr || ''}`, run.status);
1180
+ }
1181
+ } catch {}
1182
+
1183
+ loginStatusCache.set(command, { verdict, ts: Date.now() });
1184
+ return verdict;
1185
+ }
1186
+
1187
+ /**
1188
+ * Run a status check without a shell wherever one is not needed. On Windows
1189
+ * Cursor's launcher is cursor-agent.cmd → PowerShell → node.exe, the chain
1190
+ * the adapter no longer starts a turn through; when its node.exe + index.js
1191
+ * can be found, node is started directly here too. A WSL binary, or any
1192
+ * other Windows launcher (.cmd shims need cmd.exe), keeps the command line.
1193
+ */
1194
+ _spawnLoginStatus(binary, args, command) {
1195
+ const run = this._spawnSyncForTest || spawnSync;
1196
+ const opts = {
1197
+ encoding: 'utf-8',
1198
+ stdio: ['ignore', 'pipe', 'pipe'],
1199
+ env: getEnhancedEnv(),
1200
+ cwd: os.homedir(),
1201
+ timeout: LOGIN_STATUS_TIMEOUT_MS,
1202
+ windowsHide: true,
1203
+ };
1204
+ if (bridgedCommandString(binary, args)) return run(command, { ...opts, shell: true });
1205
+ if (process.platform === 'win32') {
1206
+ const own = resolveCursorNodeEntry(binary, opts.env);
1207
+ if (!own) return run(command, { ...opts, shell: true });
1208
+ const [node, entry] = own;
1209
+ // What cursor-agent.cmd would have set before handing over to node.
1210
+ if (!opts.env.CURSOR_INVOKED_AS) opts.env = { ...opts.env, CURSOR_INVOKED_AS: path.basename(binary) };
1211
+ return run(node, [entry, ...args], { ...opts, shell: false });
1212
+ }
1213
+ if (/\.(mjs|cjs|js)$/i.test(binary) || isNodeShebangScript(binary)) {
1214
+ return run(this._nodeBinary(), [binary, ...args], { ...opts, shell: false });
1215
+ }
1216
+ return run(binary, args, { ...opts, shell: false });
1217
+ }
1218
+
1219
+ /**
1220
+ * Whether Windows Credential Manager holds a credential for `target` — the
1221
+ * Windows half of keychain_service.
1222
+ *
1223
+ * Antigravity is what this exists for. agy keeps its Google sign-in as the
1224
+ * generic credential `gemini:antigravity` and writes NOTHING to disk that
1225
+ * names the account, so every other tier here comes back empty and a user
1226
+ * looking at their own address in the CLI's header is told they are not
1227
+ * signed in.
1228
+ *
1229
+ * `cmdkey /list` is the only reader available without a native module, and
1230
+ * unlike the macOS `security` call it never prints the secret itself — only
1231
+ * targets and user names. Two properties of its output shape this:
1232
+ *
1233
+ * 目标: LegacyGeneric:target=gemini:antigravity
1234
+ * 用户: antigravity
1235
+ *
1236
+ * the LABELS are localized, and the whole thing is written in the console's
1237
+ * OEM codepage (936/GBK above), which utf-8 decoding mangles. So the output is
1238
+ * read as latin1 — lossless for the ASCII we care about — and searched for the
1239
+ * `target=<name>` fragment, which is neither localized nor codepage-dependent.
1240
+ */
1241
+ _checkWindowsCredential(target) {
1242
+ if (!target) return false;
1243
+ const cached = credentialListCache;
1244
+ const fresh = cached && (Date.now() - cached.ts) < STATUS_CACHE_TTL_MS;
1245
+ // Same rule as _checkLoginStatus: never wait on a process in Electron main.
1246
+ if (!fresh && canBlock()) {
1247
+ let out = null;
1248
+ try {
1249
+ const run = (this._spawnSyncForTest || spawnSync)('cmdkey', ['/list'], {
1250
+ stdio: ['ignore', 'pipe', 'ignore'],
1251
+ timeout: 5000,
1252
+ windowsHide: true,
1253
+ encoding: 'latin1',
1254
+ });
1255
+ if (!run.error && run.status === 0) out = run.stdout || '';
1256
+ } catch {}
1257
+ credentialListCache = { out, ts: Date.now() };
1258
+ } else if (!fresh && !credentialListRefreshing) {
1259
+ // Answer from the last dump now; the refresh lands for the next call.
1260
+ credentialListRefreshing = true;
1261
+ try {
1262
+ execFile('cmdkey', ['/list'], { timeout: 5000, windowsHide: true, encoding: 'latin1' }, (err, stdout) => {
1263
+ credentialListRefreshing = false;
1264
+ credentialListCache = { out: err ? null : String(stdout || ''), ts: Date.now() };
1265
+ });
1266
+ } catch {
1267
+ credentialListRefreshing = false;
1268
+ }
1269
+ }
1270
+ return !!(credentialListCache && credentialListCache.out != null
1271
+ && credentialListHasTarget(credentialListCache.out, target));
1272
+ }
1273
+
1078
1274
  _checkStatusCommand(command) {
1079
1275
  const cached = statusCache.get(command);
1080
1276
  if (cached && (Date.now() - cached.ts) < STATUS_CACHE_TTL_MS) {
@@ -1172,8 +1368,15 @@ class Installer {
1172
1368
  };
1173
1369
  }
1174
1370
 
1371
+ // An npm package whose postinstall was skipped exits 0 with a placeholder
1372
+ // where its CLI should be. See _findPostinstallStub.
1373
+ // No live log here, so what the repair says goes into the returned output.
1374
+ let repairLog = '';
1375
+ const problem = await this.finishNpmInstall(agentType, (d) => { repairLog += d; });
1376
+ if (problem) throw new Error(problem);
1377
+
1175
1378
  this._markInstalled(agentType);
1176
- return { success: true, output };
1379
+ return { success: true, output: repairLog ? `${output}\n${repairLog.trim()}` : output };
1177
1380
  }
1178
1381
 
1179
1382
  /**
@@ -1426,9 +1629,23 @@ class Installer {
1426
1629
  const fixed = fixSpawnHelpers(installCwd);
1427
1630
  if (fixed.length && onData) onData(`\nMade ${fixed.length} node-pty spawn-helper(s) executable.\n`);
1428
1631
  }
1429
- this._markInstalled(agentType);
1430
- if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
1431
- resolve({ success: true, command: displayCmd });
1632
+ const markDone = () => {
1633
+ this._markInstalled(agentType);
1634
+ if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
1635
+ resolve({ success: true, command: displayCmd });
1636
+ };
1637
+ // An npm package whose postinstall was skipped exits 0 with a
1638
+ // placeholder where its CLI should be. See _findPostinstallStub.
1639
+ const stub = this._findPostinstallStub(agentType);
1640
+ if (stub) {
1641
+ this._repairPostinstallStub(agentType, stub, onData).then((problem) => {
1642
+ if (!problem) return markDone();
1643
+ if (onData) onData(`\n${problem}\n`);
1644
+ reject(new Error(problem));
1645
+ }, reject);
1646
+ return;
1647
+ }
1648
+ markDone();
1432
1649
  } else {
1433
1650
  // A partial install can leave a placeholder stub at
1434
1651
  // bin/<binary>.exe and npm-generated cmd-shims under
@@ -1481,6 +1698,166 @@ class Installer {
1481
1698
  }
1482
1699
  }
1483
1700
 
1701
+ /** The npm package an agent installs from, or null when it isn't an npm install. */
1702
+ _npmPackageFor(install) {
1703
+ if (!install) return null;
1704
+ if (install.npm_package) return install.npm_package;
1705
+ const cmd = this._getInstallCommand(install);
1706
+ if (!cmd || !cmd.includes('npm install')) return null;
1707
+ const m = cmd.match(/npm install\s+(?:-g\s+)?(@?[\w-]+(?:\/[\w-]+)?)(?:@\S*)?$/);
1708
+ return m ? m[1] : null;
1709
+ }
1710
+
1711
+ /**
1712
+ * The managed copy of an agent whose executable is still the placeholder its
1713
+ * npm package ships, or null.
1714
+ *
1715
+ * opencode-ai (and @anthropic-ai/claude-code) publish a few hundred bytes of
1716
+ * shell as their `bin` and have their postinstall script swap the real
1717
+ * native binary in. With `ignore-scripts=true` in the user's npmrc — a
1718
+ * common hardening setting — npm skips that script and still exits 0, so
1719
+ * "exit 0 → mark installed" recorded an agent that cannot start: the
1720
+ * marketplace said installed, the model list would not load, and every
1721
+ * message failed. An install is not done until the placeholder is gone.
1722
+ *
1723
+ * @returns {{ pkgName: string, bin: string, prefix: string } | null}
1724
+ */
1725
+ _findPostinstallStub(agentType) {
1726
+ const entry = this.registry.getEntry(agentType);
1727
+ const install = entry && entry.install;
1728
+ const pkgName = this._npmPackageFor(install);
1729
+ if (!pkgName) return null;
1730
+ const bin = resolveManagedNpmPackageBin(agentType, pkgName, install.binary || agentType);
1731
+ if (!bin || !isPostinstallStub(bin)) return null;
1732
+ const prefixes = [getRuntimePrefix(agentType), path.join(os.homedir(), '.openagents', 'nodejs')];
1733
+ const prefix = prefixes.find((p) => bin.startsWith(path.join(p, 'node_modules') + path.sep));
1734
+ return prefix ? { pkgName, bin, prefix } : null;
1735
+ }
1736
+
1737
+ /**
1738
+ * Run the install scripts npm skipped, for this one package. `npm rebuild
1739
+ * <pkg>` is npm's own way of doing that, and a flag on the command line
1740
+ * outranks the `ignore-scripts=true` in an npmrc — which stays in force for
1741
+ * every other package and every other install.
1742
+ */
1743
+ _rebuildWithScripts(pkgName, prefix) {
1744
+ const args = ['rebuild', pkgName, '--prefix', prefix, '--ignore-scripts=false', '--foreground-scripts'];
1745
+ const env = this._buildShellEnv();
1746
+ const run = this._execFileForTest || execFile;
1747
+ const runNpm = (file, argv, extra = {}) => new Promise((resolve, reject) => {
1748
+ run(file, argv, {
1749
+ encoding: 'utf-8',
1750
+ timeout: 300000,
1751
+ maxBuffer: 16 * 1024 * 1024,
1752
+ env,
1753
+ cwd: prefix,
1754
+ windowsHide: true,
1755
+ ...extra,
1756
+ }, (error, stdout, stderr) => {
1757
+ const output = `${stdout || ''}\n${stderr || ''}`.trim();
1758
+ if (error) reject(new Error(output || error.message));
1759
+ else resolve(output);
1760
+ });
1761
+ });
1762
+ const direct = this._resolveNodeNpmCli();
1763
+ if (!direct) {
1764
+ const win = process.platform === 'win32';
1765
+ const npm = whichBinary('npm') || (win ? 'npm.cmd' : 'npm');
1766
+ // npm.cmd is a batch file and only runs through cmd.exe. With shell:true
1767
+ // Node joins the argv with spaces, so quote the ones that contain any.
1768
+ if (win && /\.(cmd|bat)$/i.test(npm)) {
1769
+ const q = (a) => (/\s/.test(a) ? `"${a}"` : a);
1770
+ return runNpm(q(npm), args.map(q), { shell: true });
1771
+ }
1772
+ return runNpm(npm, args);
1773
+ }
1774
+ // As for the install itself: the script's own `node` has to resolve to the
1775
+ // runtime npm is running on.
1776
+ const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
1777
+ const nodeDir = path.dirname(direct.node);
1778
+ if (!(env[pathKey] || '').includes(nodeDir)) {
1779
+ env[pathKey] = nodeDir + path.delimiter + (env[pathKey] || '');
1780
+ }
1781
+ return runNpm(direct.node, [direct.npmCli, ...args]);
1782
+ }
1783
+
1784
+ /**
1785
+ * The check install() and installStreaming() make once npm has exited 0, for
1786
+ * a caller that ran npm itself — the launcher installs a version-pinned agent
1787
+ * (opencode among them) with its own npm call. Resolves null when the CLI is
1788
+ * real, repaired if it had to be; otherwise the message to show, with the
1789
+ * package already taken back out.
1790
+ */
1791
+ async finishNpmInstall(agentType, onData) {
1792
+ const stub = this._findPostinstallStub(agentType);
1793
+ return stub ? this._repairPostinstallStub(agentType, stub, onData) : null;
1794
+ }
1795
+
1796
+ /**
1797
+ * Whether the CLI at `binPath` (a symlink or npm shim is followed) is the
1798
+ * placeholder of a package whose postinstall was skipped. Check only — for
1799
+ * a caller that updated a copy we do not manage and must not repair.
1800
+ */
1801
+ isPostinstallStubAt(binPath) {
1802
+ return isPostinstallStub(binPath);
1803
+ }
1804
+
1805
+ /**
1806
+ * Finish an install that left a placeholder (see _findPostinstallStub).
1807
+ * Resolves null once the real binary is in place, otherwise the message the
1808
+ * user should see.
1809
+ *
1810
+ * When the script cannot be made to run, the package comes back out — which
1811
+ * is what npm does itself when a postinstall fails. Left in place it reads
1812
+ * as installed (its package.json is there), and deleting only the
1813
+ * placeholder is worse: the next `npm install` finds the package up to date,
1814
+ * restores nothing, and with no placeholder left to notice the install gets
1815
+ * recorded as good.
1816
+ */
1817
+ async _repairPostinstallStub(agentType, stub, onData) {
1818
+ const entry = this.registry.getEntry(agentType);
1819
+ const label = (entry && entry.label) || agentType;
1820
+ // The user asked npm to skip install scripts; this lets them say the
1821
+ // repair must not run one either.
1822
+ const optedOut = scriptRepairDisabled();
1823
+ let output = '';
1824
+ if (optedOut) {
1825
+ if (onData) {
1826
+ onData(`\nnpm skipped ${stub.pkgName}'s install script (ignore-scripts), so the ${label} CLI is only `
1827
+ + 'a placeholder. OPENAGENTS_NO_SCRIPT_REPAIR=1 is set, so it is not being run.\n');
1828
+ }
1829
+ } else {
1830
+ if (onData) {
1831
+ onData(`\nnpm skipped ${stub.pkgName}'s install script (ignore-scripts), so the ${label} CLI is only `
1832
+ + 'a placeholder. Running it for this one package now — set OPENAGENTS_NO_SCRIPT_REPAIR=1 to refuse.\n');
1833
+ }
1834
+ try {
1835
+ output = await this._rebuildWithScripts(stub.pkgName, stub.prefix);
1836
+ } catch (e) {
1837
+ output = (e && e.message) || '';
1838
+ }
1839
+ }
1840
+ if (!optedOut && !isPostinstallStub(stub.bin) && fs.existsSync(stub.bin)) {
1841
+ try { clearBinaryLookupCache(); } catch {}
1842
+ if (onData) onData(`${label} CLI is in place: ${stub.bin}\n`);
1843
+ return null;
1844
+ }
1845
+ try {
1846
+ this._cleanStaleShims(agentType);
1847
+ fs.rmSync(path.join(stub.prefix, 'node_modules', stub.pkgName), { recursive: true, force: true });
1848
+ } catch {}
1849
+ const tail = output.trim().slice(-2000);
1850
+ return `${label} was downloaded, but its setup could not be finished: ${stub.pkgName}'s `
1851
+ + 'postinstall script did not run, so its CLI is only a placeholder. npm is set to skip '
1852
+ + 'install scripts (ignore-scripts=true in an npmrc), and '
1853
+ + (optedOut
1854
+ ? 'OPENAGENTS_NO_SCRIPT_REPAIR=1 stops OpenAgents from running the script for this one package. '
1855
+ : 'running the script for this one package did not produce the binary either '
1856
+ + '(set OPENAGENTS_NO_SCRIPT_REPAIR=1 if you do not want OpenAgents to try). ')
1857
+ + `Allow install scripts for npm, then install ${label} again.`
1858
+ + (tail ? `\n\nnpm output:\n${tail}` : '');
1859
+ }
1860
+
1484
1861
  /**
1485
1862
  * Uninstall an agent runtime.
1486
1863
  * @returns {Promise<{success: boolean, output: string}>}
@@ -2510,4 +2887,24 @@ class Installer {
2510
2887
  }
2511
2888
  }
2512
2889
 
2513
- module.exports = { Installer, compareVersions, clearVersionCache };
2890
+ /**
2891
+ * Whether a `cmdkey /list` dump lists a credential for `target`.
2892
+ *
2893
+ * Pure, so the matching is testable without a Windows box. Compared
2894
+ * case-insensitively because the stored casing is the CLI's choice, not the
2895
+ * user's, and matched on the `target=` fragment rather than a whole line so the
2896
+ * `LegacyGeneric:` / `Domain:` prefix Windows puts in front of it is irrelevant.
2897
+ * The name must end the line, so `gemini:antigravity` does not match a
2898
+ * `gemini:antigravity-staging` entry.
2899
+ */
2900
+ function credentialListHasTarget(output, target) {
2901
+ const name = String(target || '').trim();
2902
+ if (!name) return false;
2903
+ return new RegExp(`target=${escapeRegExp(name)}[ \\t]*\\r?$`, 'im').test(String(output || ''));
2904
+ }
2905
+
2906
+ function escapeRegExp(text) {
2907
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2908
+ }
2909
+
2910
+ module.exports = { Installer, compareVersions, clearVersionCache, loginVerdict, credentialListHasTarget };