amicus 3.2.0 → 3.2.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.2.1] - 2026-07-17
9
+
10
+ ### Fixed
11
+
12
+ - **`opencode` engine now resolves under the npx-launched MCP.** The MCP server is
13
+ registered as `npx -y amicus@latest mcp`, which npm installs with the
14
+ `opencode-windows-*` engine packages **hoisted** beside `amicus/` rather than
15
+ nested under it. The resolver only probed the nested location, so a present,
16
+ runnable engine read as missing and every `amicus_fanout` / `amicus_start` leg
17
+ failed instantly with `engineMissing`. Both the PATH builder and the shared
18
+ binary resolver now probe the hoisted root too (#69).
19
+ - **The AVX2 (default) `opencode` build is searched before the `-baseline`
20
+ fallback.** `ensureNodeModulesBinInPath()` prepended PATH one entry at a time, so
21
+ the search order was the reverse of the source order and AVX2-capable machines
22
+ silently ran the slower pre-AVX2 baseline build. PATH is now built as one ordered
23
+ group (default → baseline → `.bin`, across every candidate root).
24
+
25
+ ### Added
26
+
27
+ - **`amicus doctor` cross-install engine check.** A new "OpenCode engine (MCP launch
28
+ path)" check enumerates every install that could serve the MCP — running, global,
29
+ and each npx-cache copy — and verifies the engine in each, so a green doctor can
30
+ no longer hide a broken copy the MCP actually launches. A broken single npx copy
31
+ (the unambiguous failure) is an error; ambiguous cases warn and name the exact
32
+ path.
33
+ - The runtime `engineMissing` error now prints the roots it searched, making an
34
+ npx-cache-vs-global install divergence visible at the point of failure.
35
+
8
36
  ## [3.2.0] - 2026-07-16
9
37
 
10
38
  ### Added
package/README.md CHANGED
@@ -319,7 +319,7 @@ $ amicus status demo123 --json
319
319
  "taskId": "demo123",
320
320
  "status": "complete",
321
321
  "elapsed": "5m 0s",
322
- "version": "3.2.0",
322
+ "version": "3.2.1",
323
323
  "model": "google/gemini-2.5-flash",
324
324
  "phase": "terminal"
325
325
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -4,6 +4,9 @@
4
4
  const HINTS = require('./utils/remediation-hints');
5
5
  // B14/4.3: 'mcp' + 'mcp-legacy' check bodies (mirrors the B15 tmpSweep split — see file header).
6
6
  const mcpChecks = require('./utils/doctor-mcp-checks');
7
+ // engine-mcp check body — verifies the engine in the npx-cache copies the MCP
8
+ // actually launches (bug report #1). Split out to keep this file under the gate.
9
+ const engineCheck = require('./utils/doctor-engine-check');
7
10
 
8
11
  const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
9
12
 
@@ -36,6 +39,9 @@ function realDeps() {
36
39
  ensureNodeModulesBinInPath();
37
40
  return hasOpencodeBinary();
38
41
  },
42
+ // engine-mcp check: probe the engine in every install that could serve the
43
+ // MCP (running/global/npx-cache), not just the one doctor runs from (#1).
44
+ scanEngineInstalls: () => require('./utils/engine-install-scan').scanEngineInstalls(),
39
45
  getElectronPath: () => require('./sidecar/interactive-process').getElectronPath(),
40
46
  // #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
41
47
  // stays separate; repair only runs when fix is requested.
@@ -141,6 +147,11 @@ async function runDoctorChecks(depsOverride = {}) {
141
147
  : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngineAv }
142
148
  )));
143
149
 
150
+ // Cross-install: verify the engine in the npx-cache copies the MCP actually
151
+ // launches (`npx -y amicus@latest mcp`), so a green 'opencode-bin' (the running
152
+ // install) can't hide a broken copy the MCP would spawn (bug report #1/#4).
153
+ checks.push(guard('engine-mcp', 'OpenCode engine (MCP launch path)', () => engineCheck.evaluateEngineInstalls(d)));
154
+
144
155
  checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
145
156
  if (d.getElectronPath()) {
146
157
  return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
@@ -636,7 +636,11 @@ async function startServer(options = {}) {
636
636
  || require('./utils/path-setup').hasOpencodeBinary;
637
637
  if (!hasOpencodeBinary()) {
638
638
  const HINTS = require('./utils/remediation-hints');
639
- throw new Error(HINTS.engineMissing);
639
+ // Append the roots we actually probed so an npx-cache-vs-global divergence is
640
+ // visible at the point of failure, not just in `amicus doctor` (report #4).
641
+ const opencodeRoots = options._opencodeRoots
642
+ || require('./utils/path-setup').opencodeRoots;
643
+ throw new Error(`${HINTS.engineMissing} Searched: ${opencodeRoots().join(', ')}`);
640
644
  }
641
645
 
642
646
  const createOpencodeServer = await getCreateOpencodeServer();
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @module utils/doctor-engine-check
3
+ * The `engine-mcp` doctor check ("OpenCode engine (MCP launch path)"), split out
4
+ * of src/cli-handlers-doctor.js to keep that file under the 300-line gate
5
+ * (mirrors doctor-mcp-checks.js).
6
+ *
7
+ * The existing `opencode-bin` check verifies the engine in the RUNNING install.
8
+ * This one verifies the copies the MCP actually launches from — the npx-cache
9
+ * installs `npx -y amicus@latest mcp` resolves to — so a green doctor can no
10
+ * longer hide a broken npx copy (bug report #1). Reporting only; no self-heal.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const HINTS = require('./remediation-hints');
16
+
17
+ const plural = (n, one, many) => (n === 1 ? one : many);
18
+
19
+ /**
20
+ * @param {{scanEngineInstalls: () => {installs:Array, mcpLaunch:string}}} d
21
+ * @returns {{id,name,status,message,hint}}
22
+ */
23
+ function evaluateEngineInstalls(d) {
24
+ const id = 'engine-mcp';
25
+ const name = 'OpenCode engine (MCP launch path)';
26
+ const { installs, mcpLaunch } = d.scanEngineInstalls();
27
+
28
+ if (mcpLaunch === 'none') {
29
+ return { id, name, status: 'ok', message: 'no amicus MCP registered — not checked', hint: null };
30
+ }
31
+ if (mcpLaunch === 'path') {
32
+ return {
33
+ id, name, status: 'ok',
34
+ message: 'MCP launches from a fixed path — covered by the OpenCode binary check', hint: null,
35
+ };
36
+ }
37
+
38
+ // 'npx' (and the 'unknown' fallback): verify the npx-cache copies, the ones
39
+ // subject to optional-dependency skips and AV quarantine on every re-resolve.
40
+ const npxCopies = installs.filter((i) => i.kind === 'npx');
41
+ if (npxCopies.length === 0) {
42
+ return {
43
+ id, name, status: 'warn',
44
+ message: 'MCP launches via npx; no cached copy to inspect yet — run one fanout, then re-run doctor',
45
+ hint: null,
46
+ };
47
+ }
48
+
49
+ const broken = npxCopies.filter((i) => !i.engineOk);
50
+ if (broken.length === 0) {
51
+ return {
52
+ id, name, status: 'ok',
53
+ message: `engine present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,
54
+ hint: null,
55
+ };
56
+ }
57
+
58
+ const detail = broken
59
+ .map((i) => `${i.pkgDir} (searched: ${(i.roots || []).join(', ')})`)
60
+ .join('; ');
61
+ // Exactly one npx copy and it is broken → unambiguous: that IS the copy the
62
+ // MCP will launch, and every call will fail. Elsewhere the hash npx selects is
63
+ // ambiguous, so warn (still naming the exact broken path) rather than error.
64
+ const status = npxCopies.length === 1 ? 'error' : 'warn';
65
+ const lead = status === 'error'
66
+ ? 'engine missing from the npx-cache copy the MCP launches'
67
+ : `engine missing from ${broken.length}/${npxCopies.length} npx-cache copies`;
68
+ return { id, name, status, message: `${lead}: ${detail}`, hint: HINTS.reinstallEngineAv };
69
+ }
70
+
71
+ module.exports = { evaluateEngineInstalls };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @module utils/engine-install-scan
3
+ * Discover + probe every amicus install that could serve the MCP (running,
4
+ * global, npx-cache), so `amicus doctor` verifies the copy the MCP actually
5
+ * launches — not just the copy doctor happens to run from.
6
+ *
7
+ * The MCP is registered as `npx -y amicus@latest mcp` (scripts/postinstall.js),
8
+ * so it runs from an npx-cache copy, while `amicus doctor` typically inspects the
9
+ * global install on PATH. When those diverge, doctor can report the engine
10
+ * "found" (global) while the npx copy the MCP launches is broken and every call
11
+ * fails — the bug report's green-while-broken defect (#1). This enumerates
12
+ * running + global + each npx-cache copy and probes the opencode engine in each
13
+ * via the #69 dual-root resolver.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ /** Default npm cache dir: %LocalAppData%/npm-cache on win32, else ~/.npm. */
22
+ function defaultNpmCacheDir(platform) {
23
+ if (platform === 'win32') {
24
+ const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
25
+ return path.join(local, 'npm-cache');
26
+ }
27
+ return path.join(os.homedir(), '.npm');
28
+ }
29
+
30
+ /** Best-effort `npm root -g`. Never throws; returns null on any failure. */
31
+ function defaultNpmRootG() {
32
+ try {
33
+ const { execFileSync } = require('child_process');
34
+ const out = execFileSync('npm', ['root', '-g'], {
35
+ encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'],
36
+ });
37
+ return String(out).trim() || null;
38
+ } catch (_e) {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** Run fn, swallowing any throw and returning fallback. */
44
+ function safe(fn, fallback) {
45
+ try { return fn(); } catch (_e) { return fallback; }
46
+ }
47
+
48
+ /** Drop installs whose pkgDir resolves to the same real path; keep the first. */
49
+ function dedupByRealpath(installs, fs) {
50
+ const seen = new Set();
51
+ const out = [];
52
+ for (const inst of installs) {
53
+ const real = safe(() => fs.realpathSync(inst.pkgDir), inst.pkgDir);
54
+ const key = path.normalize(real);
55
+ if (seen.has(key)) { continue; }
56
+ seen.add(key);
57
+ out.push(inst);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * The amicus installs that could serve the MCP, highest-priority first
64
+ * (running, global, then npx-cache copies). All I/O behind seams.
65
+ *
66
+ * @param {object} [deps]
67
+ * @param {object} [deps.fs] - fs module (existsSync/readdirSync/realpathSync)
68
+ * @param {string} [deps.platform] - process.platform override
69
+ * @param {string} [deps.runningPkgDir] - this process's amicus package root
70
+ * @param {string} [deps.npmCacheDir] - npm cache dir holding _npx/
71
+ * @param {() => (string|null)} [deps.npmRootG] - resolver for `npm root -g`
72
+ * @returns {Array<{kind:string, pkgDir:string}>}
73
+ */
74
+ function listAmicusInstalls(deps = {}) {
75
+ const fs = deps.fs || require('fs');
76
+ const platform = deps.platform || process.platform;
77
+ const runningPkgDir = deps.runningPkgDir || path.join(__dirname, '..', '..');
78
+ const npmCacheDir = deps.npmCacheDir || defaultNpmCacheDir(platform);
79
+ const npmRootG = deps.npmRootG || defaultNpmRootG;
80
+
81
+ const raw = [{ kind: 'running', pkgDir: runningPkgDir }];
82
+
83
+ // Global — best-effort; `npm root -g` → <root>/amicus. Never fails the scan.
84
+ const gRoot = safe(() => npmRootG(), null);
85
+ if (gRoot) {
86
+ const gDir = path.join(gRoot, 'amicus');
87
+ if (safe(() => fs.existsSync(gDir), false)) {
88
+ raw.push({ kind: 'global', pkgDir: gDir });
89
+ }
90
+ }
91
+
92
+ // npx caches — <cache>/_npx/<hash>/node_modules/amicus for each hash present.
93
+ const npxRoot = path.join(npmCacheDir, '_npx');
94
+ for (const hash of safe(() => fs.readdirSync(npxRoot), [])) {
95
+ const pkgDir = path.join(npxRoot, hash, 'node_modules', 'amicus');
96
+ if (safe(() => fs.existsSync(pkgDir), false)) {
97
+ raw.push({ kind: 'npx', pkgDir });
98
+ }
99
+ }
100
+
101
+ return dedupByRealpath(raw, fs);
102
+ }
103
+
104
+ /**
105
+ * Classify the MCP launch method from the amicus registration config.
106
+ * @param {{command?:string, args?:unknown[]}|null|undefined} config
107
+ * @returns {'npx'|'path'|'none'|'unknown'}
108
+ */
109
+ function classifyLaunch(config) {
110
+ const { isAmicusMcpConfig, normalizeToken } = require('./mcp-self-identity');
111
+ if (!config || typeof config !== 'object') { return 'none'; }
112
+ if (config.command && normalizeToken(config.command) === 'npx') { return 'npx'; }
113
+ if (isAmicusMcpConfig(config)) { return 'path'; }
114
+ return 'unknown';
115
+ }
116
+
117
+ /**
118
+ * Enumerate serving installs, probe the engine in each, and classify how the
119
+ * MCP launches.
120
+ *
121
+ * @param {object} [deps] - listAmicusInstalls seams, plus:
122
+ * @param {(d:{pkgDir:string}) => boolean} [deps.hasOpencodeBinary]
123
+ * @param {(d:{pkgDir:string}) => string[]} [deps.opencodeRoots]
124
+ * @param {() => (object|null)} [deps.readAmicusMcpConfig]
125
+ * @returns {{installs: Array<{kind,pkgDir,engineOk,roots}>, mcpLaunch: string}}
126
+ */
127
+ function scanEngineInstalls(deps = {}) {
128
+ const hasOpencodeBinary = deps.hasOpencodeBinary || require('./path-setup').hasOpencodeBinary;
129
+ const opencodeRoots = deps.opencodeRoots || require('./path-setup').opencodeRoots;
130
+ const readAmicusMcpConfig = deps.readAmicusMcpConfig
131
+ || (() => require('./mcp-discovery').readAmicusMcpConfig());
132
+
133
+ const installs = listAmicusInstalls(deps).map((i) => ({
134
+ ...i,
135
+ engineOk: !!hasOpencodeBinary({ pkgDir: i.pkgDir }),
136
+ roots: opencodeRoots({ pkgDir: i.pkgDir }),
137
+ }));
138
+ const mcpLaunch = classifyLaunch(safe(() => readAmicusMcpConfig(), null));
139
+ return { installs, mcpLaunch };
140
+ }
141
+
142
+ module.exports = { listAmicusInstalls, scanEngineInstalls, classifyLaunch };
@@ -226,6 +226,32 @@ function discoverCoworkMcps(configDir) {
226
226
  }
227
227
  }
228
228
 
229
+ /**
230
+ * The RAW amicus MCP registration config (never stripped) from either client —
231
+ * the first entry keyed 'amicus' or whose command/args resolve to an amicus MCP
232
+ * launch (isAmicusMcpConfig). Used by the doctor engine-scan to classify HOW the
233
+ * MCP launches (npx vs a fixed path). Distinct from hasAmicusRegistration (which
234
+ * returns only a boolean and reads Claude Code alone).
235
+ *
236
+ * @param {string} [claudeDir] - ~/.claude directory (for testing)
237
+ * @param {string} [claudeJsonPath] - ~/.claude.json path (for testing)
238
+ * @param {string} [coworkConfigDir] - Claude Desktop config dir (for testing)
239
+ * @returns {object|null} The amicus server config, or null if none is registered
240
+ */
241
+ function readAmicusMcpConfig(claudeDir, claudeJsonPath, coworkConfigDir) {
242
+ const sources = [
243
+ readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath),
244
+ discoverCoworkMcps(coworkConfigDir),
245
+ ];
246
+ for (const servers of sources) {
247
+ if (!servers || typeof servers !== 'object') { continue; }
248
+ for (const [name, config] of Object.entries(servers)) {
249
+ if (name === 'amicus' || isAmicusMcpConfig(config)) { return config; }
250
+ }
251
+ }
252
+ return null;
253
+ }
254
+
229
255
  /**
230
256
  * Discover MCP servers from the parent LLM's configuration.
231
257
  *
@@ -248,5 +274,6 @@ module.exports = {
248
274
  discoverClaudeCodeMcps,
249
275
  discoverCoworkMcps,
250
276
  hasAmicusRegistration,
277
+ readAmicusMcpConfig,
251
278
  normalizeMcpJson
252
279
  };
@@ -1,39 +1,84 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
3
 
4
+ /**
5
+ * The node_modules roots that may hold the opencode engine sub-packages.
6
+ *
7
+ * npm only NESTS a dependency under <pkg>/node_modules when it cannot hoist it.
8
+ * `npm i -g amicus` nests opencode-windows-*; `npx -y amicus@latest` — which is
9
+ * exactly how postinstall registers the MCP server — HOISTS it to a sibling of
10
+ * amicus/, leaving <pkg>/node_modules nonexistent. Both are valid installs, so
11
+ * probe both roots rather than assuming a layout. Assuming the nested one made a
12
+ * present, runnable engine read as missing and threw engineMissing on every
13
+ * fanout leg under npx (#69).
14
+ *
15
+ * Order is search priority: nested (amicus's own copy) before hoisted.
16
+ *
17
+ * @param {object} [deps] - test seams
18
+ * @param {string} [deps.nodeModulesRoot] - exact root override (wins outright)
19
+ * @param {string} [deps.pkgDir] - amicus package dir override
20
+ * @returns {string[]} candidate node_modules roots
21
+ */
22
+ function opencodeRoots(deps = {}) {
23
+ if (deps.nodeModulesRoot) {
24
+ return [deps.nodeModulesRoot];
25
+ }
26
+ const pkgDir = deps.pkgDir || path.join(__dirname, '..', '..');
27
+ return [
28
+ path.join(pkgDir, 'node_modules'), // nested — npm i -g
29
+ path.dirname(pkgDir), // hoisted — npx/pnpm: the node_modules holding amicus
30
+ ];
31
+ }
32
+
4
33
  /**
5
34
  * Ensures that the project's node_modules/.bin directory is included in the PATH,
6
35
  * and on Windows also adds the platform-specific native opencode binary directory
7
36
  * so that `spawn('opencode', ...)` without shell:true can resolve the .exe.
8
37
  * The OpenCode SDK spawns the 'opencode' command, and this ensures it can be found.
38
+ * Walks every candidate root so hoisted installs resolve too (#69).
39
+ *
40
+ * PATH is a first-match search, so the array below IS the priority order — built
41
+ * highest-priority-first, then prepended as ONE group. Two orderings matter and
42
+ * both were once inverted by prepending one dir at a time (LIFO reverses intent):
43
+ * - default (AVX2) before -baseline: an AVX2 machine must run the fast build,
44
+ * with baseline only as the older-CPU fallback.
45
+ * - every native .exe dir before any .bin: on Windows .bin holds a .cmd shim
46
+ * that bare spawn() cannot execute, so a real .exe must always win — even a
47
+ * .exe in the other candidate root beats a shim in this one.
48
+ * Hence the three tiers: all defaults, then all baselines, then all .bin — each
49
+ * tier spanning every root in opencodeRoots() priority order.
50
+ *
51
+ * @param {object} [deps] - test seams, forwarded to opencodeRoots()
52
+ * @param {string} [deps.nodeModulesRoot] - exact root override (wins outright)
53
+ * @param {string} [deps.pkgDir] - amicus package dir override
9
54
  */
10
- function ensureNodeModulesBinInPath() {
11
- const nodeModulesRoot = path.join(__dirname, '..', '..', 'node_modules');
12
- const nodeModulesBin = path.join(nodeModulesRoot, '.bin');
55
+ function ensureNodeModulesBinInPath(deps = {}) {
56
+ const roots = opencodeRoots(deps);
57
+ const dirs = [];
13
58
 
14
- if (!process.env.PATH.includes(nodeModulesBin)) {
15
- process.env.PATH = `${nodeModulesBin}${path.delimiter}${process.env.PATH}`;
16
- }
17
-
18
- // On Windows, Node's spawn() does not execute .cmd shims without shell:true.
19
- // Add the platform-specific native binary directory so `opencode` resolves
20
- // to opencode.exe directly (Windows searches PATHEXT-aware when .exe is present).
21
59
  if (os.platform() === 'win32') {
22
60
  const archMap = { x64: 'x64', arm64: 'arm64' };
23
61
  const arch = archMap[os.arch()] || os.arch();
24
- const nativeBin = path.join(nodeModulesRoot, `opencode-windows-${arch}`, 'bin');
25
- if (!process.env.PATH.includes(nativeBin)) {
26
- process.env.PATH = `${nativeBin}${path.delimiter}${process.env.PATH}`;
62
+ // Tier 1: default (AVX2) build in every root.
63
+ for (const root of roots) {
64
+ dirs.push(path.join(root, `opencode-windows-${arch}`, 'bin'));
27
65
  }
28
- // Baseline variant: the default build needs AVX2; opencode ships a
29
- // -baseline (pre-AVX2) build for older CPUs. Windows resolves the first
30
- // PATH entry containing a real opencode.exe, so default-before-baseline
31
- // order matters — do not delete this block as "dead code".
32
- const nativeBinBaseline = path.join(nodeModulesRoot, `opencode-windows-${arch}-baseline`, 'bin');
33
- if (!process.env.PATH.includes(nativeBinBaseline)) {
34
- process.env.PATH = `${nativeBinBaseline}${path.delimiter}${process.env.PATH}`;
66
+ // Tier 2: -baseline (pre-AVX2) fallback in every root searched only after
67
+ // every default build. Do not delete these entries as "dead code".
68
+ for (const root of roots) {
69
+ dirs.push(path.join(root, `opencode-windows-${arch}-baseline`, 'bin'));
35
70
  }
36
71
  }
72
+ // Tier 3: node_modules/.bin in every root, last (its shims spawn() cannot run).
73
+ for (const root of roots) {
74
+ dirs.push(path.join(root, '.bin'));
75
+ }
76
+
77
+ // Prepend the not-yet-present dirs as one group, preserving the order above.
78
+ const missing = dirs.filter((dir) => !process.env.PATH.includes(dir));
79
+ if (missing.length > 0) {
80
+ process.env.PATH = `${missing.join(path.delimiter)}${path.delimiter}${process.env.PATH}`;
81
+ }
37
82
  }
38
83
 
39
84
  /**
@@ -48,6 +93,8 @@ function ensureNodeModulesBinInPath() {
48
93
  *
49
94
  * Mirrors the PATH resolution order: on Windows the platform sub-package (and
50
95
  * its -baseline variant) bin/opencode.exe; elsewhere node_modules/.bin/opencode.
96
+ * Probes every candidate root (nested AND hoisted — see opencodeRoots), so a
97
+ * layout npm chose for us can never masquerade as a missing engine (#69).
51
98
  * Never throws — a probe failure reads as not-found.
52
99
  *
53
100
  * @param {object} [deps] - test seams
@@ -55,19 +102,24 @@ function ensureNodeModulesBinInPath() {
55
102
  * @param {string} [deps.platform] - process.platform override
56
103
  * @param {string} [deps.arch] - os.arch() override
57
104
  * @param {string} [deps.nodeModulesRoot] - node_modules root override
105
+ * @param {string} [deps.pkgDir] - amicus package dir override
58
106
  * @returns {boolean} true only when a real opencode binary resolves on disk
59
107
  */
60
108
  function hasOpencodeBinary(deps = {}) {
61
109
  const fs = deps.fs || require('fs');
62
110
  const platform = deps.platform || process.platform;
63
111
  const arch = deps.arch || os.arch();
64
- const root = deps.nodeModulesRoot || path.join(__dirname, '..', '..', 'node_modules');
65
112
 
66
113
  const a = arch === 'arm64' ? 'arm64' : 'x64';
67
- const candidates = platform === 'win32'
68
- ? [path.join(root, `opencode-windows-${a}`, 'bin', 'opencode.exe'),
69
- path.join(root, `opencode-windows-${a}-baseline`, 'bin', 'opencode.exe')]
70
- : [path.join(root, '.bin', 'opencode')];
114
+ const candidates = [];
115
+ for (const root of opencodeRoots(deps)) {
116
+ if (platform === 'win32') {
117
+ candidates.push(path.join(root, `opencode-windows-${a}`, 'bin', 'opencode.exe'));
118
+ candidates.push(path.join(root, `opencode-windows-${a}-baseline`, 'bin', 'opencode.exe'));
119
+ } else {
120
+ candidates.push(path.join(root, '.bin', 'opencode'));
121
+ }
122
+ }
71
123
 
72
124
  return candidates.some((p) => { try { return fs.existsSync(p); } catch (_e) { return false; } });
73
125
  }
@@ -75,4 +127,5 @@ function hasOpencodeBinary(deps = {}) {
75
127
  module.exports = {
76
128
  ensureNodeModulesBinInPath,
77
129
  hasOpencodeBinary,
130
+ opencodeRoots,
78
131
  };