amicus 3.1.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +1 -1
- package/electron/ipc-setup.js +33 -0
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-keys-script.js +8 -1
- package/electron/setup-ui-provider-default.js +101 -0
- package/electron/setup-ui-styles.js +13 -1
- package/electron/setup-ui.js +5 -1
- package/package.json +1 -1
- package/src/cli-handlers-doctor.js +11 -0
- package/src/cli-handlers-run.js +9 -1
- package/src/cli-handlers.js +40 -0
- package/src/opencode-client.js +5 -1
- package/src/sidecar/setup.js +80 -11
- package/src/utils/config.js +59 -0
- package/src/utils/curated-models.js +4 -2
- package/src/utils/doctor-engine-check.js +71 -0
- package/src/utils/engine-install-scan.js +142 -0
- package/src/utils/mcp-discovery.js +27 -0
- package/src/utils/model-tiers.js +120 -0
- package/src/utils/path-setup.js +78 -25
- package/src/utils/provider-default-picker.js +234 -0
- package/src/utils/provider-default-prompt.js +118 -0
- package/src/utils/route-launch.js +116 -36
- package/src/utils/start-helpers.js +53 -0
package/src/utils/config.js
CHANGED
|
@@ -419,6 +419,60 @@ function markMigrationNotified(vendor) {
|
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Existing-user one-time onboarding offer (Part 2, Task 9). Mirrors
|
|
424
|
+
* markMigrationNotified's flag pattern: a single boolean persisted at
|
|
425
|
+
* config.routing.tier_onboarded once the notice has fired, so it never
|
|
426
|
+
* repeats.
|
|
427
|
+
* @returns {boolean} true once the notice has fired
|
|
428
|
+
*/
|
|
429
|
+
function hasTierOnboarded() {
|
|
430
|
+
const config = loadConfig() || {};
|
|
431
|
+
return !!(config.routing && config.routing.tier_onboarded === true);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Persist the one-time onboarding-notice flag, preserving any other routing
|
|
436
|
+
* keys (prefer, tier, migration_notified). Best-effort: swallows any
|
|
437
|
+
* saveConfig failure so a persistence hiccup never breaks the command that
|
|
438
|
+
* triggered it (mirrors markMigrationNotified).
|
|
439
|
+
*/
|
|
440
|
+
function markTierOnboarded() {
|
|
441
|
+
try {
|
|
442
|
+
const config = loadConfig() || {};
|
|
443
|
+
if (!config.routing || typeof config.routing !== 'object') { config.routing = {}; }
|
|
444
|
+
config.routing.tier_onboarded = true;
|
|
445
|
+
saveConfig(config);
|
|
446
|
+
} catch (_err) {
|
|
447
|
+
// best-effort: never fail the command over a persistence error
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Global cost-tier preference (Part 2, Task 1) — priciest-to-cheapest. */
|
|
452
|
+
const COST_TIERS = ['frontier', 'balanced', 'economy'];
|
|
453
|
+
|
|
454
|
+
/** @returns {'frontier'|'balanced'|'economy'} config.routing.tier, defaulting/coercing to 'balanced' */
|
|
455
|
+
function getCostTier() {
|
|
456
|
+
const config = loadConfig() || {};
|
|
457
|
+
const tier = config.routing && config.routing.tier;
|
|
458
|
+
return COST_TIERS.includes(tier) ? tier : 'balanced';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Persist the global cost-tier preference under routing.tier, preserving any
|
|
463
|
+
* other routing keys (prefer, migration_notified).
|
|
464
|
+
* @param {string} tier one of COST_TIERS
|
|
465
|
+
* @throws {Error} when tier is not a recognized cost tier
|
|
466
|
+
*/
|
|
467
|
+
function setCostTier(tier) {
|
|
468
|
+
if (!COST_TIERS.includes(tier)) {
|
|
469
|
+
throw new Error(`Invalid cost tier '${tier}'. Must be one of: ${COST_TIERS.join(', ')}`);
|
|
470
|
+
}
|
|
471
|
+
const config = loadConfig() || {};
|
|
472
|
+
config.routing = { ...(config.routing || {}), tier };
|
|
473
|
+
saveConfig(config);
|
|
474
|
+
}
|
|
475
|
+
|
|
422
476
|
module.exports = {
|
|
423
477
|
getConfigDir,
|
|
424
478
|
getConfigPath,
|
|
@@ -440,4 +494,9 @@ module.exports = {
|
|
|
440
494
|
getRoutingConfig,
|
|
441
495
|
resolveGatewayMode,
|
|
442
496
|
markMigrationNotified,
|
|
497
|
+
COST_TIERS,
|
|
498
|
+
getCostTier,
|
|
499
|
+
setCostTier,
|
|
500
|
+
hasTierOnboarded,
|
|
501
|
+
markTierOnboarded,
|
|
443
502
|
};
|
|
@@ -160,8 +160,10 @@ function listCuratedRoutes() {
|
|
|
160
160
|
* versioning, distinct model names, etc.). NEVER derive a direct form for
|
|
161
161
|
* these — derivation would emit the wrong (dot) id, or invent a direct id
|
|
162
162
|
* for a model that is OpenRouter-only today (e.g. fable).
|
|
163
|
+
* Frozen so consumers can only read it (`.has()`) — a frozen Set still
|
|
164
|
+
* supports lookups, it just can't be `.add()`/`.delete()`/`.clear()`-ed.
|
|
163
165
|
*/
|
|
164
|
-
const DIVERGENT_VENDORS = new Set(['anthropic']);
|
|
166
|
+
const DIVERGENT_VENDORS = Object.freeze(new Set(['anthropic']));
|
|
165
167
|
|
|
166
168
|
/**
|
|
167
169
|
* @param {string} orRoute e.g. 'openrouter/anthropic/claude-sonnet-5'
|
|
@@ -212,5 +214,5 @@ function toGatewayRoutes() {
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
module.exports = {
|
|
215
|
-
getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes
|
|
217
|
+
getFamilies, toDefaultAliases, toCanonicalDefault, listCuratedRoutes, toGatewayRoutes, DIVERGENT_VENDORS
|
|
216
218
|
};
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-vendor cost tiers (economy/balanced/frontier) + resolution against the
|
|
3
|
+
* live model catalog.
|
|
4
|
+
*
|
|
5
|
+
* Tier ordering: frontier = MOST expensive/capable, economy = CHEAPEST,
|
|
6
|
+
* balanced = the middle ground.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors quick-picks.pickCurrent's "newest live-catalog id matching a
|
|
9
|
+
* pattern in a vendor namespace" approach rather than reinventing it: each
|
|
10
|
+
* tier's regex is matched over the model segment in BOTH the direct
|
|
11
|
+
* namespace (`<vendor>/<model>`) and the OpenRouter namespace
|
|
12
|
+
* (`openrouter/<vendor>/<model>`); the direct-namespace pick wins when both
|
|
13
|
+
* exist, since storage is direct-first (see gateway-router.js).
|
|
14
|
+
*
|
|
15
|
+
* Gateway-only vendors (no direct API integration — provider-registry
|
|
16
|
+
* `isDirectProvider` false) have no tier regexes here: OpenRouter is their
|
|
17
|
+
* only route, and curated-models' CARDLESS/family-fallback entries don't
|
|
18
|
+
* carry a per-alias match pattern the way FAMILIES does, so there is no
|
|
19
|
+
* live-catalog rule to reuse for them. All three tiers resolve to the same
|
|
20
|
+
* curated flagship — the static canonical pin `toDefaultAliases()` already
|
|
21
|
+
* maintains for that vendor's alias (the same pin `resolveQuickPicks` falls
|
|
22
|
+
* back to when live resolution is unavailable).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const { pickCurrent } = require('./quick-picks');
|
|
28
|
+
const { isDirectProvider } = require('./provider-registry');
|
|
29
|
+
const { toDefaultAliases, listCuratedRoutes } = require('./curated-models');
|
|
30
|
+
|
|
31
|
+
/** Tier regex table: pattern matches the model segment after `<vendor>/` (or `openrouter/<vendor>/`). */
|
|
32
|
+
const TIERS = {
|
|
33
|
+
anthropic: {
|
|
34
|
+
economy: /^claude-haiku-/,
|
|
35
|
+
balanced: /^claude-sonnet-/,
|
|
36
|
+
frontier: /^claude-opus-/,
|
|
37
|
+
},
|
|
38
|
+
openai: {
|
|
39
|
+
economy: /^gpt-[\d.]+-mini$/,
|
|
40
|
+
balanced: /^gpt-[\d.]+$/,
|
|
41
|
+
frontier: /^gpt-[\d.]+-pro$/,
|
|
42
|
+
},
|
|
43
|
+
google: {
|
|
44
|
+
economy: /^gemini-[\d.]+-flash-lite/,
|
|
45
|
+
balanced: /^gemini-[\d.]+-flash(?!-lite)/,
|
|
46
|
+
frontier: /^gemini-[\d.]+-pro/,
|
|
47
|
+
},
|
|
48
|
+
deepseek: {
|
|
49
|
+
economy: /^deepseek-v[\d.]+$/,
|
|
50
|
+
balanced: /^deepseek-v[\d.]+$/,
|
|
51
|
+
frontier: /^deepseek-v[\d.]+-pro$/,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Fallback preference order when the requested tier's pattern matches nothing. */
|
|
56
|
+
const TIER_ORDER = ['economy', 'balanced', 'frontier'];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* vendor -> curated alias, for vendors with no direct integration and no
|
|
60
|
+
* TIERS entry. Built once from curated-models' OpenRouter-routed entries;
|
|
61
|
+
* the first alias found per vendor wins (e.g. 'qwen' over its
|
|
62
|
+
* 'qwen-coder'/'qwen-flash' siblings, since CARDLESS lists it first).
|
|
63
|
+
* @returns {Object<string,string>}
|
|
64
|
+
*/
|
|
65
|
+
function buildGatewayOnlyAliasMap() {
|
|
66
|
+
const map = {};
|
|
67
|
+
for (const { alias, provider, model } of listCuratedRoutes()) {
|
|
68
|
+
if (provider !== 'openrouter' || !model.startsWith('openrouter/')) { continue; }
|
|
69
|
+
const rest = model.slice('openrouter/'.length); // '<vendor>/<rest...>'
|
|
70
|
+
const slash = rest.indexOf('/');
|
|
71
|
+
const vendor = slash > 0 ? rest.slice(0, slash) : null;
|
|
72
|
+
if (!vendor || isDirectProvider(vendor) || TIERS[vendor] || map[vendor]) { continue; }
|
|
73
|
+
map[vendor] = alias;
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const GATEWAY_ONLY_ALIAS = buildGatewayOnlyAliasMap();
|
|
79
|
+
|
|
80
|
+
/** Newest catalog id matching `regex` under `vendor`; direct namespace preferred over OpenRouter's. */
|
|
81
|
+
function pickForTier(catalog, vendor, regex) {
|
|
82
|
+
return pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True when the catalog has ANY row (any tier) under this vendor's namespace, in either gateway. */
|
|
86
|
+
function vendorHasModels(catalog, vendor) {
|
|
87
|
+
return Boolean(pickForTier(catalog, vendor, /./));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} vendor
|
|
92
|
+
* @param {'economy'|'balanced'|'frontier'} tier
|
|
93
|
+
* @param {Array<{id:string}>} catalog
|
|
94
|
+
* @returns {string|null} current live-catalog full id for vendor+tier
|
|
95
|
+
* (e.g. `anthropic/claude-sonnet-5`), or null when the vendor is unknown
|
|
96
|
+
* or absent from the catalog.
|
|
97
|
+
*/
|
|
98
|
+
function resolveTier(vendor, tier, catalog) {
|
|
99
|
+
if (typeof vendor !== 'string' || !vendor) { return null; }
|
|
100
|
+
if (!TIER_ORDER.includes(tier)) { return null; }
|
|
101
|
+
|
|
102
|
+
const gatewayAlias = GATEWAY_ONLY_ALIAS[vendor];
|
|
103
|
+
if (gatewayAlias) { return toDefaultAliases()[gatewayAlias] || null; }
|
|
104
|
+
|
|
105
|
+
const table = TIERS[vendor];
|
|
106
|
+
if (!table) { return null; }
|
|
107
|
+
|
|
108
|
+
const direct = pickForTier(catalog, vendor, table[tier]);
|
|
109
|
+
if (direct) { return direct; }
|
|
110
|
+
|
|
111
|
+
if (!vendorHasModels(catalog, vendor)) { return null; }
|
|
112
|
+
|
|
113
|
+
for (const t of TIER_ORDER) {
|
|
114
|
+
const pick = pickForTier(catalog, vendor, table[t]);
|
|
115
|
+
if (pick) { return pick; }
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { TIERS, resolveTier };
|
package/src/utils/path-setup.js
CHANGED
|
@@ -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
|
|
12
|
-
const
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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 =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
};
|